Add Mellat e-payment, videos/instructions CMS, and legacy domain lookup.
Store gateway credentials per business, initiate/verify Mellat at checkout, and resolve old CMS ids from domains in the migrate modal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
513673a7da
commit
d5e502cf77
@@ -14,6 +14,9 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
# API
|
# API
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Public base URL used for bank payment callbacks (no trailing slash)
|
||||||
|
# Production: https://api.meshkee.com
|
||||||
|
API_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars-long
|
JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars-long
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Videos module (embedded YouTube / Aparat)
|
||||||
|
|
||||||
|
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'video';
|
||||||
|
|
||||||
|
CREATE TYPE video_provider AS ENUM ('youtube', 'aparat');
|
||||||
|
|
||||||
|
CREATE TABLE videos (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL,
|
||||||
|
author_id BIGINT,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
title_fa VARCHAR(255),
|
||||||
|
title_en VARCHAR(255),
|
||||||
|
slug VARCHAR(255) NOT NULL,
|
||||||
|
excerpt TEXT,
|
||||||
|
content JSONB NOT NULL DEFAULT '{}',
|
||||||
|
video_provider video_provider NOT NULL,
|
||||||
|
embed_code TEXT NOT NULL,
|
||||||
|
status content_status NOT NULL DEFAULT 'draft',
|
||||||
|
featured_media_id BIGINT,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT videos_business_slug_unique UNIQUE (business_id, slug),
|
||||||
|
CONSTRAINT videos_business_id_fkey
|
||||||
|
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT videos_author_id_fkey
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT videos_featured_media_id_fkey
|
||||||
|
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_videos_business_id ON videos (business_id);
|
||||||
|
CREATE INDEX idx_videos_business_status ON videos (business_id, status);
|
||||||
|
CREATE INDEX idx_videos_business_published ON videos (business_id, published_at DESC);
|
||||||
|
CREATE INDEX idx_videos_author_id ON videos (author_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER videos_set_updated_at
|
||||||
|
BEFORE UPDATE ON videos
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- Permissions
|
||||||
|
INSERT INTO permissions (name, slug, group_name, description) VALUES
|
||||||
|
('View videos', 'videos.read', 'videos', 'View videos'),
|
||||||
|
('Create videos', 'videos.create', 'videos', 'Create videos'),
|
||||||
|
('Update videos', 'videos.update', 'videos', 'Edit videos'),
|
||||||
|
('Delete videos', 'videos.delete', 'videos', 'Delete videos'),
|
||||||
|
('Publish videos', 'videos.publish', 'videos', 'Publish and unpublish videos')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'videos.%'
|
||||||
|
WHERE r.slug IN ('business_owner', 'owner', 'admin')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug IN ('videos.read', 'videos.create', 'videos.update', 'videos.publish')
|
||||||
|
WHERE r.slug = 'editor'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug = 'videos.read'
|
||||||
|
WHERE r.slug = 'viewer'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE videos ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS videos_business_old_id_unique
|
||||||
|
ON videos (business_id, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos (old_id);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Instructions module (rich text + optional embedded video)
|
||||||
|
|
||||||
|
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'instruction';
|
||||||
|
|
||||||
|
CREATE TABLE instructions (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL,
|
||||||
|
author_id BIGINT,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
title_fa VARCHAR(255),
|
||||||
|
title_en VARCHAR(255),
|
||||||
|
slug VARCHAR(255) NOT NULL,
|
||||||
|
excerpt TEXT,
|
||||||
|
content JSONB NOT NULL DEFAULT '{}',
|
||||||
|
video_provider video_provider,
|
||||||
|
embed_code TEXT,
|
||||||
|
status content_status NOT NULL DEFAULT 'draft',
|
||||||
|
featured_media_id BIGINT,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
old_id BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT instructions_business_slug_unique UNIQUE (business_id, slug),
|
||||||
|
CONSTRAINT instructions_business_old_id_unique UNIQUE (business_id, old_id),
|
||||||
|
CONSTRAINT instructions_business_id_fkey
|
||||||
|
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT instructions_author_id_fkey
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT instructions_featured_media_id_fkey
|
||||||
|
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_instructions_business_id ON instructions (business_id);
|
||||||
|
CREATE INDEX idx_instructions_business_status ON instructions (business_id, status);
|
||||||
|
CREATE INDEX idx_instructions_business_published ON instructions (business_id, published_at DESC);
|
||||||
|
CREATE INDEX idx_instructions_author_id ON instructions (author_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER instructions_set_updated_at
|
||||||
|
BEFORE UPDATE ON instructions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
INSERT INTO permissions (name, slug, group_name, description) VALUES
|
||||||
|
('View instructions', 'instructions.read', 'instructions', 'View instructions'),
|
||||||
|
('Create instructions', 'instructions.create', 'instructions', 'Create instructions'),
|
||||||
|
('Update instructions', 'instructions.update', 'instructions', 'Edit instructions'),
|
||||||
|
('Delete instructions', 'instructions.delete', 'instructions', 'Delete instructions'),
|
||||||
|
('Publish instructions', 'instructions.publish', 'instructions', 'Publish and unpublish instructions')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'instructions.%'
|
||||||
|
WHERE r.slug IN ('business_owner', 'owner', 'admin')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug IN ('instructions.read', 'instructions.create', 'instructions.update', 'instructions.publish')
|
||||||
|
WHERE r.slug = 'editor'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug = 'instructions.read'
|
||||||
|
WHERE r.slug = 'viewer'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Meshkee CMS — e-payment gateway tracking on transactions
|
||||||
|
|
||||||
|
ALTER TABLE transactions
|
||||||
|
ADD COLUMN IF NOT EXISTS gateway_ref VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS gateway_track_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS gateway_meta JSONB;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_gateway_ref
|
||||||
|
ON transactions (business_id, gateway_ref)
|
||||||
|
WHERE gateway_ref IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_gateway_track
|
||||||
|
ON transactions (business_id, gateway_track_id)
|
||||||
|
WHERE gateway_track_id IS NOT NULL;
|
||||||
+12
-2
@@ -342,7 +342,16 @@ Base: `/tenants/:host/user-products`
|
|||||||
| PATCH | `/cart/items/:itemId` | Update cart item quantity |
|
| PATCH | `/cart/items/:itemId` | Update cart item quantity |
|
||||||
| DELETE | `/cart/items/:itemId` | Remove cart item |
|
| DELETE | `/cart/items/:itemId` | Remove cart item |
|
||||||
| DELETE | `/cart` | Clear cart |
|
| DELETE | `/cart` | Clear cart |
|
||||||
| POST | `/cart/checkout` | Place order from cart (requires `addressId` or `shippingAddress`) |
|
| POST | `/cart/checkout` | Place order from cart (`addressId` or `shippingAddress` + `payment`). For `e_payment_gate`, also send absolute `returnUrl`; response includes `payment.redirect` for bank redirect |
|
||||||
|
|
||||||
|
#### Payments (public bank callbacks + methods)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/payments/methods` | Enabled gateways for this business (no secrets). Also on `GET /tenants/:host` as `ePayment` |
|
||||||
|
| POST/GET | `/payments/:gateway/callback` | Bank return URL (Mellat first). Verifies/settles, then 303 → `returnUrl?status=…` |
|
||||||
|
|
||||||
|
Gateway credentials live in `businesses.settings.store.ePayment` (dashboard: Website → E-Payment). Adapter registry supports Mellat now; stubs reserved for `sep`, `snappay`, `digipay`, `zarinpal`.
|
||||||
|
|
||||||
#### Orders
|
#### Orders
|
||||||
|
|
||||||
@@ -584,7 +593,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Database | `DATABASE_URL`, `POSTGRES_*` |
|
| Database | `DATABASE_URL`, `POSTGRES_*` |
|
||||||
| Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` |
|
| Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` |
|
||||||
| API | `PORT` |
|
| API | `PORT`, `API_PUBLIC_BASE_URL` (bank payment callbacks) |
|
||||||
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
|
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
|
||||||
| SMS | `SMS_ENABLED`, `SMS_GAMA_BASE_URL`, `SMS_GAMA_USERNAME`, `SMS_GAMA_PASSWORD`, `SMS_GAMA_SOURCE_SERVICE`, `SMS_PARTNERS` |
|
| SMS | `SMS_ENABLED`, `SMS_GAMA_BASE_URL`, `SMS_GAMA_USERNAME`, `SMS_GAMA_PASSWORD`, `SMS_GAMA_SOURCE_SERVICE`, `SMS_PARTNERS` |
|
||||||
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
||||||
@@ -608,6 +617,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- Store items / product variants (price, stock, SKU)
|
||||||
- Shopping cart + checkout + orders (customer + admin)
|
- Shopping cart + checkout + orders (customer + admin)
|
||||||
|
- Online e-payment (Mellat first; multi-gateway registry for SEP / Snapp Pay / DigiPay / Zarinpal)
|
||||||
- Product technical info
|
- Product technical info
|
||||||
- Category variations & technical forms
|
- Category variations & technical forms
|
||||||
- Tenant resolution by domain
|
- Tenant resolution by domain
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
||||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||||
|
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl`
|
||||||
|
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
|
||||||
|
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
|
||||||
|
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
||||||
|
|
||||||
### User products (customer listings)
|
### User products (customer listings)
|
||||||
Public marketplace listings owned by customers — not catalog `products`.
|
Public marketplace listings owned by customers — not catalog `products`.
|
||||||
|
|||||||
@@ -767,6 +767,113 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Videos",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List published videos (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" if (json.items?.[0]?.id) pm.collectionVariables.set('videoId', json.items[0].id);",
|
||||||
|
" if (json.items?.[0]?.slug) pm.collectionVariables.set('videoSlug', json.items[0].slug);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/tenants/{{domain}}/videos?page=1&pageSize=12",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"tenants",
|
||||||
|
"{{domain}}",
|
||||||
|
"videos"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "page",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pageSize",
|
||||||
|
"value": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "categoryId",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "title",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get published video by slug (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoSlug}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List video comments (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoId}}/comments"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Submit video comment (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200 || pm.response.code === 201) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"authorName\": \"Video Viewer\",\n \"authorEmail\": \"viewer@example.com\",\n \"text\": \"Great video!\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoId}}/comments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Portfolios",
|
"name": "Portfolios",
|
||||||
"item": [
|
"item": [
|
||||||
@@ -1526,7 +1633,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Checkout cart (saved address)",
|
"name": "Checkout cart (e-payment / Mellat)",
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "test",
|
"listen": "test",
|
||||||
@@ -1555,9 +1662,59 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"zarinpal\"\n }\n}"
|
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"returnUrl\": \"https://YOUR_WEBSITE_DOMAIN/checkout/result\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"mellat\"\n }\n}"
|
||||||
},
|
},
|
||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout"
|
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout",
|
||||||
|
"description": "Creates a pending order + transaction, calls the gateway, and returns payment.redirect { method, url, fields }. Website must POST/redirect the shopper to the bank. After payment, bank hits /payments/{gateway}/callback which redirects to returnUrl?status=success|failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Payments",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List payment methods",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/methods",
|
||||||
|
"description": "Public. Returns enabled gateways (no secrets). Also available on GET /tenants/{host} as ePayment."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mellat callback (bank → API)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "urlencoded",
|
||||||
|
"urlencoded": [
|
||||||
|
{
|
||||||
|
"key": "ResCode",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleOrderId",
|
||||||
|
"value": "{{transactionId}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleReferenceId",
|
||||||
|
"value": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "RefId",
|
||||||
|
"value": "{{refId}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
||||||
|
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+2086
-372
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,8 @@ model User {
|
|||||||
ordersCreated Order[] @relation("OrderCreator")
|
ordersCreated Order[] @relation("OrderCreator")
|
||||||
orders Order[] @relation("OrderCustomer")
|
orders Order[] @relation("OrderCustomer")
|
||||||
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||||
|
authoredVideos videos[] @relation("VideoAuthor")
|
||||||
|
authoredInstructions instructions[] @relation("InstructionAuthor")
|
||||||
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
|
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
|
||||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||||
@@ -93,6 +95,8 @@ model Business {
|
|||||||
mediaAttachments MediaAttachment[]
|
mediaAttachments MediaAttachment[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
portfolios portfolios[]
|
portfolios portfolios[]
|
||||||
|
videos videos[]
|
||||||
|
instructions instructions[]
|
||||||
productTechnicalFieldValues ProductTechnicalFieldValue[]
|
productTechnicalFieldValues ProductTechnicalFieldValue[]
|
||||||
products Product[]
|
products Product[]
|
||||||
shoppingCards ShoppingCard[]
|
shoppingCards ShoppingCard[]
|
||||||
@@ -283,6 +287,8 @@ model Media {
|
|||||||
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
||||||
attachments MediaAttachment[]
|
attachments MediaAttachment[]
|
||||||
portfolios portfolios[]
|
portfolios portfolios[]
|
||||||
|
videos videos[]
|
||||||
|
instructions instructions[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_slider_slides website_slider_slides[]
|
website_slider_slides website_slider_slides[]
|
||||||
@@ -671,6 +677,68 @@ model portfolios {
|
|||||||
@@index([author_id], map: "idx_portfolios_author_id")
|
@@index([author_id], map: "idx_portfolios_author_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model videos {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
business_id BigInt
|
||||||
|
author_id BigInt?
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
title_fa String? @db.VarChar(255)
|
||||||
|
title_en String? @db.VarChar(255)
|
||||||
|
slug String @db.VarChar(255)
|
||||||
|
excerpt String?
|
||||||
|
content Json @default("{}")
|
||||||
|
video_provider VideoProvider
|
||||||
|
embed_code String
|
||||||
|
status ContentStatus @default(draft)
|
||||||
|
featured_media_id BigInt?
|
||||||
|
published_at DateTime? @db.Timestamptz(6)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
old_id BigInt?
|
||||||
|
author User? @relation("VideoAuthor", fields: [author_id], references: [id], onUpdate: NoAction)
|
||||||
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([business_id, slug], map: "videos_business_slug_unique")
|
||||||
|
@@unique([business_id, old_id], map: "videos_business_old_id_unique")
|
||||||
|
@@index([business_id], map: "idx_videos_business_id")
|
||||||
|
@@index([business_id, published_at(sort: Desc)], map: "idx_videos_business_published")
|
||||||
|
@@index([business_id, status], map: "idx_videos_business_status")
|
||||||
|
@@index([author_id], map: "idx_videos_author_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
model instructions {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
business_id BigInt
|
||||||
|
author_id BigInt?
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
title_fa String? @db.VarChar(255)
|
||||||
|
title_en String? @db.VarChar(255)
|
||||||
|
slug String @db.VarChar(255)
|
||||||
|
excerpt String?
|
||||||
|
content Json @default("{}")
|
||||||
|
video_provider VideoProvider?
|
||||||
|
embed_code String?
|
||||||
|
status ContentStatus @default(draft)
|
||||||
|
featured_media_id BigInt?
|
||||||
|
published_at DateTime? @db.Timestamptz(6)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
old_id BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
author User? @relation("InstructionAuthor", fields: [author_id], references: [id], onUpdate: NoAction)
|
||||||
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([business_id, slug], map: "instructions_business_slug_unique")
|
||||||
|
@@unique([business_id, old_id], map: "instructions_business_old_id_unique")
|
||||||
|
@@index([business_id], map: "idx_instructions_business_id")
|
||||||
|
@@index([business_id, published_at(sort: Desc)], map: "idx_instructions_business_published")
|
||||||
|
@@index([business_id, status], map: "idx_instructions_business_status")
|
||||||
|
@@index([author_id], map: "idx_instructions_author_id")
|
||||||
|
}
|
||||||
|
|
||||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
model Address {
|
model Address {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
@@ -938,6 +1006,9 @@ model Transaction {
|
|||||||
gatewayType String? @map("gateway_type") @db.VarChar(100)
|
gatewayType String? @map("gateway_type") @db.VarChar(100)
|
||||||
transferAccount String? @map("transfer_account") @db.VarChar(255)
|
transferAccount String? @map("transfer_account") @db.VarChar(255)
|
||||||
transferRefNumber String? @map("transfer_ref_number") @db.VarChar(100)
|
transferRefNumber String? @map("transfer_ref_number") @db.VarChar(100)
|
||||||
|
gatewayRef String? @map("gateway_ref") @db.VarChar(255)
|
||||||
|
gatewayTrackId String? @map("gateway_track_id") @db.VarChar(255)
|
||||||
|
gatewayMeta Json? @map("gateway_meta")
|
||||||
notes String?
|
notes String?
|
||||||
createdBy BigInt? @map("created_by")
|
createdBy BigInt? @map("created_by")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
@@ -1340,10 +1411,19 @@ enum MediaEntityType {
|
|||||||
portfolio
|
portfolio
|
||||||
customer
|
customer
|
||||||
user_product
|
user_product
|
||||||
|
video
|
||||||
|
instruction
|
||||||
|
|
||||||
@@map("media_entity_type")
|
@@map("media_entity_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VideoProvider {
|
||||||
|
youtube
|
||||||
|
aparat
|
||||||
|
|
||||||
|
@@map("video_provider")
|
||||||
|
}
|
||||||
|
|
||||||
enum VariationType {
|
enum VariationType {
|
||||||
color
|
color
|
||||||
size
|
size
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { CategoriesModule } from './categories/categories.module';
|
|||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { BlogsModule } from './blogs/blogs.module';
|
import { BlogsModule } from './blogs/blogs.module';
|
||||||
import { PortfoliosModule } from './portfolios/portfolios.module';
|
import { PortfoliosModule } from './portfolios/portfolios.module';
|
||||||
|
import { VideosModule } from './videos/videos.module';
|
||||||
|
import { InstructionsModule } from './instructions/instructions.module';
|
||||||
import { CommentsModule } from './comments/comments.module';
|
import { CommentsModule } from './comments/comments.module';
|
||||||
import { CitiesModule } from './cities/cities.module';
|
import { CitiesModule } from './cities/cities.module';
|
||||||
import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module';
|
import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module';
|
||||||
@@ -36,6 +38,7 @@ import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
|||||||
import { InvoicesModule } from './invoices/invoices.module';
|
import { InvoicesModule } from './invoices/invoices.module';
|
||||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||||
import { PublicSmsModule } from './public-sms/public-sms.module';
|
import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -58,6 +61,8 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
|
|||||||
ProductsModule,
|
ProductsModule,
|
||||||
BlogsModule,
|
BlogsModule,
|
||||||
PortfoliosModule,
|
PortfoliosModule,
|
||||||
|
VideosModule,
|
||||||
|
InstructionsModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
ExpertReviewsModule,
|
ExpertReviewsModule,
|
||||||
BusinessSettingsModule,
|
BusinessSettingsModule,
|
||||||
@@ -66,6 +71,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
|
|||||||
StoreModule,
|
StoreModule,
|
||||||
CartModule,
|
CartModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
|
PaymentsModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
ShoppingCardsModule,
|
ShoppingCardsModule,
|
||||||
ContactSubmissionsModule,
|
ContactSubmissionsModule,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UpdateDomainDto } from './dto/update-domain.dto';
|
|||||||
import { DisableBusinessDto } from './dto/disable-business.dto';
|
import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||||
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||||
|
import { LegacyMigrateLookupDto } from './dto/legacy-migrate-lookup.dto';
|
||||||
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||||
import { BusinessAdminService } from './business-admin.service';
|
import { BusinessAdminService } from './business-admin.service';
|
||||||
|
|
||||||
@@ -35,6 +36,16 @@ export class BusinessAdminController {
|
|||||||
return this.service.listStaff(businessId, user);
|
return this.service.listStaff(businessId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':businessId/migrate-from-old/lookup')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
lookupLegacyMigrate(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: LegacyMigrateLookupDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.lookupLegacyMigrate(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':businessId')
|
@Get(':businessId')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { UpdateBusinessDto } from './dto/update-business.dto';
|
|||||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||||
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||||
|
import { LegacyMigrateLookupDto } from './dto/legacy-migrate-lookup.dto';
|
||||||
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||||
import { LegacyMigrateService } from './legacy-migrate.service';
|
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||||
import { LegacyPurgeService } from './legacy-purge.service';
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
@@ -676,6 +677,50 @@ export class BusinessAdminService {
|
|||||||
return { message: 'Business removed' };
|
return { message: 'Business removed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async lookupLegacyMigrate(
|
||||||
|
businessIdRaw: string,
|
||||||
|
query: LegacyMigrateLookupDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
domains: {
|
||||||
|
select: { host: true, isPrimary: true },
|
||||||
|
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshkeeHosts = business.domains.map((d) => d.host);
|
||||||
|
const lookup = await this.legacyMigrate.lookupLegacyBusiness({
|
||||||
|
meshkeeHosts,
|
||||||
|
oldBusinessId: query.oldBusinessId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
meshkeeHosts,
|
||||||
|
meshkeeBusinessName: business.name,
|
||||||
|
oldBusinessId:
|
||||||
|
lookup.oldBusinessId != null ? String(lookup.oldBusinessId) : null,
|
||||||
|
domains: lookup.domains,
|
||||||
|
brandEn: lookup.brandEn,
|
||||||
|
brandFa: lookup.brandFa,
|
||||||
|
slug: lookup.slug,
|
||||||
|
matchedHost: lookup.matchedHost,
|
||||||
|
source: lookup.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async migrateFromOld(businessIdRaw: string, dto: MigrateFromOldDto, actor: AuthUser) {
|
async migrateFromOld(businessIdRaw: string, dto: MigrateFromOldDto, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsPositive } from 'class-validator';
|
||||||
|
|
||||||
|
export class LegacyMigrateLookupDto {
|
||||||
|
/** When set, look up domains/brand for this old CMS business id. */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
oldBusinessId?: number;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export const MIGRATE_FROM_OLD_ENTITIES = [
|
|||||||
'customer',
|
'customer',
|
||||||
'blog_categories',
|
'blog_categories',
|
||||||
'blog',
|
'blog',
|
||||||
|
'video_categories',
|
||||||
|
'video',
|
||||||
'portfolio_categories',
|
'portfolio_categories',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MediaEntityType,
|
MediaEntityType,
|
||||||
MediaType,
|
MediaType,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
VideoProvider,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -77,6 +78,24 @@ type OldArticleRow = RowDataPacket & {
|
|||||||
updated_at: Date | null;
|
updated_at: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OldVideoRow = RowDataPacket & {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
name_en: string | null;
|
||||||
|
url_title: string | null;
|
||||||
|
abstract: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
content: string | null;
|
||||||
|
description: string | null;
|
||||||
|
embed: string | null;
|
||||||
|
verified: number | boolean | null;
|
||||||
|
publish_at: Date | null;
|
||||||
|
video_category_id: number | null;
|
||||||
|
user_id: number | null;
|
||||||
|
created_at: Date | null;
|
||||||
|
updated_at: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
type OldNewsRow = RowDataPacket & {
|
type OldNewsRow = RowDataPacket & {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -227,6 +246,7 @@ function normalizeLegacyPasswordHash(hash: string | null | undefined): string {
|
|||||||
type ContentCategoryEntity =
|
type ContentCategoryEntity =
|
||||||
| typeof MediaEntityType.portfolio
|
| typeof MediaEntityType.portfolio
|
||||||
| typeof MediaEntityType.blog
|
| typeof MediaEntityType.blog
|
||||||
|
| typeof MediaEntityType.video
|
||||||
| typeof MediaEntityType.customer
|
| typeof MediaEntityType.customer
|
||||||
| typeof MediaEntityType.product;
|
| typeof MediaEntityType.product;
|
||||||
|
|
||||||
@@ -239,6 +259,157 @@ export class LegacyMigrateService {
|
|||||||
private readonly storage: StorageService,
|
private readonly storage: StorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve legacy WillaEngine business from Meshkee hosts and/or an explicit old id.
|
||||||
|
* Old CMS `domains.domain` → `domains.business_id`.
|
||||||
|
*/
|
||||||
|
async lookupLegacyBusiness(input: {
|
||||||
|
meshkeeHosts: string[];
|
||||||
|
oldBusinessId?: number;
|
||||||
|
}): Promise<{
|
||||||
|
oldBusinessId: number | null;
|
||||||
|
domains: string[];
|
||||||
|
brandEn: string | null;
|
||||||
|
brandFa: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
matchedHost: string | null;
|
||||||
|
source: 'old_business_id' | 'meshkee_domain' | 'none';
|
||||||
|
}> {
|
||||||
|
if (input.oldBusinessId != null && input.oldBusinessId > 0) {
|
||||||
|
const detail = await this.fetchLegacyBusinessDetail(input.oldBusinessId);
|
||||||
|
if (!detail) {
|
||||||
|
return {
|
||||||
|
oldBusinessId: null,
|
||||||
|
domains: [],
|
||||||
|
brandEn: null,
|
||||||
|
brandFa: null,
|
||||||
|
slug: null,
|
||||||
|
matchedHost: null,
|
||||||
|
source: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
matchedHost: null,
|
||||||
|
source: 'old_business_id',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = this.expandHostCandidates(input.meshkeeHosts);
|
||||||
|
if (!candidates.length) {
|
||||||
|
return {
|
||||||
|
oldBusinessId: null,
|
||||||
|
domains: [],
|
||||||
|
brandEn: null,
|
||||||
|
brandFa: null,
|
||||||
|
slug: null,
|
||||||
|
matchedHost: null,
|
||||||
|
source: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = candidates.map(() => '?').join(', ');
|
||||||
|
const rows = await this.legacyMysql.query<
|
||||||
|
(RowDataPacket & {
|
||||||
|
business_id: number;
|
||||||
|
domain: string;
|
||||||
|
})[]
|
||||||
|
>(
|
||||||
|
`SELECT business_id, domain
|
||||||
|
FROM domains
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(domain) IN (${placeholders})
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 20`,
|
||||||
|
candidates,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return {
|
||||||
|
oldBusinessId: null,
|
||||||
|
domains: [],
|
||||||
|
brandEn: null,
|
||||||
|
brandFa: null,
|
||||||
|
slug: null,
|
||||||
|
matchedHost: null,
|
||||||
|
source: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldBusinessId = Number(rows[0].business_id);
|
||||||
|
const matchedHost = String(rows[0].domain);
|
||||||
|
const detail = await this.fetchLegacyBusinessDetail(oldBusinessId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldBusinessId,
|
||||||
|
domains: detail?.domains ?? rows.map((r) => String(r.domain)),
|
||||||
|
brandEn: detail?.brandEn ?? null,
|
||||||
|
brandFa: detail?.brandFa ?? null,
|
||||||
|
slug: detail?.slug ?? null,
|
||||||
|
matchedHost,
|
||||||
|
source: 'meshkee_domain',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private expandHostCandidates(hosts: string[]): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const raw of hosts) {
|
||||||
|
const host = raw.trim().toLowerCase().replace(/\.$/, '');
|
||||||
|
if (!host) continue;
|
||||||
|
const apex = host.startsWith('www.') ? host.slice(4) : host;
|
||||||
|
if (!apex) continue;
|
||||||
|
out.add(apex);
|
||||||
|
out.add(`www.${apex}`);
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLegacyBusinessDetail(oldBusinessId: number): Promise<{
|
||||||
|
oldBusinessId: number;
|
||||||
|
domains: string[];
|
||||||
|
brandEn: string | null;
|
||||||
|
brandFa: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
} | null> {
|
||||||
|
const businesses = await this.legacyMysql.query<
|
||||||
|
(RowDataPacket & {
|
||||||
|
id: number;
|
||||||
|
brand_en: string | null;
|
||||||
|
brand_fa: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
})[]
|
||||||
|
>(
|
||||||
|
`SELECT id, brand_en, brand_fa, slug
|
||||||
|
FROM businesses
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[oldBusinessId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!businesses.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = await this.legacyMysql.query<
|
||||||
|
(RowDataPacket & { domain: string })[]
|
||||||
|
>(
|
||||||
|
`SELECT domain
|
||||||
|
FROM domains
|
||||||
|
WHERE business_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[oldBusinessId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const biz = businesses[0];
|
||||||
|
return {
|
||||||
|
oldBusinessId: Number(biz.id),
|
||||||
|
domains: domains.map((d) => String(d.domain)),
|
||||||
|
brandEn: biz.brand_en ? String(biz.brand_en) : null,
|
||||||
|
brandFa: biz.brand_fa ? String(biz.brand_fa) : null,
|
||||||
|
slug: biz.slug ? String(biz.slug) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async migrateEntities(
|
async migrateEntities(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
oldBusinessId: bigint,
|
oldBusinessId: bigint,
|
||||||
@@ -275,6 +446,17 @@ export class LegacyMigrateService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (selected.has('video') && !selected.has('video_categories')) {
|
||||||
|
results.video_categories = await this.migrateNestedCategories(
|
||||||
|
businessId,
|
||||||
|
oldBusinessId,
|
||||||
|
{
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
table: 'video_categories',
|
||||||
|
hasSlug: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
if (selected.has('customer') && !selected.has('customer_categories')) {
|
if (selected.has('customer') && !selected.has('customer_categories')) {
|
||||||
results.customer_categories = await this.migrateNestedCategories(
|
results.customer_categories = await this.migrateNestedCategories(
|
||||||
businessId,
|
businessId,
|
||||||
@@ -332,6 +514,21 @@ export class LegacyMigrateService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entity === 'video_categories') {
|
||||||
|
if (!results.video_categories) {
|
||||||
|
results[entity] = await this.migrateNestedCategories(
|
||||||
|
businessId,
|
||||||
|
oldBusinessId,
|
||||||
|
{
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
table: 'video_categories',
|
||||||
|
hasSlug: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (entity === 'customer_categories') {
|
if (entity === 'customer_categories') {
|
||||||
if (!results.customer_categories) {
|
if (!results.customer_categories) {
|
||||||
results[entity] = await this.migrateNestedCategories(
|
results[entity] = await this.migrateNestedCategories(
|
||||||
@@ -375,6 +572,11 @@ export class LegacyMigrateService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entity === 'video') {
|
||||||
|
results[entity] = await this.migrateVideos(businessId, oldBusinessId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (entity === 'customer') {
|
if (entity === 'customer') {
|
||||||
results[entity] = await this.migrateCustomers(businessId, oldBusinessId);
|
results[entity] = await this.migrateCustomers(businessId, oldBusinessId);
|
||||||
continue;
|
continue;
|
||||||
@@ -413,14 +615,16 @@ export class LegacyMigrateService {
|
|||||||
table:
|
table:
|
||||||
| 'portfolio_categories'
|
| 'portfolio_categories'
|
||||||
| 'article_categories'
|
| 'article_categories'
|
||||||
|
| 'video_categories'
|
||||||
| 'client_categories'
|
| 'client_categories'
|
||||||
| 'product_categories';
|
| 'product_categories';
|
||||||
hasSlug: boolean;
|
hasSlug: boolean;
|
||||||
},
|
},
|
||||||
): Promise<MigrateEntityCounts> {
|
): Promise<MigrateEntityCounts> {
|
||||||
const slugSelect = opts.hasSlug ? 'slug' : 'NULL AS slug';
|
const slugSelect = opts.hasSlug ? 'slug' : 'NULL AS slug';
|
||||||
|
const nameEnSelect = await this.legacySelectCol(opts.table, 'name_en');
|
||||||
const rows = await this.legacyMysql.query<OldPortfolioCategoryRow[]>(
|
const rows = await this.legacyMysql.query<OldPortfolioCategoryRow[]>(
|
||||||
`SELECT id, name, name_en, parent_id, ${slugSelect}, \`_lft\` AS _lft
|
`SELECT id, name, ${nameEnSelect}, parent_id, ${slugSelect}, \`_lft\` AS _lft
|
||||||
FROM ${opts.table}
|
FROM ${opts.table}
|
||||||
WHERE business_id = ?
|
WHERE business_id = ?
|
||||||
ORDER BY \`_lft\` ASC`,
|
ORDER BY \`_lft\` ASC`,
|
||||||
@@ -800,6 +1004,280 @@ export class LegacyMigrateService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async migrateVideos(
|
||||||
|
businessId: bigint,
|
||||||
|
oldBusinessId: bigint,
|
||||||
|
): Promise<MigrateEntityCounts> {
|
||||||
|
const oldBizId = Number(oldBusinessId);
|
||||||
|
|
||||||
|
const bizRows = await this.legacyMysql.query<RowDataPacket[]>(
|
||||||
|
`SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`,
|
||||||
|
[oldBizId],
|
||||||
|
);
|
||||||
|
const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null;
|
||||||
|
|
||||||
|
const videoTable = 'videos';
|
||||||
|
const videoDeleted = await this.legacySoftDeleteClause(videoTable);
|
||||||
|
const videoSelect = [
|
||||||
|
'id',
|
||||||
|
await this.legacySelectCol(videoTable, 'title'),
|
||||||
|
await this.legacySelectCol(videoTable, 'name_en'),
|
||||||
|
await this.legacySelectCol(videoTable, 'url_title'),
|
||||||
|
await this.legacySelectCol(videoTable, 'abstract'),
|
||||||
|
await this.legacySelectCol(videoTable, 'summary'),
|
||||||
|
await this.legacySelectCol(videoTable, 'content'),
|
||||||
|
await this.legacySelectCol(videoTable, 'description'),
|
||||||
|
await this.legacySelectCol(videoTable, 'embed'),
|
||||||
|
await this.legacySelectCol(videoTable, 'verified'),
|
||||||
|
await this.legacySelectCol(videoTable, 'publish_at'),
|
||||||
|
await this.legacySelectCol(videoTable, 'video_category_id'),
|
||||||
|
await this.legacySelectCol(videoTable, 'user_id'),
|
||||||
|
await this.legacySelectCol(videoTable, 'created_at'),
|
||||||
|
await this.legacySelectCol(videoTable, 'updated_at'),
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
const videos = await this.legacyMysql.query<OldVideoRow[]>(
|
||||||
|
`SELECT ${videoSelect}
|
||||||
|
FROM ${videoTable}
|
||||||
|
WHERE business_id = ?${videoDeleted}
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[oldBizId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaRows = await this.legacyMysql.query<OldMediaRow[]>(
|
||||||
|
`SELECT id, model_id, collection_name, file_name, mime_type, size, order_column
|
||||||
|
FROM media
|
||||||
|
WHERE model_type = 'video'
|
||||||
|
AND model_id IN (
|
||||||
|
SELECT id FROM videos WHERE business_id = ?${videoDeleted}
|
||||||
|
)
|
||||||
|
ORDER BY model_id ASC, order_column ASC, id ASC`,
|
||||||
|
[oldBizId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pivots = await this.legacyMysql.query<
|
||||||
|
(RowDataPacket & {
|
||||||
|
video_id: number;
|
||||||
|
video_category_id: number;
|
||||||
|
})[]
|
||||||
|
>(
|
||||||
|
`SELECT video_id, video_category_id
|
||||||
|
FROM video_video_category
|
||||||
|
WHERE video_id IN (
|
||||||
|
SELECT id FROM videos WHERE business_id = ?${videoDeleted}
|
||||||
|
)`,
|
||||||
|
[oldBizId],
|
||||||
|
).catch(() => [] as (RowDataPacket & {
|
||||||
|
video_id: number;
|
||||||
|
video_category_id: number;
|
||||||
|
})[]);
|
||||||
|
|
||||||
|
const mediaByVideo = new Map<number, OldMediaRow[]>();
|
||||||
|
for (const row of mediaRows) {
|
||||||
|
const list = mediaByVideo.get(row.model_id) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
mediaByVideo.set(row.model_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriesByVideo = new Map<number, number[]>();
|
||||||
|
for (const row of pivots) {
|
||||||
|
const list = categoriesByVideo.get(row.video_id) ?? [];
|
||||||
|
list.push(row.video_category_id);
|
||||||
|
categoriesByVideo.set(row.video_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryMap = await this.loadCategoryOldIdMap(
|
||||||
|
businessId,
|
||||||
|
MediaEntityType.video,
|
||||||
|
);
|
||||||
|
const existingVideos = await this.prisma.videos.findMany({
|
||||||
|
where: { business_id: businessId, old_id: { not: null } },
|
||||||
|
select: { id: true, old_id: true, author_id: true },
|
||||||
|
});
|
||||||
|
const existingByOldId = new Map(
|
||||||
|
existingVideos
|
||||||
|
.filter((v) => v.old_id != null)
|
||||||
|
.map((v) => [
|
||||||
|
Number(v.old_id),
|
||||||
|
{ id: v.id, authorId: v.author_id },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingMedia = await this.prisma.media.findMany({
|
||||||
|
where: { businessId, oldId: { not: null } },
|
||||||
|
select: { id: true, oldId: true },
|
||||||
|
});
|
||||||
|
const mediaOldToNew = new Map<number, bigint>();
|
||||||
|
for (const row of existingMedia) {
|
||||||
|
if (row.oldId != null) {
|
||||||
|
mediaOldToNew.set(Number(row.oldId), row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeMedia = mediaRows
|
||||||
|
.filter((m) => Number(m.size) > 0)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||||
|
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
||||||
|
oldBizId,
|
||||||
|
oldSlug,
|
||||||
|
probeMedia,
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let imagesCopied = 0;
|
||||||
|
let imagesResized = 0;
|
||||||
|
let imagesFailed = 0;
|
||||||
|
const authorCache = new Map<number, bigint | null>();
|
||||||
|
|
||||||
|
for (const row of videos) {
|
||||||
|
const embedCode = (row.embed ?? '').trim();
|
||||||
|
if (!embedCode) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = await this.resolveLegacyAuthorId(row.user_id, authorCache);
|
||||||
|
const existing = existingByOldId.get(row.id);
|
||||||
|
if (existing) {
|
||||||
|
skipped += 1;
|
||||||
|
if (existing.authorId == null && authorId != null) {
|
||||||
|
await this.prisma.videos.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { author_id: authorId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleFa = (row.title ?? '').trim() || row.name_en?.trim() || 'Video';
|
||||||
|
const titleEn = row.name_en?.trim() || null;
|
||||||
|
const baseSlug =
|
||||||
|
this.normalizeSlug((row.url_title ?? '').trim()) ||
|
||||||
|
slugify(titleEn || titleFa);
|
||||||
|
const slug = await this.ensureUniqueVideoSlug(businessId, baseSlug, row.id);
|
||||||
|
|
||||||
|
const videoMedia = mediaByVideo.get(row.id) ?? [];
|
||||||
|
let featuredMediaId: bigint | null = null;
|
||||||
|
|
||||||
|
for (const media of videoMedia) {
|
||||||
|
const size = Number(media.size) || 0;
|
||||||
|
if (size <= 0) {
|
||||||
|
imagesFailed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newMediaId = mediaOldToNew.get(media.id) ?? null;
|
||||||
|
if (!newMediaId) {
|
||||||
|
try {
|
||||||
|
const copied = await this.copyLegacyMedia({
|
||||||
|
businessId,
|
||||||
|
oldMedia: media,
|
||||||
|
storagePrefix,
|
||||||
|
});
|
||||||
|
newMediaId = copied.id;
|
||||||
|
mediaOldToNew.set(media.id, newMediaId);
|
||||||
|
imagesCopied += 1;
|
||||||
|
if (copied.resized) {
|
||||||
|
imagesResized += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
imagesFailed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(media.collection_name === 'main_image' ||
|
||||||
|
media.collection_name === 'cover') &&
|
||||||
|
!featuredMediaId
|
||||||
|
) {
|
||||||
|
featuredMediaId = newMediaId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = row.created_at ? new Date(row.created_at) : new Date();
|
||||||
|
const publishedAt = row.publish_at
|
||||||
|
? new Date(row.publish_at)
|
||||||
|
: createdAt;
|
||||||
|
const isVerified = isTruthyFlag(row.verified);
|
||||||
|
const abstract = row.abstract?.trim() || row.summary?.trim() || null;
|
||||||
|
const descriptionHtml = row.content ?? row.description ?? '';
|
||||||
|
|
||||||
|
const video = await this.prisma.videos.create({
|
||||||
|
data: {
|
||||||
|
business_id: businessId,
|
||||||
|
author_id: authorId,
|
||||||
|
title: titleFa,
|
||||||
|
title_fa: titleFa,
|
||||||
|
title_en: titleEn,
|
||||||
|
slug,
|
||||||
|
excerpt: abstract,
|
||||||
|
content: {
|
||||||
|
html: descriptionHtml,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
video_provider: this.resolveVideoProviderFromEmbed(embedCode),
|
||||||
|
embed_code: embedCode,
|
||||||
|
status: isVerified ? ContentStatus.published : ContentStatus.draft,
|
||||||
|
featured_media_id: featuredMediaId,
|
||||||
|
published_at: isVerified ? publishedAt : null,
|
||||||
|
metadata: {
|
||||||
|
migratedFromOldId: row.id,
|
||||||
|
legacyTable: 'videos',
|
||||||
|
verified: isVerified,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
old_id: BigInt(row.id),
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: row.updated_at ? new Date(row.updated_at) : createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldCategoryIds = new Set<number>(
|
||||||
|
categoriesByVideo.get(row.id) ?? [],
|
||||||
|
);
|
||||||
|
if (row.video_category_id != null) {
|
||||||
|
oldCategoryIds.add(row.video_category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const oldCategoryId of oldCategoryIds) {
|
||||||
|
const newCategoryId = categoryMap.get(oldCategoryId);
|
||||||
|
if (!newCategoryId) continue;
|
||||||
|
try {
|
||||||
|
await this.prisma.categoryAssignment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
categoryId: newCategoryId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: video.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// duplicate assignment on re-run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
skipped,
|
||||||
|
total: videos.length,
|
||||||
|
imagesCopied,
|
||||||
|
imagesResized,
|
||||||
|
imagesFailed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVideoProviderFromEmbed(embedCode: string): VideoProvider {
|
||||||
|
const normalized = embedCode.toLowerCase();
|
||||||
|
if (normalized.includes('aparat.com') || normalized.includes('aparat.ir')) {
|
||||||
|
return VideoProvider.aparat;
|
||||||
|
}
|
||||||
|
return VideoProvider.youtube;
|
||||||
|
}
|
||||||
|
|
||||||
private async migrateArticles(
|
private async migrateArticles(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
oldBusinessId: bigint,
|
oldBusinessId: bigint,
|
||||||
@@ -1869,6 +2347,27 @@ export class LegacyMigrateService {
|
|||||||
return `${slug}-${Date.now()}`;
|
return `${slug}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueVideoSlug(
|
||||||
|
businessId: bigint,
|
||||||
|
baseSlug: string,
|
||||||
|
oldId: number,
|
||||||
|
): Promise<string> {
|
||||||
|
let slug = baseSlug || `video-old-${oldId}`;
|
||||||
|
const clash = await this.prisma.videos.findFirst({
|
||||||
|
where: { business_id: businessId, slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!clash) return slug;
|
||||||
|
|
||||||
|
slug = `${baseSlug}-old-${oldId}`.replace(/-+/g, '-');
|
||||||
|
const stillClash = await this.prisma.videos.findFirst({
|
||||||
|
where: { business_id: businessId, slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!stillClash) return slug;
|
||||||
|
return `${slug}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private async migrateProducts(
|
private async migrateProducts(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
oldBusinessId: bigint,
|
oldBusinessId: bigint,
|
||||||
@@ -2162,6 +2661,30 @@ export class LegacyMigrateService {
|
|||||||
return Number(rows[0]?.cnt ?? 0) > 0;
|
return Number(rows[0]?.cnt ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Column name when present, otherwise `NULL AS alias` for legacy schema drift. */
|
||||||
|
private async legacySelectCol(
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
alias = columnName,
|
||||||
|
): Promise<string> {
|
||||||
|
if (await this.legacyColumnExists(tableName, columnName)) {
|
||||||
|
return columnName;
|
||||||
|
}
|
||||||
|
return `NULL AS ${alias}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete filter when the legacy table has `deleted_at`. */
|
||||||
|
private async legacySoftDeleteClause(
|
||||||
|
tableName: string,
|
||||||
|
tableAlias?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!(await this.legacyColumnExists(tableName, 'deleted_at'))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const prefix = tableAlias ? `${tableAlias}.` : '';
|
||||||
|
return ` AND ${prefix}deleted_at IS NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
private async migrateProductCategories(
|
private async migrateProductCategories(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
oldBusinessId: bigint,
|
oldBusinessId: bigint,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PurgeBusinessDataEntity } from './dto/purge-business-data.dto';
|
|||||||
type ContentCategoryEntity =
|
type ContentCategoryEntity =
|
||||||
| typeof MediaEntityType.portfolio
|
| typeof MediaEntityType.portfolio
|
||||||
| typeof MediaEntityType.blog
|
| typeof MediaEntityType.blog
|
||||||
|
| typeof MediaEntityType.video
|
||||||
| typeof MediaEntityType.customer
|
| typeof MediaEntityType.customer
|
||||||
| typeof MediaEntityType.product;
|
| typeof MediaEntityType.product;
|
||||||
|
|
||||||
@@ -55,6 +56,12 @@ export class LegacyPurgeService {
|
|||||||
if (selected.has('blog_categories')) {
|
if (selected.has('blog_categories')) {
|
||||||
results.blog_categories = await this.purgeBlogCategories(businessId);
|
results.blog_categories = await this.purgeBlogCategories(businessId);
|
||||||
}
|
}
|
||||||
|
if (selected.has('video')) {
|
||||||
|
results.video = await this.purgeVideos(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('video_categories')) {
|
||||||
|
results.video_categories = await this.purgeVideoCategories(businessId);
|
||||||
|
}
|
||||||
if (selected.has('customer')) {
|
if (selected.has('customer')) {
|
||||||
results.customer = await this.purgeCustomers(businessId);
|
results.customer = await this.purgeCustomers(businessId);
|
||||||
}
|
}
|
||||||
@@ -279,6 +286,62 @@ export class LegacyPurgeService {
|
|||||||
return this.purgeCategories(businessId, MediaEntityType.blog);
|
return this.purgeCategories(businessId, MediaEntityType.blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async purgeVideos(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
|
const videos = await this.prisma.videos.findMany({
|
||||||
|
where: { business_id: businessId },
|
||||||
|
select: { id: true, featured_media_id: true },
|
||||||
|
});
|
||||||
|
const videoIds = videos.map((v) => v.id);
|
||||||
|
|
||||||
|
if (!videoIds.length) {
|
||||||
|
return { deleted: 0, imagesDeleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaIds = new Set<bigint>();
|
||||||
|
for (const row of videos) {
|
||||||
|
if (row.featured_media_id != null) {
|
||||||
|
mediaIds.add(row.featured_media_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.comment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: { in: videoIds },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: { in: videoIds },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.videos.updateMany({
|
||||||
|
where: { business_id: businessId, id: { in: videoIds } },
|
||||||
|
data: { featured_media_id: null },
|
||||||
|
}),
|
||||||
|
this.prisma.videos.deleteMany({
|
||||||
|
where: { business_id: businessId, id: { in: videoIds } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: videoIds.length,
|
||||||
|
imagesDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeVideoCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
return this.purgeCategories(businessId, MediaEntityType.video);
|
||||||
|
}
|
||||||
|
|
||||||
private async purgeCustomers(businessId: bigint): Promise<PurgeEntityCounts> {
|
private async purgeCustomers(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
const links = await this.prisma.businessCustomer.findMany({
|
const links = await this.prisma.businessCustomer.findMany({
|
||||||
where: { businessId },
|
where: { businessId },
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { BusinessSettings } from './business-settings.types';
|
import { BusinessSettings } from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
mergeBusinessSettings,
|
mergeBusinessSettings,
|
||||||
|
mergeEPaymentSettings,
|
||||||
|
mellatPasswordSet,
|
||||||
normalizeBusinessSettings,
|
normalizeBusinessSettings,
|
||||||
normalizeEnabledBusinessModules,
|
normalizeEnabledBusinessModules,
|
||||||
|
normalizeEPaymentSettings,
|
||||||
normalizeHomeCharts,
|
normalizeHomeCharts,
|
||||||
|
sanitizeBusinessSettingsForClient,
|
||||||
toPrismaJson,
|
toPrismaJson,
|
||||||
} from './business-settings.util';
|
} from './business-settings.util';
|
||||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||||
@@ -39,7 +43,9 @@ export class BusinessSettingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
businessId: business.id.toString(),
|
businessId: business.id.toString(),
|
||||||
settings: normalizeBusinessSettings(business.settings),
|
settings: this.toClientSettings(
|
||||||
|
normalizeBusinessSettings(business.settings),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +104,56 @@ export class BusinessSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.store) {
|
if (dto.store) {
|
||||||
|
const currentEPayment = current.store.ePayment;
|
||||||
|
const ePaymentPatch = dto.store.ePayment
|
||||||
|
? mergeEPaymentSettings(
|
||||||
|
currentEPayment,
|
||||||
|
normalizeEPaymentSettings({
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.enabled ?? currentEPayment.enabled,
|
||||||
|
defaultGateway:
|
||||||
|
dto.store.ePayment.defaultGateway !== undefined
|
||||||
|
? dto.store.ePayment.defaultGateway
|
||||||
|
: currentEPayment.defaultGateway,
|
||||||
|
gateways: {
|
||||||
|
mellat: {
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.gateways?.mellat?.enabled ??
|
||||||
|
currentEPayment.gateways.mellat.enabled,
|
||||||
|
terminalId:
|
||||||
|
dto.store.ePayment.gateways?.mellat?.terminalId ??
|
||||||
|
currentEPayment.gateways.mellat.terminalId,
|
||||||
|
username:
|
||||||
|
dto.store.ePayment.gateways?.mellat?.username ??
|
||||||
|
currentEPayment.gateways.mellat.username,
|
||||||
|
password:
|
||||||
|
dto.store.ePayment.gateways?.mellat?.password ?? '',
|
||||||
|
},
|
||||||
|
sep: {
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.gateways?.sep?.enabled ??
|
||||||
|
currentEPayment.gateways.sep.enabled,
|
||||||
|
},
|
||||||
|
snappay: {
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.gateways?.snappay?.enabled ??
|
||||||
|
currentEPayment.gateways.snappay.enabled,
|
||||||
|
},
|
||||||
|
digipay: {
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.gateways?.digipay?.enabled ??
|
||||||
|
currentEPayment.gateways.digipay.enabled,
|
||||||
|
},
|
||||||
|
zarinpal: {
|
||||||
|
enabled:
|
||||||
|
dto.store.ePayment.gateways?.zarinpal?.enabled ??
|
||||||
|
currentEPayment.gateways.zarinpal.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
patch.store = {
|
patch.store = {
|
||||||
onlineSellEnabled:
|
onlineSellEnabled:
|
||||||
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||||
@@ -106,6 +162,7 @@ export class BusinessSettingsService {
|
|||||||
store: { orderProcessSteps: dto.store.orderProcessSteps },
|
store: { orderProcessSteps: dto.store.orderProcessSteps },
|
||||||
}).store.orderProcessSteps
|
}).store.orderProcessSteps
|
||||||
: current.store.orderProcessSteps,
|
: current.store.orderProcessSteps,
|
||||||
|
ePayment: ePaymentPatch ?? current.store.ePayment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +189,7 @@ export class BusinessSettingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
businessId: updated.id.toString(),
|
businessId: updated.id.toString(),
|
||||||
settings: next,
|
settings: this.toClientSettings(next),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +226,26 @@ export class BusinessSettingsService {
|
|||||||
return settings.store.orderProcessSteps;
|
return settings.store.orderProcessSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toClientSettings(settings: BusinessSettings) {
|
||||||
|
const sanitized = sanitizeBusinessSettingsForClient(settings);
|
||||||
|
return {
|
||||||
|
...sanitized,
|
||||||
|
store: {
|
||||||
|
...sanitized.store,
|
||||||
|
ePayment: {
|
||||||
|
...sanitized.store.ePayment,
|
||||||
|
gateways: {
|
||||||
|
...sanitized.store.ePayment.gateways,
|
||||||
|
mellat: {
|
||||||
|
...sanitized.store.ePayment.gateways.mellat,
|
||||||
|
passwordSet: mellatPasswordSet(settings),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async assertPermission(
|
private async assertPermission(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
|
|||||||
@@ -38,10 +38,56 @@ export type OrderProcessStep = {
|
|||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Supported online payment gateways (extend when adding adapters). */
|
||||||
|
export const PAYMENT_GATEWAY_IDS = [
|
||||||
|
'mellat',
|
||||||
|
'sep',
|
||||||
|
'snappay',
|
||||||
|
'digipay',
|
||||||
|
'zarinpal',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaymentGatewayId = (typeof PAYMENT_GATEWAY_IDS)[number];
|
||||||
|
|
||||||
|
/** Behpardakht Mellat credentials. */
|
||||||
|
export type MellatGatewaySettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
terminalId: string;
|
||||||
|
username: string;
|
||||||
|
/** Stored secret — redacted in API responses; use passwordSet instead. */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for gateways not yet wired (SEP, Snapp Pay, DigiPay, Zarinpal).
|
||||||
|
* Credential fields are added when each adapter ships.
|
||||||
|
*/
|
||||||
|
export type StubGatewaySettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentGatewaysSettings = {
|
||||||
|
mellat: MellatGatewaySettings;
|
||||||
|
sep: StubGatewaySettings;
|
||||||
|
snappay: StubGatewaySettings;
|
||||||
|
digipay: StubGatewaySettings;
|
||||||
|
zarinpal: StubGatewaySettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Online e-payment (website checkout). */
|
||||||
|
export type EPaymentSettings = {
|
||||||
|
/** Master switch — when false, websites must not offer e-payment. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Preferred gateway when several are enabled (null = first configured). */
|
||||||
|
defaultGateway: PaymentGatewayId | null;
|
||||||
|
gateways: PaymentGatewaysSettings;
|
||||||
|
};
|
||||||
|
|
||||||
/** Per-business store / sales settings. */
|
/** Per-business store / sales settings. */
|
||||||
export type StoreSettings = {
|
export type StoreSettings = {
|
||||||
onlineSellEnabled: boolean;
|
onlineSellEnabled: boolean;
|
||||||
orderProcessSteps: OrderProcessStep[];
|
orderProcessSteps: OrderProcessStep[];
|
||||||
|
ePayment: EPaymentSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
|
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
|
||||||
@@ -52,6 +98,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
|||||||
'blog',
|
'blog',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
'videos',
|
'videos',
|
||||||
|
'instructions',
|
||||||
'finance',
|
'finance',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -134,6 +181,29 @@ export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
|||||||
'customers_joined_1y',
|
'customers_joined_1y',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_MELLAT_GATEWAY_SETTINGS: MellatGatewaySettings = {
|
||||||
|
enabled: false,
|
||||||
|
terminalId: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_STUB_GATEWAY_SETTINGS: StubGatewaySettings = {
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
|
||||||
|
enabled: false,
|
||||||
|
defaultGateway: null,
|
||||||
|
gateways: {
|
||||||
|
mellat: { ...DEFAULT_MELLAT_GATEWAY_SETTINGS },
|
||||||
|
sep: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
|
snappay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
|
digipay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
|
zarinpal: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||||
branding: {
|
branding: {
|
||||||
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||||
@@ -147,6 +217,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
store: {
|
store: {
|
||||||
onlineSellEnabled: true,
|
onlineSellEnabled: true,
|
||||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
|
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ import {
|
|||||||
DEFAULT_BUSINESS_SETTINGS,
|
DEFAULT_BUSINESS_SETTINGS,
|
||||||
DEFAULT_BUSINESS_THEME_MODE,
|
DEFAULT_BUSINESS_THEME_MODE,
|
||||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
DEFAULT_EPAYMENT_SETTINGS,
|
||||||
DEFAULT_HOME_CHARTS,
|
DEFAULT_HOME_CHARTS,
|
||||||
|
DEFAULT_MELLAT_GATEWAY_SETTINGS,
|
||||||
DEFAULT_ORDER_PROCESS_STEPS,
|
DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
|
DEFAULT_STUB_GATEWAY_SETTINGS,
|
||||||
|
EPaymentSettings,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
|
MellatGatewaySettings,
|
||||||
|
PAYMENT_GATEWAY_IDS,
|
||||||
type DashboardLocale,
|
type DashboardLocale,
|
||||||
type DashboardThemeMode,
|
type DashboardThemeMode,
|
||||||
type HomeChartId,
|
type HomeChartId,
|
||||||
|
type PaymentGatewayId,
|
||||||
OrderProcessStep,
|
OrderProcessStep,
|
||||||
|
StubGatewaySettings,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
defaultOrderStepColor,
|
defaultOrderStepColor,
|
||||||
@@ -80,6 +88,190 @@ function readBoolean(value: unknown, fallback: boolean) {
|
|||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown, fallback = '') {
|
||||||
|
return typeof value === 'string' ? value.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaymentGatewayId(value: unknown): value is PaymentGatewayId {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(PAYMENT_GATEWAY_IDS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMellatGateway(value: unknown): MellatGatewaySettings {
|
||||||
|
const source = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
enabled: readBoolean(
|
||||||
|
source.enabled,
|
||||||
|
DEFAULT_MELLAT_GATEWAY_SETTINGS.enabled,
|
||||||
|
),
|
||||||
|
terminalId: readString(
|
||||||
|
source.terminalId,
|
||||||
|
DEFAULT_MELLAT_GATEWAY_SETTINGS.terminalId,
|
||||||
|
),
|
||||||
|
username: readString(
|
||||||
|
source.username,
|
||||||
|
DEFAULT_MELLAT_GATEWAY_SETTINGS.username,
|
||||||
|
),
|
||||||
|
password:
|
||||||
|
typeof source.password === 'string'
|
||||||
|
? source.password
|
||||||
|
: DEFAULT_MELLAT_GATEWAY_SETTINGS.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStubGateway(value: unknown): StubGatewaySettings {
|
||||||
|
const source = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
enabled: readBoolean(
|
||||||
|
source.enabled,
|
||||||
|
DEFAULT_STUB_GATEWAY_SETTINGS.enabled,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
|
||||||
|
const source = isRecord(value) ? value : {};
|
||||||
|
const gateways = isRecord(source.gateways) ? source.gateways : {};
|
||||||
|
const defaultGateway = isPaymentGatewayId(source.defaultGateway)
|
||||||
|
? source.defaultGateway
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: readBoolean(source.enabled, DEFAULT_EPAYMENT_SETTINGS.enabled),
|
||||||
|
defaultGateway,
|
||||||
|
gateways: {
|
||||||
|
mellat: readMellatGateway(gateways.mellat),
|
||||||
|
sep: readStubGateway(gateways.sep),
|
||||||
|
snappay: readStubGateway(gateways.snappay),
|
||||||
|
digipay: readStubGateway(gateways.digipay),
|
||||||
|
zarinpal: readStubGateway(gateways.zarinpal),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge e-payment patch; empty mellat password keeps the current secret. */
|
||||||
|
export function mergeEPaymentSettings(
|
||||||
|
current: EPaymentSettings,
|
||||||
|
patch: EPaymentSettings | undefined,
|
||||||
|
): EPaymentSettings {
|
||||||
|
if (!patch) return current;
|
||||||
|
|
||||||
|
const nextPassword = patch.gateways.mellat.password;
|
||||||
|
const password =
|
||||||
|
typeof nextPassword === 'string' && nextPassword.length > 0
|
||||||
|
? nextPassword
|
||||||
|
: current.gateways.mellat.password;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: patch.enabled,
|
||||||
|
defaultGateway: patch.defaultGateway,
|
||||||
|
gateways: {
|
||||||
|
mellat: {
|
||||||
|
enabled: patch.gateways.mellat.enabled,
|
||||||
|
terminalId: patch.gateways.mellat.terminalId.trim(),
|
||||||
|
username: patch.gateways.mellat.username.trim(),
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
sep: { enabled: patch.gateways.sep.enabled },
|
||||||
|
snappay: { enabled: patch.gateways.snappay.enabled },
|
||||||
|
digipay: { enabled: patch.gateways.digipay.enabled },
|
||||||
|
zarinpal: { enabled: patch.gateways.zarinpal.enabled },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redact gateway secrets for dashboard API responses. */
|
||||||
|
export function sanitizeBusinessSettingsForClient(
|
||||||
|
settings: BusinessSettings,
|
||||||
|
): BusinessSettings {
|
||||||
|
const mellat = settings.store.ePayment.gateways.mellat;
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
store: {
|
||||||
|
...settings.store,
|
||||||
|
ePayment: {
|
||||||
|
...settings.store.ePayment,
|
||||||
|
gateways: {
|
||||||
|
...settings.store.ePayment.gateways,
|
||||||
|
mellat: {
|
||||||
|
...mellat,
|
||||||
|
password: '',
|
||||||
|
// Clients treat non-empty passwordSet via a parallel field — see
|
||||||
|
// get/update response shaping in BusinessSettingsService.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mellatPasswordSet(settings: BusinessSettings): boolean {
|
||||||
|
return settings.store.ePayment.gateways.mellat.password.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a gateway is enabled and has enough credentials to initiate. */
|
||||||
|
export function isGatewayReady(
|
||||||
|
settings: EPaymentSettings,
|
||||||
|
gatewayId: PaymentGatewayId,
|
||||||
|
): boolean {
|
||||||
|
if (!settings.enabled) return false;
|
||||||
|
|
||||||
|
switch (gatewayId) {
|
||||||
|
case 'mellat': {
|
||||||
|
const g = settings.gateways.mellat;
|
||||||
|
return (
|
||||||
|
g.enabled &&
|
||||||
|
g.terminalId.length > 0 &&
|
||||||
|
g.username.length > 0 &&
|
||||||
|
g.password.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'sep':
|
||||||
|
case 'snappay':
|
||||||
|
case 'digipay':
|
||||||
|
case 'zarinpal':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPublicPaymentGateways(settings: EPaymentSettings) {
|
||||||
|
const labels: Record<
|
||||||
|
PaymentGatewayId,
|
||||||
|
{ label: string; labelFa: string }
|
||||||
|
> = {
|
||||||
|
mellat: { label: 'Bank Mellat', labelFa: 'بانک ملت' },
|
||||||
|
sep: { label: 'SEP (Saman)', labelFa: 'سپ (سامان)' },
|
||||||
|
snappay: { label: 'Snapp Pay', labelFa: 'اسنپپی' },
|
||||||
|
digipay: { label: 'DigiPay', labelFa: 'دیجیپی' },
|
||||||
|
zarinpal: { label: 'Zarinpal', labelFa: 'زرینپال' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const gateways = PAYMENT_GATEWAY_IDS.filter((id) =>
|
||||||
|
isGatewayReady(settings, id),
|
||||||
|
).map((id) => ({
|
||||||
|
id,
|
||||||
|
...labels[id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let defaultGateway = settings.defaultGateway;
|
||||||
|
if (defaultGateway && !gateways.some((g) => g.id === defaultGateway)) {
|
||||||
|
defaultGateway = null;
|
||||||
|
}
|
||||||
|
if (!defaultGateway && gateways.length) {
|
||||||
|
defaultGateway = gateways[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: settings.enabled && gateways.length > 0,
|
||||||
|
defaultGateway,
|
||||||
|
gateways,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return DEFAULT_ORDER_PROCESS_STEPS;
|
return DEFAULT_ORDER_PROCESS_STEPS;
|
||||||
@@ -152,6 +344,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
|
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
|
||||||
),
|
),
|
||||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||||
|
ePayment: normalizeEPaymentSettings(store.ePayment),
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
||||||
@@ -194,6 +387,10 @@ export function mergeBusinessSettings(
|
|||||||
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||||
orderProcessSteps:
|
orderProcessSteps:
|
||||||
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
|
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
|
||||||
|
ePayment: mergeEPaymentSettings(
|
||||||
|
current.store.ePayment,
|
||||||
|
patch.store?.ePayment,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors';
|
import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors';
|
||||||
@@ -15,6 +16,7 @@ import { BUSINESS_PRIMARY_COLOR_IDS } from '../business-primary-colors';
|
|||||||
import {
|
import {
|
||||||
BUSINESS_MODULE_IDS,
|
BUSINESS_MODULE_IDS,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
|
PAYMENT_GATEWAY_IDS,
|
||||||
} from '../business-settings.types';
|
} from '../business-settings.types';
|
||||||
|
|
||||||
class BrandingSettingsDto {
|
class BrandingSettingsDto {
|
||||||
@@ -77,6 +79,74 @@ class OrderProcessStepDto {
|
|||||||
color!: string;
|
color!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MellatGatewaySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
terminalId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
/** Omit or send empty to keep the existing password. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubGatewaySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentGatewaysSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => MellatGatewaySettingsDto)
|
||||||
|
mellat?: MellatGatewaySettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => StubGatewaySettingsDto)
|
||||||
|
sep?: StubGatewaySettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => StubGatewaySettingsDto)
|
||||||
|
snappay?: StubGatewaySettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => StubGatewaySettingsDto)
|
||||||
|
digipay?: StubGatewaySettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => StubGatewaySettingsDto)
|
||||||
|
zarinpal?: StubGatewaySettingsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EPaymentSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== undefined)
|
||||||
|
@IsIn([...PAYMENT_GATEWAY_IDS])
|
||||||
|
defaultGateway?: (typeof PAYMENT_GATEWAY_IDS)[number] | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PaymentGatewaysSettingsDto)
|
||||||
|
gateways?: PaymentGatewaysSettingsDto;
|
||||||
|
}
|
||||||
|
|
||||||
class StoreSettingsDto {
|
class StoreSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -87,6 +157,11 @@ class StoreSettingsDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => OrderProcessStepDto)
|
@Type(() => OrderProcessStepDto)
|
||||||
orderProcessSteps?: OrderProcessStepDto[];
|
orderProcessSteps?: OrderProcessStepDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => EPaymentSettingsDto)
|
||||||
|
ePayment?: EPaymentSettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModulesSettingsDto {
|
class ModulesSettingsDto {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { OrdersModule } from '../orders/orders.module';
|
import { OrdersModule } from '../orders/orders.module';
|
||||||
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
import { CartController } from './cart.controller';
|
import { CartController } from './cart.controller';
|
||||||
import { CartService } from './cart.service';
|
import { CartService } from './cart.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, OrdersModule],
|
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
|
||||||
controllers: [CartController],
|
controllers: [CartController],
|
||||||
providers: [CartService],
|
providers: [CartService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
UpdateCartItemDto,
|
UpdateCartItemDto,
|
||||||
} from './dto/cart.dto';
|
} from './dto/cart.dto';
|
||||||
import { OrdersService } from '../orders/orders.service';
|
import { OrdersService } from '../orders/orders.service';
|
||||||
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
|
import { TransactionType } from '@prisma/client';
|
||||||
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
|
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||||
|
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||||
|
|
||||||
const cartVariantInclude = {
|
const cartVariantInclude = {
|
||||||
storeItem: {
|
storeItem: {
|
||||||
@@ -52,6 +57,8 @@ export class CartService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly orders: OrdersService,
|
private readonly orders: OrdersService,
|
||||||
|
private readonly payments: PaymentsService,
|
||||||
|
private readonly businessSettings: BusinessSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||||
@@ -193,6 +200,30 @@ export class CartService {
|
|||||||
dto.shippingAddress,
|
dto.shippingAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
||||||
|
let gatewayType: PaymentGatewayId | undefined;
|
||||||
|
|
||||||
|
if (isEPayment) {
|
||||||
|
if (!dto.returnUrl?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'returnUrl is required for e-payment checkout',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings =
|
||||||
|
await this.businessSettings.getNormalizedSettings(businessId);
|
||||||
|
const publicMethods = listPublicPaymentGateways(settings.store.ePayment);
|
||||||
|
if (!publicMethods.enabled) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Online payment is not enabled for this store',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gatewayType = this.payments.resolveGatewayType(
|
||||||
|
dto.payment.gatewayType,
|
||||||
|
publicMethods,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.orders.createFromCart({
|
const order = await this.orders.createFromCart({
|
||||||
businessId,
|
businessId,
|
||||||
userId: actor.id,
|
userId: actor.id,
|
||||||
@@ -207,13 +238,38 @@ export class CartService {
|
|||||||
payment: {
|
payment: {
|
||||||
type: dto.payment.type,
|
type: dto.payment.type,
|
||||||
posType: dto.payment.posType,
|
posType: dto.payment.posType,
|
||||||
gatewayType: dto.payment.gatewayType,
|
gatewayType: isEPayment ? gatewayType : dto.payment.gatewayType,
|
||||||
transferAccount: dto.payment.transferAccount,
|
transferAccount: dto.payment.transferAccount,
|
||||||
transferRefNumber: dto.payment.transferRefNumber,
|
transferRefNumber: dto.payment.transferRefNumber,
|
||||||
notes: dto.payment.notes?.trim() || null,
|
notes: dto.payment.notes?.trim() || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isEPayment && gatewayType) {
|
||||||
|
const payment = await this.payments.initiateForCheckout({
|
||||||
|
businessId,
|
||||||
|
order: {
|
||||||
|
id: order.id,
|
||||||
|
total: order.total,
|
||||||
|
transactions: order.transactions,
|
||||||
|
},
|
||||||
|
gatewayType,
|
||||||
|
returnUrl: dto.returnUrl!.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Redirect customer to payment gateway',
|
||||||
|
order,
|
||||||
|
payment: {
|
||||||
|
gatewayType: payment.gatewayType,
|
||||||
|
transactionId: payment.transactionId,
|
||||||
|
redirect: payment.redirect,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUrl,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -67,6 +68,14 @@ export class CheckoutCartDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
customerNotes?: string;
|
customerNotes?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute URL the bank callback redirects the shopper to after payment.
|
||||||
|
* Required when payment.type is e_payment_gate.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_protocol: true, require_tld: false })
|
||||||
|
returnUrl?: string;
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => CreateTransactionPaymentDto)
|
@Type(() => CreateTransactionPaymentDto)
|
||||||
payment!: CreateTransactionPaymentDto;
|
payment!: CreateTransactionPaymentDto;
|
||||||
|
|||||||
@@ -203,6 +203,38 @@ export class CommentsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entityType === MediaEntityType.video) {
|
||||||
|
const video = await this.prisma.videos.findFirst({
|
||||||
|
where: {
|
||||||
|
id: entityId,
|
||||||
|
business_id: businessId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === MediaEntityType.instruction) {
|
||||||
|
const instruction = await this.prisma.instructions.findFirst({
|
||||||
|
where: {
|
||||||
|
id: entityId,
|
||||||
|
business_id: businessId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instruction) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
||||||
SELECT id FROM portfolios
|
SELECT id FROM portfolios
|
||||||
WHERE id = ${entityId}
|
WHERE id = ${entityId}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { ContentStatus, VideoProvider } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ListInstructionsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
provider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListPublicInstructionsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
provider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateInstructionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string;
|
||||||
|
|
||||||
|
/** @deprecated use titleFa */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abstract?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionHtml?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
videoProvider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
embedCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredMediaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInstructionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string | null;
|
||||||
|
|
||||||
|
/** @deprecated use titleFa */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abstract?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionHtml?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
videoProvider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
embedCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredMediaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateInstructionCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
authorName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorEmail?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { InstructionsService } from './instructions.service';
|
||||||
|
import {
|
||||||
|
CreateInstructionCommentDto,
|
||||||
|
CreateInstructionDto,
|
||||||
|
ListPublicInstructionsDto,
|
||||||
|
ListInstructionsDto,
|
||||||
|
UpdateInstructionDto,
|
||||||
|
} from './dto/instruction.dto';
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/instructions')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class InstructionsController {
|
||||||
|
constructor(private readonly service: InstructionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('instructions.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListInstructionsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':instructionId')
|
||||||
|
@RequireBusinessPermission('instructions.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('instructionId') instructionId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, instructionId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('instructions.create')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateInstructionDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':instructionId')
|
||||||
|
@RequireBusinessPermission('instructions.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('instructionId') instructionId: string,
|
||||||
|
@Body() dto: UpdateInstructionDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, instructionId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':instructionId')
|
||||||
|
@RequireBusinessPermission('instructions.delete')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('instructionId') instructionId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, instructionId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':instructionId/comments')
|
||||||
|
@RequireBusinessPermission('comments.read')
|
||||||
|
listComments(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('instructionId') instructionId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.listCommentsAdmin(businessId, instructionId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('tenants/:host/instructions')
|
||||||
|
export class PublicInstructionsController {
|
||||||
|
constructor(private readonly service: InstructionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string, @Query() query: ListPublicInstructionsDto) {
|
||||||
|
return this.service.listPublic(host, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':instructionId/comments')
|
||||||
|
listComments(@Param('host') host: string, @Param('instructionId') instructionId: string) {
|
||||||
|
return this.service.listCommentsPublic(host, instructionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':instructionId/comments')
|
||||||
|
createComment(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('instructionId') instructionId: string,
|
||||||
|
@Body() dto: CreateInstructionCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.createCommentPublic(host, instructionId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':slug')
|
||||||
|
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
|
return this.service.getPublicBySlug(host, slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { PublicInstructionsController, InstructionsController } from './instructions.controller';
|
||||||
|
import { InstructionsService } from './instructions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||||
|
controllers: [InstructionsController, PublicInstructionsController],
|
||||||
|
providers: [InstructionsService],
|
||||||
|
})
|
||||||
|
export class InstructionsModule {}
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ContentStatus,
|
||||||
|
MediaEntityType,
|
||||||
|
Prisma,
|
||||||
|
VideoProvider,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
CreateInstructionCommentDto,
|
||||||
|
CreateInstructionDto,
|
||||||
|
ListPublicInstructionsDto,
|
||||||
|
ListInstructionsDto,
|
||||||
|
UpdateInstructionDto,
|
||||||
|
} from './dto/instruction.dto';
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'instruction'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstructionWithRelations = Prisma.instructionsGetPayload<{
|
||||||
|
include: {
|
||||||
|
media: true;
|
||||||
|
author: {
|
||||||
|
select: { id: true; firstName: true; lastName: true; email: true };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const instructionInclude = {
|
||||||
|
media: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.instructionsInclude;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstructionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
private readonly businessSettings: BusinessSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(businessIdRaw: string, query: ListInstructionsDto, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'instructions.read');
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 12;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = await this.buildWhere(businessId, query);
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.instructions.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: instructionInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.instructions.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = await Promise.all(
|
||||||
|
items.map((item) => this.serializeInstruction(item, { includeComments: true })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items: serialized, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'instructions.read');
|
||||||
|
|
||||||
|
const video = await this.findInstructionOrThrow(businessId, instructionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instruction: await this.serializeInstruction(video, { includeComments: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(businessIdRaw: string, dto: CreateInstructionDto, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'instructions.create');
|
||||||
|
|
||||||
|
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
|
||||||
|
const titleEn = dto.titleEn?.trim() || null;
|
||||||
|
if (!titleFa) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = await this.ensureUniqueInstructionSlug(
|
||||||
|
businessId,
|
||||||
|
dto.slug ?? slugify(titleEn || titleFa),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = dto.status ?? ContentStatus.published;
|
||||||
|
const featuredMediaId = dto.featuredMediaId
|
||||||
|
? BigInt(dto.featuredMediaId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (featuredMediaId) {
|
||||||
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
|
||||||
|
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await this.assertCategoryBelongsToBusiness(
|
||||||
|
businessId,
|
||||||
|
BigInt(dto.categoryId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedCode = dto.embedCode?.trim() || null;
|
||||||
|
const videoProvider = embedCode ? dto.videoProvider : null;
|
||||||
|
if (embedCode && !videoProvider) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'videoProvider is required when embedCode is set',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!embedCode && !(dto.descriptionHtml ?? '').trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Provide descriptionHtml or embedCode',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const item = await tx.instructions.create({
|
||||||
|
data: {
|
||||||
|
business_id: businessId,
|
||||||
|
author_id: authorId,
|
||||||
|
title: titleFa,
|
||||||
|
title_fa: titleFa,
|
||||||
|
title_en: titleEn,
|
||||||
|
slug,
|
||||||
|
excerpt: dto.abstract?.trim() || null,
|
||||||
|
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
|
||||||
|
video_provider: videoProvider,
|
||||||
|
embed_code: embedCode,
|
||||||
|
status,
|
||||||
|
featured_media_id: featuredMediaId,
|
||||||
|
published_at: status === ContentStatus.published ? new Date() : null,
|
||||||
|
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
include: instructionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await tx.categoryAssignment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
categoryId: BigInt(dto.categoryId),
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: item.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Instruction created successfully',
|
||||||
|
instruction: await this.serializeInstruction(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
instructionIdRaw: string,
|
||||||
|
dto: UpdateInstructionDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'instructions.update');
|
||||||
|
|
||||||
|
const existing = await this.prisma.instructions.findFirst({
|
||||||
|
where: { id: instructionId, business_id: businessId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTitleFa =
|
||||||
|
dto.titleFa !== undefined || dto.title !== undefined
|
||||||
|
? this.resolveTitleFa(dto.titleFa, dto.title)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(dto.titleFa !== undefined || dto.title !== undefined) &&
|
||||||
|
!nextTitleFa
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (dto.slug) {
|
||||||
|
slug = await this.ensureUniqueInstructionSlug(businessId, dto.slug, instructionId);
|
||||||
|
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
|
||||||
|
const slugBase =
|
||||||
|
(dto.titleEn !== undefined
|
||||||
|
? dto.titleEn?.trim() || null
|
||||||
|
: existing.title_en) || nextTitleFa;
|
||||||
|
slug = await this.ensureUniqueInstructionSlug(businessId, slugify(slugBase), instructionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let featuredMediaId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.featuredMediaId !== undefined) {
|
||||||
|
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||||
|
featuredMediaId = null;
|
||||||
|
} else {
|
||||||
|
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||||
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.authorId !== undefined) {
|
||||||
|
if (dto.authorId === null || dto.authorId === '') {
|
||||||
|
authorId = null;
|
||||||
|
} else {
|
||||||
|
authorId = BigInt(dto.authorId);
|
||||||
|
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingContent = this.asRecord(existing.content);
|
||||||
|
const existingMetadata = this.asRecord(existing.metadata);
|
||||||
|
|
||||||
|
const nextContent = { ...existingContent };
|
||||||
|
if (dto.descriptionHtml !== undefined) {
|
||||||
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMetadata = { ...existingMetadata };
|
||||||
|
if (dto.tags !== undefined) {
|
||||||
|
nextMetadata.tags = dto.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
if (
|
||||||
|
dto.status === ContentStatus.published &&
|
||||||
|
existing.status !== ContentStatus.published
|
||||||
|
) {
|
||||||
|
publishedAt = new Date();
|
||||||
|
}
|
||||||
|
if (dto.status !== ContentStatus.published) {
|
||||||
|
publishedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const nextEmbed =
|
||||||
|
dto.embedCode !== undefined ? dto.embedCode?.trim() || null : undefined;
|
||||||
|
let nextProvider: VideoProvider | null | undefined = undefined;
|
||||||
|
if (dto.videoProvider !== undefined) {
|
||||||
|
nextProvider = dto.videoProvider ?? null;
|
||||||
|
} else if (nextEmbed !== undefined && !nextEmbed) {
|
||||||
|
nextProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = await tx.instructions.update({
|
||||||
|
where: { id: instructionId },
|
||||||
|
data: {
|
||||||
|
...(nextTitleFa !== undefined
|
||||||
|
? { title: nextTitleFa, title_fa: nextTitleFa }
|
||||||
|
: {}),
|
||||||
|
...(dto.titleEn !== undefined
|
||||||
|
? { title_en: dto.titleEn?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.abstract !== undefined
|
||||||
|
? { excerpt: dto.abstract?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(nextProvider !== undefined ? { video_provider: nextProvider } : {}),
|
||||||
|
...(nextEmbed !== undefined ? { embed_code: nextEmbed } : {}),
|
||||||
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
|
...(featuredMediaId !== undefined
|
||||||
|
? { featured_media_id: featuredMediaId }
|
||||||
|
: {}),
|
||||||
|
...(authorId !== undefined ? { author_id: authorId } : {}),
|
||||||
|
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
||||||
|
slug,
|
||||||
|
content: nextContent as Prisma.InputJsonValue,
|
||||||
|
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
include: instructionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
const categoryId = BigInt(dto.categoryId);
|
||||||
|
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||||
|
await tx.categoryAssignment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
categoryId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Instruction updated successfully',
|
||||||
|
instruction: await this.serializeInstruction(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'instructions.delete');
|
||||||
|
|
||||||
|
const existing = await this.prisma.instructions.findFirst({
|
||||||
|
where: { id: instructionId, business_id: businessId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.comment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.instructions.delete({ where: { id: instructionId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { message: 'Instruction deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPublic(host: string, query: ListPublicInstructionsDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 12;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = await this.buildWhere(businessId, {
|
||||||
|
...query,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.instructions.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: instructionInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.instructions.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = await Promise.all(
|
||||||
|
items.map((item) =>
|
||||||
|
this.serializeInstruction(item, {
|
||||||
|
includeComments: true,
|
||||||
|
approvedCommentsOnly: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items: serialized, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicBySlug(host: string, slug: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
const video = await this.prisma.instructions.findFirst({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
slug,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
include: instructionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
instruction: await this.serializeInstruction(video, {
|
||||||
|
includeComments: true,
|
||||||
|
approvedCommentsOnly: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCommentsPublic(host: string, instructionIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
|
||||||
|
await this.assertPublishedInstructionExists(businessId, instructionId);
|
||||||
|
|
||||||
|
const items = await this.prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
isApproved: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: items.map((item) => this.serializeComment(item)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommentPublic(
|
||||||
|
host: string,
|
||||||
|
instructionIdRaw: string,
|
||||||
|
dto: CreateInstructionCommentDto,
|
||||||
|
) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
|
||||||
|
await this.assertPublishedInstructionExists(businessId, instructionId);
|
||||||
|
|
||||||
|
const autoApprove =
|
||||||
|
await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||||
|
const approvedAt = autoApprove ? new Date() : null;
|
||||||
|
|
||||||
|
const created = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
authorName: dto.authorName.trim(),
|
||||||
|
authorEmail: dto.authorEmail?.trim() || null,
|
||||||
|
text: dto.text.trim(),
|
||||||
|
isApproved: autoApprove,
|
||||||
|
approvedAt,
|
||||||
|
},
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
comment: this.serializeComment(created),
|
||||||
|
message: autoApprove
|
||||||
|
? 'Comment submitted and is approved'
|
||||||
|
: 'Comment submitted and is pending approval',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCommentsAdmin(
|
||||||
|
businessIdRaw: string,
|
||||||
|
instructionIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const instructionId = BigInt(instructionIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||||
|
|
||||||
|
const video = await this.prisma.instructions.findFirst({
|
||||||
|
where: { id: instructionId, business_id: businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: items.map((item) => this.serializeComment(item)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTitleFa(titleFa?: string, title?: string): string {
|
||||||
|
return (titleFa ?? title ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWhere(
|
||||||
|
businessId: bigint,
|
||||||
|
query: (ListInstructionsDto | ListPublicInstructionsDto) & { status?: ContentStatus },
|
||||||
|
): Promise<Prisma.instructionsWhereInput> {
|
||||||
|
let entityIds: bigint[] | undefined;
|
||||||
|
|
||||||
|
if (query.categoryId) {
|
||||||
|
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
categoryId: BigInt(query.categoryId),
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
},
|
||||||
|
select: { entityId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
entityIds = assignments.map((item) => item.entityId);
|
||||||
|
|
||||||
|
if (entityIds.length === 0) {
|
||||||
|
return { id: { in: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
business_id: businessId,
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.provider ? { video_provider: query.provider } : {}),
|
||||||
|
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||||
|
...(query.title?.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title_fa: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title_en: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findInstructionOrThrow(businessId: bigint, instructionId: bigint) {
|
||||||
|
const video = await this.prisma.instructions.findFirst({
|
||||||
|
where: { id: instructionId, business_id: businessId },
|
||||||
|
include: instructionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPublishedInstructionExists(
|
||||||
|
businessId: bigint,
|
||||||
|
instructionId: bigint,
|
||||||
|
) {
|
||||||
|
const video = await this.prisma.instructions.findFirst({
|
||||||
|
where: {
|
||||||
|
id: instructionId,
|
||||||
|
business_id: businessId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Instruction not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async serializeInstruction(
|
||||||
|
video: InstructionWithRelations,
|
||||||
|
options: {
|
||||||
|
includeComments?: boolean;
|
||||||
|
approvedCommentsOnly?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const content = this.asRecord(video.content);
|
||||||
|
const metadata = this.asRecord(video.metadata);
|
||||||
|
|
||||||
|
const [categoryAssignment, commentData] = await Promise.all([
|
||||||
|
this.prisma.categoryAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: video.business_id,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: video.id,
|
||||||
|
},
|
||||||
|
include: { category: true },
|
||||||
|
}),
|
||||||
|
options.includeComments
|
||||||
|
? this.loadComments(
|
||||||
|
video.business_id,
|
||||||
|
video.id,
|
||||||
|
options.approvedCommentsOnly,
|
||||||
|
)
|
||||||
|
: Promise.resolve({ commentCount: 0, comments: [] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: video.id.toString(),
|
||||||
|
businessId: video.business_id.toString(),
|
||||||
|
title: video.title,
|
||||||
|
titleFa: video.title_fa ?? video.title,
|
||||||
|
titleEn: video.title_en,
|
||||||
|
slug: video.slug,
|
||||||
|
abstract: video.excerpt ?? '',
|
||||||
|
descriptionHtml: (content.html as string | undefined) ?? '',
|
||||||
|
videoProvider: video.video_provider ?? null,
|
||||||
|
embedCode: video.embed_code ?? '',
|
||||||
|
status: video.status,
|
||||||
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
authorId: video.author_id?.toString() ?? null,
|
||||||
|
author: video.author
|
||||||
|
? {
|
||||||
|
id: video.author.id.toString(),
|
||||||
|
firstName: video.author.firstName,
|
||||||
|
lastName: video.author.lastName,
|
||||||
|
email: video.author.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
coverImageUrl: video.media?.publicUrl ?? null,
|
||||||
|
featuredMediaId: video.featured_media_id?.toString() ?? null,
|
||||||
|
commentCount: commentData.commentCount,
|
||||||
|
comments: commentData.comments,
|
||||||
|
publishedAt: video.published_at,
|
||||||
|
createdAt: video.created_at,
|
||||||
|
updatedAt: video.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadComments(
|
||||||
|
businessId: bigint,
|
||||||
|
instructionId: bigint,
|
||||||
|
approvedOnly?: boolean,
|
||||||
|
) {
|
||||||
|
const where: Prisma.CommentWhereInput = {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
entityId: instructionId,
|
||||||
|
...(approvedOnly ? { isApproved: true } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [commentCount, comments] = await Promise.all([
|
||||||
|
this.prisma.comment.count({ where }),
|
||||||
|
this.prisma.comment.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: approvedOnly ? 50 : undefined,
|
||||||
|
include: { approver: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentCount,
|
||||||
|
comments: comments.map((item) => this.serializeComment(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeComment(
|
||||||
|
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: comment.id.toString(),
|
||||||
|
businessId: comment.businessId.toString(),
|
||||||
|
entityType: comment.entityType,
|
||||||
|
entityId: comment.entityId.toString(),
|
||||||
|
authorName: comment.authorName,
|
||||||
|
authorEmail: comment.authorEmail,
|
||||||
|
text: comment.text,
|
||||||
|
isApproved: comment.isApproved,
|
||||||
|
approvedAt: comment.approvedAt,
|
||||||
|
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||||
|
approver: comment.approver
|
||||||
|
? {
|
||||||
|
id: comment.approver.id.toString(),
|
||||||
|
firstName: comment.approver.firstName,
|
||||||
|
lastName: comment.approver.lastName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: comment.createdAt,
|
||||||
|
updatedAt: comment.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContent(descriptionHtml?: string) {
|
||||||
|
return {
|
||||||
|
html: descriptionHtml ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMetadata(tags?: string[]) {
|
||||||
|
return {
|
||||||
|
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertMediaBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
mediaId: bigint,
|
||||||
|
) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
});
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException('Media not found for this business');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCategoryBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
categoryId: bigint,
|
||||||
|
) {
|
||||||
|
const category = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
id: categoryId,
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.instruction,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
throw new BadRequestException('Instruction category not found for this business');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertAuthorBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
authorId: bigint,
|
||||||
|
) {
|
||||||
|
const member = await this.prisma.businessUser.findFirst({
|
||||||
|
where: { businessId, userId: authorId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Author must be a team member of this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueInstructionSlug(
|
||||||
|
businessId: bigint,
|
||||||
|
baseSlug: string,
|
||||||
|
excludeId?: bigint,
|
||||||
|
) {
|
||||||
|
let slug = baseSlug;
|
||||||
|
let suffix = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.instructions.findFirst({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
slug,
|
||||||
|
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix += 1;
|
||||||
|
slug = `${baseSlug}-${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -897,6 +897,8 @@ export class OrdersService {
|
|||||||
amount: Number(transaction.amount),
|
amount: Number(transaction.amount),
|
||||||
posType: transaction.posType,
|
posType: transaction.posType,
|
||||||
gatewayType: transaction.gatewayType,
|
gatewayType: transaction.gatewayType,
|
||||||
|
gatewayRef: transaction.gatewayRef ?? null,
|
||||||
|
gatewayTrackId: transaction.gatewayTrackId ?? null,
|
||||||
transferAccount: transaction.transferAccount,
|
transferAccount: transaction.transferAccount,
|
||||||
transferRefNumber: transaction.transferRefNumber,
|
transferRefNumber: transaction.transferRefNumber,
|
||||||
notes: transaction.notes,
|
notes: transaction.notes,
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { EPaymentSettings } from '../../business-settings/business-settings.types';
|
||||||
|
import { isGatewayReady } from '../../business-settings/business-settings.util';
|
||||||
|
import type {
|
||||||
|
GatewayCredentials,
|
||||||
|
InitiatePaymentInput,
|
||||||
|
InitiatePaymentResult,
|
||||||
|
PaymentGatewayAdapter,
|
||||||
|
VerifyPaymentInput,
|
||||||
|
VerifyPaymentResult,
|
||||||
|
} from './payment-gateway.types';
|
||||||
|
|
||||||
|
const MELLAT_SOAP_URL =
|
||||||
|
'https://bpm.shaparak.ir/pgwchannel/services/pgw';
|
||||||
|
const MELLAT_PAYMENT_URL =
|
||||||
|
'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||||
|
|
||||||
|
/** ResCode values that mean the payment was already verified successfully. */
|
||||||
|
const MELLAT_ALREADY_VERIFIED = new Set(['43']);
|
||||||
|
|
||||||
|
function escapeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSoapReturn(xml: string): string {
|
||||||
|
const match = xml.match(/<return[^>]*>([\s\S]*?)<\/return>/i);
|
||||||
|
return match ? match[1].trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayloadString(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = payload[key];
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MellatGateway implements PaymentGatewayAdapter {
|
||||||
|
readonly id = 'mellat' as const;
|
||||||
|
private readonly logger = new Logger(MellatGateway.name);
|
||||||
|
|
||||||
|
isConfigured(settings: EPaymentSettings): boolean {
|
||||||
|
return isGatewayReady(settings, 'mellat');
|
||||||
|
}
|
||||||
|
|
||||||
|
async initiate(
|
||||||
|
credentials: GatewayCredentials,
|
||||||
|
input: InitiatePaymentInput,
|
||||||
|
): Promise<InitiatePaymentResult> {
|
||||||
|
const mellat = credentials.mellat;
|
||||||
|
const now = new Date();
|
||||||
|
const localDate = [
|
||||||
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getDate()).padStart(2, '0'),
|
||||||
|
].join('');
|
||||||
|
const localTime = [
|
||||||
|
String(now.getHours()).padStart(2, '0'),
|
||||||
|
String(now.getMinutes()).padStart(2, '0'),
|
||||||
|
String(now.getSeconds()).padStart(2, '0'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
// Mellat amount is Rials; Meshkee stores IRT (Toman).
|
||||||
|
const amountRials = Math.round(input.amountIrt * 10);
|
||||||
|
|
||||||
|
const raw = await this.soapCall('bpPayRequest', {
|
||||||
|
terminalId: mellat.terminalId,
|
||||||
|
userName: mellat.username,
|
||||||
|
userPassword: mellat.password,
|
||||||
|
orderId: input.transactionId.toString(),
|
||||||
|
amount: String(amountRials),
|
||||||
|
localDate,
|
||||||
|
localTime,
|
||||||
|
additionalData: input.description?.slice(0, 1000) ?? '',
|
||||||
|
callBackUrl: input.callbackUrl,
|
||||||
|
payerId: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [resCode, refId = ''] = raw.split(',', 2);
|
||||||
|
if (resCode !== '0' || !refId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`bpPayRequest failed business=${input.businessId} order=${input.orderId} code=${resCode}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
`Mellat payment init failed (code ${resCode || 'unknown'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gatewayRef: refId,
|
||||||
|
redirect: {
|
||||||
|
method: 'POST',
|
||||||
|
url: MELLAT_PAYMENT_URL,
|
||||||
|
fields: { RefId: refId },
|
||||||
|
},
|
||||||
|
meta: { resCode, amountRials },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(
|
||||||
|
credentials: GatewayCredentials,
|
||||||
|
input: VerifyPaymentInput,
|
||||||
|
): Promise<VerifyPaymentResult> {
|
||||||
|
const mellat = credentials.mellat;
|
||||||
|
const resCode = readPayloadString(input.callbackPayload, 'ResCode', 'resCode');
|
||||||
|
const saleOrderId = readPayloadString(
|
||||||
|
input.callbackPayload,
|
||||||
|
'SaleOrderId',
|
||||||
|
'saleOrderId',
|
||||||
|
);
|
||||||
|
const saleReferenceId = readPayloadString(
|
||||||
|
input.callbackPayload,
|
||||||
|
'SaleReferenceId',
|
||||||
|
'saleReferenceId',
|
||||||
|
);
|
||||||
|
const refId = readPayloadString(input.callbackPayload, 'RefId', 'refId');
|
||||||
|
|
||||||
|
if (resCode !== '0') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
resCode: resCode || 'unknown',
|
||||||
|
message: `Mellat payment declined (ResCode ${resCode || 'unknown'})`,
|
||||||
|
meta: { saleOrderId, saleReferenceId, refId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saleOrderId || !saleReferenceId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
resCode,
|
||||||
|
message: 'Mellat callback missing SaleOrderId or SaleReferenceId',
|
||||||
|
meta: { saleOrderId, saleReferenceId, refId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyRaw = await this.soapCall('bpVerifyRequest', {
|
||||||
|
terminalId: mellat.terminalId,
|
||||||
|
userName: mellat.username,
|
||||||
|
userPassword: mellat.password,
|
||||||
|
orderId: saleOrderId,
|
||||||
|
saleOrderId,
|
||||||
|
saleReferenceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(verifyRaw)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
resCode: verifyRaw,
|
||||||
|
trackId: saleReferenceId,
|
||||||
|
message: `Mellat verify failed (code ${verifyRaw})`,
|
||||||
|
meta: { saleOrderId, saleReferenceId, refId, verifyRaw },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleRaw = await this.soapCall('bpSettleRequest', {
|
||||||
|
terminalId: mellat.terminalId,
|
||||||
|
userName: mellat.username,
|
||||||
|
userPassword: mellat.password,
|
||||||
|
orderId: saleOrderId,
|
||||||
|
saleOrderId,
|
||||||
|
saleReferenceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settleRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(settleRaw)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`bpSettleRequest non-success business=${input.businessId} code=${settleRaw} saleRef=${saleReferenceId}`,
|
||||||
|
);
|
||||||
|
// Payment was verified; settle failure is logged but treated as success
|
||||||
|
// so the customer is not charged twice on retry.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
resCode: '0',
|
||||||
|
trackId: saleReferenceId,
|
||||||
|
message: 'Payment verified',
|
||||||
|
meta: {
|
||||||
|
saleOrderId,
|
||||||
|
saleReferenceId,
|
||||||
|
refId,
|
||||||
|
verifyRaw,
|
||||||
|
settleRaw,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async soapCall(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
|
const inner = Object.entries(params)
|
||||||
|
.map(([key, value]) => `<${key}>${escapeXml(value)}</${key}>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const body =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||||
|
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ` +
|
||||||
|
`xmlns:int="http://interfaces.core.sw.bps.com/">` +
|
||||||
|
`<soapenv:Header/>` +
|
||||||
|
`<soapenv:Body>` +
|
||||||
|
`<int:${method}>${inner}</int:${method}>` +
|
||||||
|
`</soapenv:Body>` +
|
||||||
|
`</soapenv:Envelope>`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(MELLAT_SOAP_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
|
SOAPAction: '',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Mellat SOAP network error (${method})`, err);
|
||||||
|
throw new BadGatewayException('Unable to reach Mellat payment gateway');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.error(
|
||||||
|
`Mellat SOAP HTTP ${response.status} (${method}): ${text.slice(0, 400)}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException('Mellat payment gateway returned an error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractSoapReturn(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type {
|
||||||
|
EPaymentSettings,
|
||||||
|
MellatGatewaySettings,
|
||||||
|
PaymentGatewayId,
|
||||||
|
} from '../../business-settings/business-settings.types';
|
||||||
|
|
||||||
|
export type PaymentRedirect = {
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
url: string;
|
||||||
|
/** Form fields for POST redirects (e.g. Mellat RefId). */
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiatePaymentInput = {
|
||||||
|
businessId: bigint;
|
||||||
|
orderId: bigint;
|
||||||
|
/** Used as gateway order id when the provider requires a numeric id. */
|
||||||
|
transactionId: bigint;
|
||||||
|
amountIrt: number;
|
||||||
|
callbackUrl: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiatePaymentResult = {
|
||||||
|
redirect: PaymentRedirect;
|
||||||
|
gatewayRef: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyPaymentInput = {
|
||||||
|
businessId: bigint;
|
||||||
|
orderId: bigint;
|
||||||
|
transactionId: bigint;
|
||||||
|
amountIrt: number;
|
||||||
|
gatewayRef: string | null;
|
||||||
|
callbackPayload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyPaymentResult = {
|
||||||
|
success: boolean;
|
||||||
|
trackId?: string;
|
||||||
|
resCode?: string;
|
||||||
|
message?: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayCredentials = {
|
||||||
|
mellat: MellatGatewaySettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PaymentGatewayAdapter {
|
||||||
|
readonly id: PaymentGatewayId;
|
||||||
|
|
||||||
|
isConfigured(settings: EPaymentSettings): boolean;
|
||||||
|
|
||||||
|
initiate(
|
||||||
|
credentials: GatewayCredentials,
|
||||||
|
input: InitiatePaymentInput,
|
||||||
|
): Promise<InitiatePaymentResult>;
|
||||||
|
|
||||||
|
verify(
|
||||||
|
credentials: GatewayCredentials,
|
||||||
|
input: VerifyPaymentInput,
|
||||||
|
): Promise<VerifyPaymentResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||||
|
import { MellatGateway } from './gateways/mellat.gateway';
|
||||||
|
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentGatewayRegistry {
|
||||||
|
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
|
||||||
|
|
||||||
|
constructor(mellat: MellatGateway) {
|
||||||
|
this.adapters = new Map([[mellat.id, mellat]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(gatewayId: string): PaymentGatewayAdapter {
|
||||||
|
const adapter = this.adapters.get(gatewayId as PaymentGatewayId);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Payment gateway "${gatewayId}" is not supported`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
listIds(): PaymentGatewayId[] {
|
||||||
|
return [...this.adapters.keys()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public bank callback + optional status helpers.
|
||||||
|
* Bank gateways POST/GET here without JWT.
|
||||||
|
*/
|
||||||
|
@Controller('businesses/:businessId/payments')
|
||||||
|
export class PaymentsController {
|
||||||
|
constructor(private readonly payments: PaymentsService) {}
|
||||||
|
|
||||||
|
@Get('methods')
|
||||||
|
methods(@Param('businessId') businessId: string) {
|
||||||
|
return this.payments.getPublicMethods(BigInt(businessId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':gateway/callback')
|
||||||
|
async callbackPost(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('gateway') gateway: string,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.payments.handleGatewayCallback(
|
||||||
|
businessId,
|
||||||
|
gateway,
|
||||||
|
body ?? {},
|
||||||
|
);
|
||||||
|
return res.redirect(303, result.redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':gateway/callback')
|
||||||
|
async callbackGet(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('gateway') gateway: string,
|
||||||
|
@Query() query: Record<string, unknown>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.payments.handleGatewayCallback(
|
||||||
|
businessId,
|
||||||
|
gateway,
|
||||||
|
query ?? {},
|
||||||
|
);
|
||||||
|
return res.redirect(303, result.redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
|
import { MellatGateway } from './gateways/mellat.gateway';
|
||||||
|
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||||
|
import { PaymentsController } from './payments.controller';
|
||||||
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [BusinessSettingsModule],
|
||||||
|
controllers: [PaymentsController],
|
||||||
|
providers: [MellatGateway, PaymentGatewayRegistry, PaymentsService],
|
||||||
|
exports: [PaymentsService],
|
||||||
|
})
|
||||||
|
export class PaymentsModule {}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PAYMENT_GATEWAY_IDS,
|
||||||
|
type PaymentGatewayId,
|
||||||
|
} from '../business-settings/business-settings.types';
|
||||||
|
import {
|
||||||
|
isGatewayReady,
|
||||||
|
listPublicPaymentGateways,
|
||||||
|
} from '../business-settings/business-settings.util';
|
||||||
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||||
|
import type { PaymentRedirect } from './gateways/payment-gateway.types';
|
||||||
|
|
||||||
|
type GatewayMeta = {
|
||||||
|
returnUrl?: string;
|
||||||
|
initiatedAt?: string;
|
||||||
|
callback?: Record<string, unknown>;
|
||||||
|
verify?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asGatewayMeta(value: unknown): GatewayMeta {
|
||||||
|
return isRecord(value) ? (value as GatewayMeta) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(url: string, params: Record<string, string>) {
|
||||||
|
const target = new URL(url);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
target.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeReturnUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentsService {
|
||||||
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settings: BusinessSettingsService,
|
||||||
|
private readonly gateways: PaymentGatewayRegistry,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getPublicMethods(businessId: bigint) {
|
||||||
|
const normalized = await this.settings.getNormalizedSettings(businessId);
|
||||||
|
return listPublicPaymentGateways(normalized.store.ePayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveGatewayType(
|
||||||
|
requested: string | undefined,
|
||||||
|
ePayment: ReturnType<typeof listPublicPaymentGateways>,
|
||||||
|
): PaymentGatewayId {
|
||||||
|
const requestedId = requested?.trim().toLowerCase() || '';
|
||||||
|
if (requestedId) {
|
||||||
|
if (!(PAYMENT_GATEWAY_IDS as readonly string[]).includes(requestedId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unknown payment gateway "${requestedId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ePayment.gateways.some((g) => g.id === requestedId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Payment gateway "${requestedId}" is not enabled for this store`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requestedId as PaymentGatewayId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ePayment.defaultGateway) {
|
||||||
|
throw new BadRequestException('No online payment gateway is configured');
|
||||||
|
}
|
||||||
|
return ePayment.defaultGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initiateForCheckout(input: {
|
||||||
|
businessId: bigint;
|
||||||
|
order: {
|
||||||
|
id: string;
|
||||||
|
total: number;
|
||||||
|
transactions?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
gatewayType: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
gatewayType: PaymentGatewayId;
|
||||||
|
returnUrl: string;
|
||||||
|
}): Promise<{
|
||||||
|
transactionId: string;
|
||||||
|
gatewayType: PaymentGatewayId;
|
||||||
|
redirect: PaymentRedirect;
|
||||||
|
}> {
|
||||||
|
if (!isSafeReturnUrl(input.returnUrl)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'returnUrl must be an absolute http(s) URL',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = await this.settings.getNormalizedSettings(
|
||||||
|
input.businessId,
|
||||||
|
);
|
||||||
|
const ePayment = normalized.store.ePayment;
|
||||||
|
|
||||||
|
if (!isGatewayReady(ePayment, input.gatewayType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Payment gateway "${input.gatewayType}" is not ready`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = (input.order.transactions ?? []).find(
|
||||||
|
(row) =>
|
||||||
|
row.type === 'e_payment_gate' &&
|
||||||
|
row.status === 'pending' &&
|
||||||
|
row.gatewayType === input.gatewayType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Pending e-payment transaction not found for this order',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = this.gateways.get(input.gatewayType);
|
||||||
|
const callbackUrl = this.buildCallbackUrl(
|
||||||
|
input.businessId,
|
||||||
|
input.gatewayType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await adapter.initiate(
|
||||||
|
{ mellat: ePayment.gateways.mellat },
|
||||||
|
{
|
||||||
|
businessId: input.businessId,
|
||||||
|
orderId: BigInt(input.order.id),
|
||||||
|
transactionId: BigInt(transaction.id),
|
||||||
|
amountIrt: input.order.total,
|
||||||
|
callbackUrl,
|
||||||
|
description: `Order ${input.order.id}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta: GatewayMeta = {
|
||||||
|
returnUrl: input.returnUrl,
|
||||||
|
initiatedAt: new Date().toISOString(),
|
||||||
|
...(result.meta ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.transaction.update({
|
||||||
|
where: { id: BigInt(transaction.id) },
|
||||||
|
data: {
|
||||||
|
gatewayRef: result.gatewayRef,
|
||||||
|
gatewayMeta: meta as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactionId: transaction.id,
|
||||||
|
gatewayType: input.gatewayType,
|
||||||
|
redirect: result.redirect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGatewayCallback(
|
||||||
|
businessIdRaw: string,
|
||||||
|
gatewayTypeRaw: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ redirectUrl: string }> {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const gatewayType = gatewayTypeRaw.trim().toLowerCase();
|
||||||
|
const adapter = this.gateways.get(gatewayType);
|
||||||
|
|
||||||
|
const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId');
|
||||||
|
const refId = this.readString(payload, 'RefId', 'refId');
|
||||||
|
|
||||||
|
const transaction = await this.findCallbackTransaction(
|
||||||
|
businessId,
|
||||||
|
gatewayType,
|
||||||
|
saleOrderId,
|
||||||
|
refId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
throw new NotFoundException('Payment transaction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = asGatewayMeta(transaction.gatewayMeta);
|
||||||
|
const returnUrl =
|
||||||
|
typeof meta.returnUrl === 'string' && isSafeReturnUrl(meta.returnUrl)
|
||||||
|
? meta.returnUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const failRedirect = (status: string, message?: string) => {
|
||||||
|
if (!returnUrl) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
message || 'Payment failed and no returnUrl was stored',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
redirectUrl: appendQuery(returnUrl, {
|
||||||
|
status,
|
||||||
|
orderId: transaction.orderId?.toString() ?? '',
|
||||||
|
transactionId: transaction.id.toString(),
|
||||||
|
...(message ? { message } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (transaction.status === 'completed') {
|
||||||
|
return failRedirect('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.status === 'failed' || transaction.status === 'refunded') {
|
||||||
|
return failRedirect('failed', 'Payment already marked as failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await this.settings.getNormalizedSettings(businessId);
|
||||||
|
if (!isGatewayReady(settings.store.ePayment, gatewayType as PaymentGatewayId)) {
|
||||||
|
await this.markFailed(transaction.id, {
|
||||||
|
...meta,
|
||||||
|
callback: payload,
|
||||||
|
error: 'Gateway no longer configured',
|
||||||
|
});
|
||||||
|
return failRedirect('failed', 'Payment gateway is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transaction.orderId) {
|
||||||
|
await this.markFailed(transaction.id, {
|
||||||
|
...meta,
|
||||||
|
callback: payload,
|
||||||
|
error: 'Transaction has no order',
|
||||||
|
});
|
||||||
|
return failRedirect('failed', 'Order missing for payment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verify = await adapter.verify(
|
||||||
|
{ mellat: settings.store.ePayment.gateways.mellat },
|
||||||
|
{
|
||||||
|
businessId,
|
||||||
|
orderId: transaction.orderId,
|
||||||
|
transactionId: transaction.id,
|
||||||
|
amountIrt: Number(transaction.amount),
|
||||||
|
gatewayRef: transaction.gatewayRef,
|
||||||
|
callbackPayload: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verify.success) {
|
||||||
|
await this.markFailed(transaction.id, {
|
||||||
|
...meta,
|
||||||
|
callback: payload,
|
||||||
|
verify: verify.meta ?? { message: verify.message, resCode: verify.resCode },
|
||||||
|
});
|
||||||
|
this.logger.warn(
|
||||||
|
`Payment failed business=${businessId} tx=${transaction.id} code=${verify.resCode}`,
|
||||||
|
);
|
||||||
|
return failRedirect('failed', verify.message || 'Payment failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.transaction.update({
|
||||||
|
where: { id: transaction.id },
|
||||||
|
data: {
|
||||||
|
status: 'completed',
|
||||||
|
gatewayTrackId: verify.trackId ?? transaction.gatewayTrackId,
|
||||||
|
gatewayMeta: {
|
||||||
|
...meta,
|
||||||
|
callback: payload,
|
||||||
|
verify: verify.meta ?? {},
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
||||||
|
if (!returnUrl) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Payment completed but no returnUrl was stored',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectUrl: appendQuery(returnUrl, {
|
||||||
|
status: 'success',
|
||||||
|
orderId: transaction.orderId.toString(),
|
||||||
|
transactionId: transaction.id.toString(),
|
||||||
|
...(verify.trackId ? { trackId: verify.trackId } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCallbackUrl(businessId: bigint, gatewayType: string) {
|
||||||
|
const base =
|
||||||
|
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
|
||||||
|
`http://localhost:${this.config.get<string>('PORT') || '3000'}`;
|
||||||
|
|
||||||
|
return `${base}/api/v1/businesses/${businessId.toString()}/payments/${gatewayType}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCallbackTransaction(
|
||||||
|
businessId: bigint,
|
||||||
|
gatewayType: string,
|
||||||
|
saleOrderId: string,
|
||||||
|
refId: string,
|
||||||
|
) {
|
||||||
|
if (saleOrderId && /^\d+$/.test(saleOrderId)) {
|
||||||
|
const byId = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(saleOrderId),
|
||||||
|
businessId,
|
||||||
|
type: 'e_payment_gate',
|
||||||
|
gatewayType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (byId) return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refId) {
|
||||||
|
return this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
type: 'e_payment_gate',
|
||||||
|
gatewayType,
|
||||||
|
gatewayRef: refId,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
||||||
|
await this.prisma.transaction.update({
|
||||||
|
where: { id: transactionId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
gatewayMeta: {
|
||||||
|
...meta,
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readString(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = payload[key];
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
import { normalizeBusinessSettings, listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||||
|
|
||||||
const DASHBOARD_SUBDOMAIN_PREFIXES = ['customer.', 'business.'] as const;
|
const DASHBOARD_SUBDOMAIN_PREFIXES = ['customer.', 'business.'] as const;
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ export class TenantService {
|
|||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
||||||
|
ePayment: listPublicPaymentGateways(settings.store.ePayment),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { ContentStatus, VideoProvider } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ListVideosDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
provider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListPublicVideosDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
provider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateVideoDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string;
|
||||||
|
|
||||||
|
/** @deprecated use titleFa */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abstract?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionHtml?: string;
|
||||||
|
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
videoProvider!: VideoProvider;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
embedCode!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredMediaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateVideoDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string | null;
|
||||||
|
|
||||||
|
/** @deprecated use titleFa */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abstract?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionHtml?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoProvider)
|
||||||
|
videoProvider?: VideoProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
embedCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStatus)
|
||||||
|
status?: ContentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredMediaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateVideoCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
authorName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorEmail?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { VideosService } from './videos.service';
|
||||||
|
import {
|
||||||
|
CreateVideoCommentDto,
|
||||||
|
CreateVideoDto,
|
||||||
|
ListPublicVideosDto,
|
||||||
|
ListVideosDto,
|
||||||
|
UpdateVideoDto,
|
||||||
|
} from './dto/video.dto';
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/videos')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class VideosController {
|
||||||
|
constructor(private readonly service: VideosService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('videos.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListVideosDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':videoId')
|
||||||
|
@RequireBusinessPermission('videos.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('videoId') videoId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, videoId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('videos.create')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateVideoDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':videoId')
|
||||||
|
@RequireBusinessPermission('videos.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('videoId') videoId: string,
|
||||||
|
@Body() dto: UpdateVideoDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, videoId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':videoId')
|
||||||
|
@RequireBusinessPermission('videos.delete')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('videoId') videoId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, videoId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':videoId/comments')
|
||||||
|
@RequireBusinessPermission('comments.read')
|
||||||
|
listComments(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('videoId') videoId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.listCommentsAdmin(businessId, videoId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('tenants/:host/videos')
|
||||||
|
export class PublicVideosController {
|
||||||
|
constructor(private readonly service: VideosService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||||
|
return this.service.listPublic(host, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':videoId/comments')
|
||||||
|
listComments(@Param('host') host: string, @Param('videoId') videoId: string) {
|
||||||
|
return this.service.listCommentsPublic(host, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':videoId/comments')
|
||||||
|
createComment(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('videoId') videoId: string,
|
||||||
|
@Body() dto: CreateVideoCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.createCommentPublic(host, videoId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':slug')
|
||||||
|
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
|
return this.service.getPublicBySlug(host, slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { PublicVideosController, VideosController } from './videos.controller';
|
||||||
|
import { VideosService } from './videos.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||||
|
controllers: [VideosController, PublicVideosController],
|
||||||
|
providers: [VideosService],
|
||||||
|
})
|
||||||
|
export class VideosModule {}
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ContentStatus,
|
||||||
|
MediaEntityType,
|
||||||
|
Prisma,
|
||||||
|
VideoProvider,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
CreateVideoCommentDto,
|
||||||
|
CreateVideoDto,
|
||||||
|
ListPublicVideosDto,
|
||||||
|
ListVideosDto,
|
||||||
|
UpdateVideoDto,
|
||||||
|
} from './dto/video.dto';
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'video'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoWithRelations = Prisma.videosGetPayload<{
|
||||||
|
include: {
|
||||||
|
media: true;
|
||||||
|
author: {
|
||||||
|
select: { id: true; firstName: true; lastName: true; email: true };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const videoInclude = {
|
||||||
|
media: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.videosInclude;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VideosService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
private readonly businessSettings: BusinessSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(businessIdRaw: string, query: ListVideosDto, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'videos.read');
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 12;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = await this.buildWhere(businessId, query);
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.videos.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: videoInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.videos.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = await Promise.all(
|
||||||
|
items.map((item) => this.serializeVideo(item, { includeComments: true })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items: serialized, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'videos.read');
|
||||||
|
|
||||||
|
const video = await this.findVideoOrThrow(businessId, videoId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
video: await this.serializeVideo(video, { includeComments: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(businessIdRaw: string, dto: CreateVideoDto, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'videos.create');
|
||||||
|
|
||||||
|
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
|
||||||
|
const titleEn = dto.titleEn?.trim() || null;
|
||||||
|
if (!titleFa) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = await this.ensureUniqueSlug(
|
||||||
|
businessId,
|
||||||
|
dto.slug ?? slugify(titleEn || titleFa),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = dto.status ?? ContentStatus.published;
|
||||||
|
const featuredMediaId = dto.featuredMediaId
|
||||||
|
? BigInt(dto.featuredMediaId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (featuredMediaId) {
|
||||||
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
|
||||||
|
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await this.assertCategoryBelongsToBusiness(
|
||||||
|
businessId,
|
||||||
|
BigInt(dto.categoryId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const video = await tx.videos.create({
|
||||||
|
data: {
|
||||||
|
business_id: businessId,
|
||||||
|
author_id: authorId,
|
||||||
|
title: titleFa,
|
||||||
|
title_fa: titleFa,
|
||||||
|
title_en: titleEn,
|
||||||
|
slug,
|
||||||
|
excerpt: dto.abstract?.trim() || null,
|
||||||
|
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
|
||||||
|
video_provider: dto.videoProvider,
|
||||||
|
embed_code: dto.embedCode.trim(),
|
||||||
|
status,
|
||||||
|
featured_media_id: featuredMediaId,
|
||||||
|
published_at: status === ContentStatus.published ? new Date() : null,
|
||||||
|
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
include: videoInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await tx.categoryAssignment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
categoryId: BigInt(dto.categoryId),
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: video.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Video created successfully',
|
||||||
|
video: await this.serializeVideo(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
videoIdRaw: string,
|
||||||
|
dto: UpdateVideoDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'videos.update');
|
||||||
|
|
||||||
|
const existing = await this.prisma.videos.findFirst({
|
||||||
|
where: { id: videoId, business_id: businessId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTitleFa =
|
||||||
|
dto.titleFa !== undefined || dto.title !== undefined
|
||||||
|
? this.resolveTitleFa(dto.titleFa, dto.title)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(dto.titleFa !== undefined || dto.title !== undefined) &&
|
||||||
|
!nextTitleFa
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (dto.slug) {
|
||||||
|
slug = await this.ensureUniqueSlug(businessId, dto.slug, videoId);
|
||||||
|
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
|
||||||
|
const slugBase =
|
||||||
|
(dto.titleEn !== undefined
|
||||||
|
? dto.titleEn?.trim() || null
|
||||||
|
: existing.title_en) || nextTitleFa;
|
||||||
|
slug = await this.ensureUniqueSlug(businessId, slugify(slugBase), videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let featuredMediaId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.featuredMediaId !== undefined) {
|
||||||
|
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||||
|
featuredMediaId = null;
|
||||||
|
} else {
|
||||||
|
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||||
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.authorId !== undefined) {
|
||||||
|
if (dto.authorId === null || dto.authorId === '') {
|
||||||
|
authorId = null;
|
||||||
|
} else {
|
||||||
|
authorId = BigInt(dto.authorId);
|
||||||
|
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingContent = this.asRecord(existing.content);
|
||||||
|
const existingMetadata = this.asRecord(existing.metadata);
|
||||||
|
|
||||||
|
const nextContent = { ...existingContent };
|
||||||
|
if (dto.descriptionHtml !== undefined) {
|
||||||
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMetadata = { ...existingMetadata };
|
||||||
|
if (dto.tags !== undefined) {
|
||||||
|
nextMetadata.tags = dto.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
if (
|
||||||
|
dto.status === ContentStatus.published &&
|
||||||
|
existing.status !== ContentStatus.published
|
||||||
|
) {
|
||||||
|
publishedAt = new Date();
|
||||||
|
}
|
||||||
|
if (dto.status !== ContentStatus.published) {
|
||||||
|
publishedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const video = await tx.videos.update({
|
||||||
|
where: { id: videoId },
|
||||||
|
data: {
|
||||||
|
...(nextTitleFa !== undefined
|
||||||
|
? { title: nextTitleFa, title_fa: nextTitleFa }
|
||||||
|
: {}),
|
||||||
|
...(dto.titleEn !== undefined
|
||||||
|
? { title_en: dto.titleEn?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.abstract !== undefined
|
||||||
|
? { excerpt: dto.abstract?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.videoProvider !== undefined
|
||||||
|
? { video_provider: dto.videoProvider }
|
||||||
|
: {}),
|
||||||
|
...(dto.embedCode !== undefined
|
||||||
|
? { embed_code: dto.embedCode.trim() }
|
||||||
|
: {}),
|
||||||
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
|
...(featuredMediaId !== undefined
|
||||||
|
? { featured_media_id: featuredMediaId }
|
||||||
|
: {}),
|
||||||
|
...(authorId !== undefined ? { author_id: authorId } : {}),
|
||||||
|
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
||||||
|
slug,
|
||||||
|
content: nextContent as Prisma.InputJsonValue,
|
||||||
|
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
include: videoInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.categoryId) {
|
||||||
|
const categoryId = BigInt(dto.categoryId);
|
||||||
|
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||||
|
await tx.categoryAssignment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
categoryId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Video updated successfully',
|
||||||
|
video: await this.serializeVideo(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'videos.delete');
|
||||||
|
|
||||||
|
const existing = await this.prisma.videos.findFirst({
|
||||||
|
where: { id: videoId, business_id: businessId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.comment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.videos.delete({ where: { id: videoId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { message: 'Video deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPublic(host: string, query: ListPublicVideosDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 12;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = await this.buildWhere(businessId, {
|
||||||
|
...query,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.videos.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: videoInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.videos.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = await Promise.all(
|
||||||
|
items.map((item) =>
|
||||||
|
this.serializeVideo(item, {
|
||||||
|
includeComments: true,
|
||||||
|
approvedCommentsOnly: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items: serialized, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicBySlug(host: string, slug: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
const video = await this.prisma.videos.findFirst({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
slug,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
include: videoInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
video: await this.serializeVideo(video, {
|
||||||
|
includeComments: true,
|
||||||
|
approvedCommentsOnly: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCommentsPublic(host: string, videoIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
|
||||||
|
await this.assertPublishedVideoExists(businessId, videoId);
|
||||||
|
|
||||||
|
const items = await this.prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
isApproved: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: items.map((item) => this.serializeComment(item)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommentPublic(
|
||||||
|
host: string,
|
||||||
|
videoIdRaw: string,
|
||||||
|
dto: CreateVideoCommentDto,
|
||||||
|
) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
|
||||||
|
await this.assertPublishedVideoExists(businessId, videoId);
|
||||||
|
|
||||||
|
const autoApprove =
|
||||||
|
await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||||
|
const approvedAt = autoApprove ? new Date() : null;
|
||||||
|
|
||||||
|
const created = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
authorName: dto.authorName.trim(),
|
||||||
|
authorEmail: dto.authorEmail?.trim() || null,
|
||||||
|
text: dto.text.trim(),
|
||||||
|
isApproved: autoApprove,
|
||||||
|
approvedAt,
|
||||||
|
},
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
comment: this.serializeComment(created),
|
||||||
|
message: autoApprove
|
||||||
|
? 'Comment submitted and is approved'
|
||||||
|
: 'Comment submitted and is pending approval',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCommentsAdmin(
|
||||||
|
businessIdRaw: string,
|
||||||
|
videoIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const videoId = BigInt(videoIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||||
|
|
||||||
|
const video = await this.prisma.videos.findFirst({
|
||||||
|
where: { id: videoId, business_id: businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { approver: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: items.map((item) => this.serializeComment(item)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTitleFa(titleFa?: string, title?: string): string {
|
||||||
|
return (titleFa ?? title ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWhere(
|
||||||
|
businessId: bigint,
|
||||||
|
query: (ListVideosDto | ListPublicVideosDto) & { status?: ContentStatus },
|
||||||
|
): Promise<Prisma.videosWhereInput> {
|
||||||
|
let entityIds: bigint[] | undefined;
|
||||||
|
|
||||||
|
if (query.categoryId) {
|
||||||
|
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
categoryId: BigInt(query.categoryId),
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
},
|
||||||
|
select: { entityId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
entityIds = assignments.map((item) => item.entityId);
|
||||||
|
|
||||||
|
if (entityIds.length === 0) {
|
||||||
|
return { id: { in: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
business_id: businessId,
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.provider ? { video_provider: query.provider } : {}),
|
||||||
|
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||||
|
...(query.title?.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title_fa: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title_en: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findVideoOrThrow(businessId: bigint, videoId: bigint) {
|
||||||
|
const video = await this.prisma.videos.findFirst({
|
||||||
|
where: { id: videoId, business_id: businessId },
|
||||||
|
include: videoInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPublishedVideoExists(
|
||||||
|
businessId: bigint,
|
||||||
|
videoId: bigint,
|
||||||
|
) {
|
||||||
|
const video = await this.prisma.videos.findFirst({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
business_id: businessId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw new NotFoundException('Video not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async serializeVideo(
|
||||||
|
video: VideoWithRelations,
|
||||||
|
options: {
|
||||||
|
includeComments?: boolean;
|
||||||
|
approvedCommentsOnly?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const content = this.asRecord(video.content);
|
||||||
|
const metadata = this.asRecord(video.metadata);
|
||||||
|
|
||||||
|
const [categoryAssignment, commentData] = await Promise.all([
|
||||||
|
this.prisma.categoryAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: video.business_id,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: video.id,
|
||||||
|
},
|
||||||
|
include: { category: true },
|
||||||
|
}),
|
||||||
|
options.includeComments
|
||||||
|
? this.loadComments(
|
||||||
|
video.business_id,
|
||||||
|
video.id,
|
||||||
|
options.approvedCommentsOnly,
|
||||||
|
)
|
||||||
|
: Promise.resolve({ commentCount: 0, comments: [] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: video.id.toString(),
|
||||||
|
businessId: video.business_id.toString(),
|
||||||
|
title: video.title,
|
||||||
|
titleFa: video.title_fa ?? video.title,
|
||||||
|
titleEn: video.title_en,
|
||||||
|
slug: video.slug,
|
||||||
|
abstract: video.excerpt ?? '',
|
||||||
|
descriptionHtml: (content.html as string | undefined) ?? '',
|
||||||
|
videoProvider: video.video_provider,
|
||||||
|
embedCode: video.embed_code,
|
||||||
|
status: video.status,
|
||||||
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
authorId: video.author_id?.toString() ?? null,
|
||||||
|
author: video.author
|
||||||
|
? {
|
||||||
|
id: video.author.id.toString(),
|
||||||
|
firstName: video.author.firstName,
|
||||||
|
lastName: video.author.lastName,
|
||||||
|
email: video.author.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
coverImageUrl: video.media?.publicUrl ?? null,
|
||||||
|
featuredMediaId: video.featured_media_id?.toString() ?? null,
|
||||||
|
commentCount: commentData.commentCount,
|
||||||
|
comments: commentData.comments,
|
||||||
|
publishedAt: video.published_at,
|
||||||
|
createdAt: video.created_at,
|
||||||
|
updatedAt: video.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadComments(
|
||||||
|
businessId: bigint,
|
||||||
|
videoId: bigint,
|
||||||
|
approvedOnly?: boolean,
|
||||||
|
) {
|
||||||
|
const where: Prisma.CommentWhereInput = {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
entityId: videoId,
|
||||||
|
...(approvedOnly ? { isApproved: true } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [commentCount, comments] = await Promise.all([
|
||||||
|
this.prisma.comment.count({ where }),
|
||||||
|
this.prisma.comment.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: approvedOnly ? 50 : undefined,
|
||||||
|
include: { approver: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentCount,
|
||||||
|
comments: comments.map((item) => this.serializeComment(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeComment(
|
||||||
|
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: comment.id.toString(),
|
||||||
|
businessId: comment.businessId.toString(),
|
||||||
|
entityType: comment.entityType,
|
||||||
|
entityId: comment.entityId.toString(),
|
||||||
|
authorName: comment.authorName,
|
||||||
|
authorEmail: comment.authorEmail,
|
||||||
|
text: comment.text,
|
||||||
|
isApproved: comment.isApproved,
|
||||||
|
approvedAt: comment.approvedAt,
|
||||||
|
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||||
|
approver: comment.approver
|
||||||
|
? {
|
||||||
|
id: comment.approver.id.toString(),
|
||||||
|
firstName: comment.approver.firstName,
|
||||||
|
lastName: comment.approver.lastName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: comment.createdAt,
|
||||||
|
updatedAt: comment.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContent(descriptionHtml?: string) {
|
||||||
|
return {
|
||||||
|
html: descriptionHtml ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMetadata(tags?: string[]) {
|
||||||
|
return {
|
||||||
|
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertMediaBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
mediaId: bigint,
|
||||||
|
) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
});
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException('Media not found for this business');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCategoryBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
categoryId: bigint,
|
||||||
|
) {
|
||||||
|
const category = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
id: categoryId,
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.video,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
throw new BadRequestException('Video category not found for this business');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertAuthorBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
authorId: bigint,
|
||||||
|
) {
|
||||||
|
const member = await this.prisma.businessUser.findFirst({
|
||||||
|
where: { businessId, userId: authorId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Author must be a team member of this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueSlug(
|
||||||
|
businessId: bigint,
|
||||||
|
baseSlug: string,
|
||||||
|
excludeId?: bigint,
|
||||||
|
) {
|
||||||
|
let slug = baseSlug;
|
||||||
|
let suffix = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.videos.findFirst({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
slug,
|
||||||
|
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix += 1;
|
||||||
|
slug = `${baseSlug}-${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
||||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||||
|
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl`
|
||||||
|
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
|
||||||
|
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
|
||||||
|
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
||||||
|
|
||||||
### User products (customer listings)
|
### User products (customer listings)
|
||||||
Public marketplace listings owned by customers — not catalog `products`.
|
Public marketplace listings owned by customers — not catalog `products`.
|
||||||
|
|||||||
@@ -767,6 +767,113 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Videos",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List published videos (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" if (json.items?.[0]?.id) pm.collectionVariables.set('videoId', json.items[0].id);",
|
||||||
|
" if (json.items?.[0]?.slug) pm.collectionVariables.set('videoSlug', json.items[0].slug);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/tenants/{{domain}}/videos?page=1&pageSize=12",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"tenants",
|
||||||
|
"{{domain}}",
|
||||||
|
"videos"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "page",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pageSize",
|
||||||
|
"value": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "categoryId",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "title",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get published video by slug (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoSlug}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List video comments (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoId}}/comments"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Submit video comment (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200 || pm.response.code === 201) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"authorName\": \"Video Viewer\",\n \"authorEmail\": \"viewer@example.com\",\n \"text\": \"Great video!\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/videos/{{videoId}}/comments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Portfolios",
|
"name": "Portfolios",
|
||||||
"item": [
|
"item": [
|
||||||
@@ -1526,7 +1633,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Checkout cart (saved address)",
|
"name": "Checkout cart (e-payment / Mellat)",
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "test",
|
"listen": "test",
|
||||||
@@ -1555,9 +1662,59 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"zarinpal\"\n }\n}"
|
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"returnUrl\": \"https://YOUR_WEBSITE_DOMAIN/checkout/result\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"mellat\"\n }\n}"
|
||||||
},
|
},
|
||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout"
|
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout",
|
||||||
|
"description": "Creates a pending order + transaction, calls the gateway, and returns payment.redirect { method, url, fields }. Website must POST/redirect the shopper to the bank. After payment, bank hits /payments/{gateway}/callback which redirects to returnUrl?status=success|failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Payments",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List payment methods",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/methods",
|
||||||
|
"description": "Public. Returns enabled gateways (no secrets). Also available on GET /tenants/{host} as ePayment."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mellat callback (bank → API)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "urlencoded",
|
||||||
|
"urlencoded": [
|
||||||
|
{
|
||||||
|
"key": "ResCode",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleOrderId",
|
||||||
|
"value": "{{transactionId}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleReferenceId",
|
||||||
|
"value": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "RefId",
|
||||||
|
"value": "{{refId}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
||||||
|
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+2086
-372
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user