From d5e502cf773ce3fa916f908d61a365bda2b51c28 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 13 Aug 2026 09:51:17 +0330 Subject: [PATCH] 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 --- .env.example | 3 + database/migrations/059_videos.sql | 73 + database/migrations/060_videos_old_id.sql | 7 + database/migrations/061_instructions.sql | 72 + .../migrations/062_payment_gateway_fields.sql | 14 + docs/PROJECT_CONTEXT.md | 14 +- docs/website-api/AI_PROMPT.md | 4 + ...eshkee-Website-API.postman_collection.json | 163 +- docs/website-api/openapi.json | 2696 ++++++++++++++--- prisma/schema.prisma | 80 + src/app.module.ts | 6 + .../business-admin.controller.ts | 11 + src/business-admin/business-admin.service.ts | 45 + .../dto/legacy-migrate-lookup.dto.ts | 11 + .../dto/migrate-from-old.dto.ts | 2 + src/business-admin/legacy-migrate.service.ts | 525 +++- src/business-admin/legacy-purge.service.ts | 63 + .../business-settings.service.ts | 81 +- .../business-settings.types.ts | 71 + .../business-settings.util.ts | 197 ++ .../dto/update-business-settings.dto.ts | 75 + src/cart/cart.module.ts | 4 +- src/cart/cart.service.ts | 58 +- src/cart/dto/cart.dto.ts | 9 + src/comments/comments.service.ts | 32 + src/instructions/dto/instruction.dto.ts | 199 ++ src/instructions/instructions.controller.ts | 120 + src/instructions/instructions.module.ts | 13 + src/instructions/instructions.service.ts | 843 ++++++ src/orders/orders.service.ts | 2 + src/payments/gateways/mellat.gateway.ts | 246 ++ .../gateways/payment-gateway.types.ts | 65 + src/payments/payment-gateway.registry.ts | 27 + src/payments/payments.controller.ts | 55 + src/payments/payments.module.ts | 14 + src/payments/payments.service.ts | 378 +++ src/tenant/tenant.service.ts | 3 +- src/videos/dto/video.dto.ts | 197 ++ src/videos/videos.controller.ts | 120 + src/videos/videos.module.ts | 13 + src/videos/videos.service.ts | 825 +++++ src/website-docs/static/AI_PROMPT.md | 4 + ...eshkee-Website-API.postman_collection.json | 163 +- src/website-docs/static/openapi.json | 2696 ++++++++++++++--- 44 files changed, 9303 insertions(+), 996 deletions(-) create mode 100644 database/migrations/059_videos.sql create mode 100644 database/migrations/060_videos_old_id.sql create mode 100644 database/migrations/061_instructions.sql create mode 100644 database/migrations/062_payment_gateway_fields.sql create mode 100644 src/business-admin/dto/legacy-migrate-lookup.dto.ts create mode 100644 src/instructions/dto/instruction.dto.ts create mode 100644 src/instructions/instructions.controller.ts create mode 100644 src/instructions/instructions.module.ts create mode 100644 src/instructions/instructions.service.ts create mode 100644 src/payments/gateways/mellat.gateway.ts create mode 100644 src/payments/gateways/payment-gateway.types.ts create mode 100644 src/payments/payment-gateway.registry.ts create mode 100644 src/payments/payments.controller.ts create mode 100644 src/payments/payments.module.ts create mode 100644 src/payments/payments.service.ts create mode 100644 src/videos/dto/video.dto.ts create mode 100644 src/videos/videos.controller.ts create mode 100644 src/videos/videos.module.ts create mode 100644 src/videos/videos.service.ts diff --git a/.env.example b/.env.example index ff4063f..02b09c3 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ REDIS_URL=redis://localhost:6379 # API 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_ACCESS_SECRET=change-me-access-secret-min-32-chars-long diff --git a/database/migrations/059_videos.sql b/database/migrations/059_videos.sql new file mode 100644 index 0000000..4225fe6 --- /dev/null +++ b/database/migrations/059_videos.sql @@ -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; diff --git a/database/migrations/060_videos_old_id.sql b/database/migrations/060_videos_old_id.sql new file mode 100644 index 0000000..1211e3b --- /dev/null +++ b/database/migrations/060_videos_old_id.sql @@ -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); diff --git a/database/migrations/061_instructions.sql b/database/migrations/061_instructions.sql new file mode 100644 index 0000000..39b6629 --- /dev/null +++ b/database/migrations/061_instructions.sql @@ -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; diff --git a/database/migrations/062_payment_gateway_fields.sql b/database/migrations/062_payment_gateway_fields.sql new file mode 100644 index 0000000..85d39da --- /dev/null +++ b/database/migrations/062_payment_gateway_fields.sql @@ -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; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 1c6f7bd..ade892e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -342,7 +342,16 @@ Base: `/tenants/:host/user-products` | PATCH | `/cart/items/:itemId` | Update cart item quantity | | DELETE | `/cart/items/:itemId` | Remove cart item | | 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 @@ -584,7 +593,7 @@ See `.env.example` for the full list. Key groups: |-------|-----------| | Database | `DATABASE_URL`, `POSTGRES_*` | | 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` | | 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` | @@ -608,6 +617,7 @@ See `.env.example` for the full list. Key groups: - Product variation values (which options a product offers) - Store items / product variants (price, stock, SKU) - Shopping cart + checkout + orders (customer + admin) +- Online e-payment (Mellat first; multi-gateway registry for SEP / Snapp Pay / DigiPay / Zarinpal) - Product technical info - Category variations & technical forms - Tenant resolution by domain diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 05cb798..d264c22 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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) 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` + - 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) Public marketplace listings owned by customers — not catalog `products`. diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 583c737..b039dd9 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -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", "item": [ @@ -1526,7 +1633,7 @@ } }, { - "name": "Checkout cart (saved address)", + "name": "Checkout cart (e-payment / Mellat)", "event": [ { "listen": "test", @@ -1555,9 +1662,59 @@ ], "body": { "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." } } ] diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 58b3fbc..7942c1e 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -21,24 +21,64 @@ } ], "tags": [ - { "name": "Tenant" }, - { "name": "Homepage" }, - { "name": "Categories" }, - { "name": "Products" }, - { "name": "User Products" }, - { "name": "Store" }, - { "name": "Blogs" }, - { "name": "Portfolios" }, - { "name": "Comments" }, - { "name": "Expert Reviews" }, - { "name": "Contact" }, - { "name": "Auth" }, - { "name": "Addresses" }, - { "name": "Cities" }, - { "name": "Cart" }, - { "name": "Orders" }, - { "name": "Favorites" }, - { "name": "Partner SMS" } + { + "name": "Tenant" + }, + { + "name": "Homepage" + }, + { + "name": "Categories" + }, + { + "name": "Products" + }, + { + "name": "User Products" + }, + { + "name": "Store" + }, + { + "name": "Blogs" + }, + { + "name": "Portfolios" + }, + { + "name": "Comments" + }, + { + "name": "Expert Reviews" + }, + { + "name": "Contact" + }, + { + "name": "Auth" + }, + { + "name": "Addresses" + }, + { + "name": "Cities" + }, + { + "name": "Cart" + }, + { + "name": "Orders" + }, + { + "name": "Favorites" + }, + { + "name": "Partner SMS" + }, + { + "name": "Payments", + "description": "Online e-payment gateways (website checkout)" + } ], "components": { "securitySchemes": { @@ -60,23 +100,34 @@ "in": "path", "required": true, "description": "Website apex host only (e.g. sanihome.ir). No www/api/customer/business prefix.", - "schema": { "type": "string", "example": "example.com" } + "schema": { + "type": "string", + "example": "example.com" + } }, "businessId": { "name": "businessId", "in": "path", "required": true, "description": "From GET /tenants/{domain} → id", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } } }, "paths": { "/tenants/{domain}": { "get": { - "tags": ["Tenant"], + "tags": [ + "Tenant" + ], "summary": "Resolve website domain → business", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "responses": { "200": { "description": "Business branding", @@ -85,15 +136,40 @@ "schema": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "nameFa": { "type": "string" }, - "slug": { "type": "string" }, - "domain": { "type": "string" }, - "primaryColor": { "type": "string", "nullable": true }, - "defaultLocale": { "type": "string", "enum": ["en", "fa"] }, - "logoUrl": { "type": "string", "nullable": true }, - "faviconUrl": { "type": "string", "nullable": true } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nameFa": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "defaultLocale": { + "type": "string", + "enum": [ + "en", + "fa" + ] + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "faviconUrl": { + "type": "string", + "nullable": true + } } } } @@ -104,136 +180,357 @@ }, "/tenants/{domain}/website/business-info": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "About, contacts, addresses, social", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "Business public profile" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Business public profile" + } + } } }, "/tenants/{domain}/website/sliders": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage sliders + slides", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: Slider[] }" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: Slider[] }" + } + } } }, "/tenants/{domain}/website/category-groups": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage category groups", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" + } + } } }, "/tenants/{domain}/website/brand-groups": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage brand groups", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" + } + } } }, "/tenants/{domain}/store-specials": { "get": { - "tags": ["Homepage", "Store"], + "tags": [ + "Homepage", + "Store" + ], "summary": "Active store specials", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" + } + } } }, "/tenants/{domain}/categories": { "get": { - "tags": ["Categories"], + "tags": [ + "Categories" + ], "summary": "Public categories", "parameters": [ - { "$ref": "#/components/parameters/domain" }, + { + "$ref": "#/components/parameters/domain" + }, { "name": "entityType", "in": "query", "schema": { "type": "string", - "enum": ["product", "blog", "portfolio"], + "enum": [ + "product", + "blog", + "portfolio", + "video" + ], "default": "product" } } ], - "responses": { "200": { "description": "{ items: Category[] }" } } + "responses": { + "200": { + "description": "{ items: Category[] }" + } + } } }, "/tenants/{domain}/products": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "List published products", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "name", "in": "query", "schema": { "type": "string" } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "brandId", "in": "query", "schema": { "type": "string" } }, - { "name": "tag", "in": "query", "schema": { "type": "string" } }, - { "name": "inStore", "in": "query", "schema": { "type": "boolean" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "name", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "brandId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "inStore", + "in": "query", + "schema": { + "type": "boolean" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/products/{slug}": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ product }" } } + "responses": { + "200": { + "description": "{ product }" + } + } } }, "/tenants/{domain}/products/{slug}/variations": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product variation options", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ variations }" } } + "responses": { + "200": { + "description": "{ variations }" + } + } } }, "/tenants/{domain}/products/{slug}/technical-info": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product technical specs", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ form, values }" } } + "responses": { + "200": { + "description": "{ form, values }" + } + } } }, "/tenants/{domain}/user-products": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "List published customer / stock listings", "description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } }, - { "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "cityId", "in": "query", "schema": { "type": "string" } }, - { "name": "countryId", "in": "query", "schema": { "type": "string" } }, + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "name", + "in": "query", + "description": "Search title/description (alias of q)", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "description": "Search title/description (alias of name)", + "schema": { + "type": "string" + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cityId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "countryId", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "condition", "in": "query", "schema": { "type": "string", - "enum": ["new", "stock", "needs_repair", "scrap"] + "enum": [ + "new", + "stock", + "needs_repair", + "scrap" + ] } }, - { "name": "promoted", "in": "query", "schema": { "type": "boolean" } } + { + "name": "promoted", + "in": "query", + "schema": { + "type": "boolean" + } + } ], "responses": { "200": { @@ -244,117 +541,331 @@ }, "/tenants/{domain}/user-products/{slug}": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "User product details by slug", "description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "responses": { "200": { "description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug" }, - "404": { "description": "Not found or not published" } + "404": { + "description": "Not found or not published" + } } } }, "/tenants/{domain}/user-products/{slug}/technical-info": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "User product technical form + values", "description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ form, values }" } } + "responses": { + "200": { + "description": "{ form, values }" + } + } } }, "/tenants/{domain}/store-items": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "List sellable variants", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "brandId", "in": "query", "schema": { "type": "string" } }, - { "name": "productId", "in": "query", "schema": { "type": "string" } }, - { "name": "name", "in": "query", "schema": { "type": "string" } }, - { "name": "inStock", "in": "query", "schema": { "type": "boolean" } }, - { "name": "isFestival", "in": "query", "schema": { "type": "boolean" } }, - { "name": "minPrice", "in": "query", "schema": { "type": "number" } }, - { "name": "maxPrice", "in": "query", "schema": { "type": "number" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "brandId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "productId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "inStock", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFestival", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "minPrice", + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "maxPrice", + "in": "query", + "schema": { + "type": "number" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/store-items/by-product/{productId}": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "Variants for one product", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ storeItem }" } } + "responses": { + "200": { + "description": "{ storeItem }" + } + } } }, "/tenants/{domain}/store-items/{variantId}": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "One variant", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "variantId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "variantId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ variant }" } } + "responses": { + "200": { + "description": "{ variant }" + } + } } }, "/tenants/{domain}/blogs": { "get": { - "tags": ["Blogs"], + "tags": [ + "Blogs" + ], "summary": "List published blogs", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["news", "article", "blog"] } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "title", "in": "query", "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "news", + "article", + "blog" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/blogs/{slug}": { "get": { - "tags": ["Blogs"], + "tags": [ + "Blogs" + ], "summary": "Blog by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ blog }" } } + "responses": { + "200": { + "description": "{ blog }" + } + } } }, "/tenants/{domain}/blogs/{blogId}/comments": { "get": { - "tags": ["Blogs", "Comments"], + "tags": [ + "Blogs", + "Comments" + ], "summary": "Approved blog comments", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Blogs", "Comments"], + "tags": [ + "Blogs", + "Comments" + ], "summary": "Submit blog comment", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "requestBody": { "required": true, @@ -362,60 +873,310 @@ "application/json": { "schema": { "type": "object", - "required": ["authorName", "text"], + "required": [ + "authorName", + "text" + ], "properties": { - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } + } + }, + "/tenants/{domain}/videos": { + "get": { + "tags": [ + "Videos" + ], + "summary": "List published videos", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "provider", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "youtube", + "aparat" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } + } + }, + "/tenants/{domain}/videos/{slug}": { + "get": { + "tags": [ + "Videos" + ], + "summary": "Video by slug", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ video }" + } + } + } + }, + "/tenants/{domain}/videos/{videoId}/comments": { + "get": { + "tags": [ + "Videos", + "Comments" + ], + "summary": "Approved video comments", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "videoId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Videos", + "Comments" + ], + "summary": "Submit video comment", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "videoId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "authorName", + "text" + ], + "properties": { + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/portfolios": { "get": { - "tags": ["Portfolios"], + "tags": [ + "Portfolios" + ], "summary": "List published portfolios", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "title", "in": "query", "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/portfolios/{slug}": { "get": { - "tags": ["Portfolios"], + "tags": [ + "Portfolios" + ], "summary": "Portfolio by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ portfolio }" } } + "responses": { + "200": { + "description": "{ portfolio }" + } + } } }, "/tenants/{domain}/portfolios/{portfolioId}/comments": { "get": { - "tags": ["Portfolios", "Comments"], + "tags": [ + "Portfolios", + "Comments" + ], "summary": "Approved portfolio comments", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Portfolios", "Comments"], + "tags": [ + "Portfolios", + "Comments" + ], "summary": "Submit portfolio comment", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "requestBody": { "required": true, @@ -423,126 +1184,273 @@ "application/json": { "schema": { "type": "object", - "required": ["authorName", "text"], + "required": [ + "authorName", + "text" + ], "properties": { - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/comments": { "get": { - "tags": ["Comments"], + "tags": [ + "Comments" + ], "summary": "List approved comments for any entity", "parameters": [ - { "$ref": "#/components/parameters/domain" }, + { + "$ref": "#/components/parameters/domain" + }, { "name": "entityType", "in": "query", "required": true, - "schema": { "type": "string", "enum": ["product", "blog", "portfolio"] } + "schema": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ] + } }, - { "name": "entityId", "in": "query", "required": true, "schema": { "type": "string" } } + { + "name": "entityId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Comments"], + "tags": [ + "Comments" + ], "summary": "Submit comment (product/blog/portfolio)", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["entityType", "entityId", "authorName", "text"], + "required": [ + "entityType", + "entityId", + "authorName", + "text" + ], "properties": { - "entityType": { "type": "string", "enum": ["product", "blog", "portfolio"] }, - "entityId": { "type": "string" }, - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "entityType": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ] + }, + "entityId": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/expert-reviews": { "get": { - "tags": ["Expert Reviews"], + "tags": [ + "Expert Reviews" + ], "summary": "Approved expert reviews for a product", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "productId", "in": "query", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Expert Reviews"], + "tags": [ + "Expert Reviews" + ], "summary": "Submit expert review", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["productId", "authorName", "rate", "positivePoints", "negativePoints", "text"], + "required": [ + "productId", + "authorName", + "rate", + "positivePoints", + "negativePoints", + "text" + ], "properties": { - "productId": { "type": "string" }, - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "rate": { "type": "integer", "minimum": 1, "maximum": 10 }, - "positivePoints": { "type": "array", "items": { "type": "string" } }, - "negativePoints": { "type": "array", "items": { "type": "string" } }, - "text": { "type": "string" } + "productId": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "rate": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "positivePoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "negativePoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ review, message }" } } + "responses": { + "201": { + "description": "{ review, message }" + } + } } }, "/tenants/{domain}/contact-submissions": { "post": { - "tags": ["Contact"], + "tags": [ + "Contact" + ], "summary": "Contact form", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["title", "name", "text"], + "required": [ + "title", + "name", + "text" + ], "properties": { - "title": { "type": "string" }, - "name": { "type": "string" }, - "email": { "type": "string" }, - "cellNumber": { "type": "string" }, - "text": { "type": "string" } + "title": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "cellNumber": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ submission, message }" } } + "responses": { + "201": { + "description": "{ submission, message }" + } + } } }, "/auth/register": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Register customer on a website", "requestBody": { "required": true, @@ -550,14 +1458,35 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "password", "firstName", "lastName", "domain"], + "required": [ + "cellNumber", + "password", + "firstName", + "lastName", + "domain" + ], "properties": { - "cellNumber": { "type": "string", "description": "E.164 e.g. +98912..." }, - "password": { "type": "string", "minLength": 8 }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "email": { "type": "string" }, - "domain": { "type": "string", "description": "Same website apex as {domain}" }, + "cellNumber": { + "type": "string", + "description": "E.164 e.g. +98912..." + }, + "password": { + "type": "string", + "minLength": 8 + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "domain": { + "type": "string", + "description": "Same website apex as {domain}" + }, "acknowledgeExistingAccount": { "type": "boolean", "description": "If true, link an existing Meshkee account from another website without matching its password. Existing password and profile stay unchanged." @@ -567,12 +1496,18 @@ } } }, - "responses": { "201": { "description": "{ user, accessToken, refreshToken, registeredBusiness }" } } + "responses": { + "201": { + "description": "{ user, accessToken, refreshToken, registeredBusiness }" + } + } } }, "/auth/login": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Login", "requestBody": { "required": true, @@ -580,21 +1515,34 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "password"], + "required": [ + "cellNumber", + "password" + ], "properties": { - "cellNumber": { "type": "string" }, - "password": { "type": "string" } + "cellNumber": { + "type": "string" + }, + "password": { + "type": "string" + } } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/login-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Passwordless login with SMS OTP", "requestBody": { "required": true, @@ -602,21 +1550,36 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code"], + "required": [ + "cellNumber", + "code" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 } + "cellNumber": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 + } } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/reset-password": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Reset password with SMS OTP", "requestBody": { "required": true, @@ -624,22 +1587,41 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code", "newPassword"], + "required": [ + "cellNumber", + "code", + "newPassword" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 }, - "newPassword": { "type": "string", "minLength": 8 } + "cellNumber": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 + }, + "newPassword": { + "type": "string", + "minLength": 8 + } } } } } }, - "responses": { "200": { "description": "{ message }" } } + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/auth/refresh": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Refresh tokens", "requestBody": { "required": true, @@ -647,57 +1629,107 @@ "application/json": { "schema": { "type": "object", - "required": ["refreshToken"], - "properties": { "refreshToken": { "type": "string" } } + "required": [ + "refreshToken" + ], + "properties": { + "refreshToken": { + "type": "string" + } + } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/me": { "get": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Current user", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ user }" } } + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ user }" + } + } } }, "/auth/profile": { "patch": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Update profile", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ message, user }" } } + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ message, user }" + } + } } }, "/auth/change-password": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Change password", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["currentPassword", "newPassword"], + "required": [ + "currentPassword", + "newPassword" + ], "properties": { - "currentPassword": { "type": "string" }, - "newPassword": { "type": "string", "minLength": 8 } + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string", + "minLength": 8 + } } } } } }, - "responses": { "200": { "description": "{ message }" } } + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/auth/send-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Send OTP SMS", "requestBody": { "required": true, @@ -705,9 +1737,13 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber"], + "required": [ + "cellNumber" + ], "properties": { - "cellNumber": { "type": "string" }, + "cellNumber": { + "type": "string" + }, "domain": { "type": "string", "description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name" @@ -717,12 +1753,18 @@ } } }, - "responses": { "200": { "description": "{ enabled, message, expiresInSeconds? }" } } + "responses": { + "200": { + "description": "{ enabled, message, expiresInSeconds? }" + } + } } }, "/auth/verify-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Verify OTP", "requestBody": { "required": true, @@ -730,310 +1772,711 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code"], + "required": [ + "cellNumber", + "code" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 } - } - } - } - } - }, - "responses": { "200": { "description": "{ enabled, verified, message }" } } - } - }, - "/auth/addresses": { - "get": { - "tags": ["Addresses"], - "summary": "List my shipping addresses", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ items }" } } - }, - "post": { - "tags": ["Addresses"], - "summary": "Create address", - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["province", "city", "address"], - "properties": { - "label": { "type": "string" }, - "province": { "type": "string" }, - "city": { "type": "string" }, - "address": { "type": "string" }, - "postalCode": { "type": "string" }, - "landline": { "type": "string" } - } - } - } - } - }, - "responses": { "201": { "description": "{ address }" } } - } - }, - "/auth/addresses/{addressId}": { - "patch": { - "tags": ["Addresses"], - "summary": "Update address", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ address }" } } - }, - "delete": { - "tags": ["Addresses"], - "summary": "Delete address", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ message }" } } - } - }, - "/cities": { - "get": { - "tags": ["Cities"], - "summary": "Location tree (countries / provinces / cities)", - "parameters": [ - { - "name": "level", - "in": "query", - "schema": { "type": "string", "enum": ["country", "province", "city"] } - }, - { "name": "parentId", "in": "query", "schema": { "type": "string" } }, - { "name": "parentSlug", "in": "query", "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ items }" } } - } - }, - "/cities/{cityId}": { - "get": { - "tags": ["Cities"], - "summary": "Get one location node", - "parameters": [ - { "name": "cityId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ city }" } } - } - }, - "/businesses/{businessId}/cart": { - "get": { - "tags": ["Cart"], - "summary": "Get cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "responses": { "200": { "description": "{ cart }" } } - }, - "delete": { - "tags": ["Cart"], - "summary": "Clear cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "responses": { "200": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/items": { - "post": { - "tags": ["Cart"], - "summary": "Add variant to cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["storeItemVariantId"], - "properties": { - "storeItemVariantId": { "type": "string" }, - "quantity": { "type": "integer", "minimum": 1, "default": 1 } - } - } - } - } - }, - "responses": { "201": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/items/{itemId}": { - "patch": { - "tags": ["Cart"], - "summary": "Update cart line quantity", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["quantity"], - "properties": { "quantity": { "type": "integer", "minimum": 1 } } - } - } - } - }, - "responses": { "200": { "description": "{ message, cart }" } } - }, - "delete": { - "tags": ["Cart"], - "summary": "Remove cart line", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/checkout": { - "post": { - "tags": ["Cart"], - "summary": "Checkout → create order", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["payment"], - "properties": { - "addressId": { "type": "string" }, - "shippingAddress": { - "type": "object", - "properties": { - "province": { "type": "string" }, - "city": { "type": "string" }, - "address": { "type": "string" }, - "postalCode": { "type": "string" }, - "landline": { "type": "string" } - } + "cellNumber": { + "type": "string" }, - "customerNotes": { "type": "string" }, - "payment": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": ["pos", "cash", "transfer", "e_payment_gate"] - }, - "posType": { "type": "string" }, - "transferAccount": { "type": "string" }, - "transferRefNumber": { "type": "string" }, - "gatewayType": { "type": "string" }, - "notes": { "type": "string" } - } + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 } } } } } }, - "responses": { "201": { "description": "{ message, order }" } } + "responses": { + "200": { + "description": "{ enabled, verified, message }" + } + } + } + }, + "/auth/addresses": { + "get": { + "tags": [ + "Addresses" + ], + "summary": "List my shipping addresses", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Addresses" + ], + "summary": "Create address", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "province", + "city", + "address" + ], + "properties": { + "label": { + "type": "string" + }, + "province": { + "type": "string" + }, + "city": { + "type": "string" + }, + "address": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "landline": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ address }" + } + } + } + }, + "/auth/addresses/{addressId}": { + "patch": { + "tags": [ + "Addresses" + ], + "summary": "Update address", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "addressId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ address }" + } + } + }, + "delete": { + "tags": [ + "Addresses" + ], + "summary": "Delete address", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "addressId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message }" + } + } + } + }, + "/cities": { + "get": { + "tags": [ + "Cities" + ], + "summary": "Location tree (countries / provinces / cities)", + "parameters": [ + { + "name": "level", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "country", + "province", + "city" + ] + } + }, + { + "name": "parentId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parentSlug", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + } + }, + "/cities/{cityId}": { + "get": { + "tags": [ + "Cities" + ], + "summary": "Get one location node", + "parameters": [ + { + "name": "cityId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ city }" + } + } + } + }, + "/businesses/{businessId}/cart": { + "get": { + "tags": [ + "Cart" + ], + "summary": "Get cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ cart }" + } + } + }, + "delete": { + "tags": [ + "Cart" + ], + "summary": "Clear cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/items": { + "post": { + "tags": [ + "Cart" + ], + "summary": "Add variant to cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "storeItemVariantId" + ], + "properties": { + "storeItemVariantId": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "default": 1 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/items/{itemId}": { + "patch": { + "tags": [ + "Cart" + ], + "summary": "Update cart line quantity", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "{ message, cart }" + } + } + }, + "delete": { + "tags": [ + "Cart" + ], + "summary": "Remove cart line", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/checkout": { + "post": { + "tags": [ + "Cart" + ], + "summary": "Checkout → create order", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "payment" + ], + "properties": { + "addressId": { + "type": "string" + }, + "shippingAddress": { + "type": "object", + "properties": { + "province": { + "type": "string" + }, + "city": { + "type": "string" + }, + "address": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "landline": { + "type": "string" + } + } + }, + "customerNotes": { + "type": "string" + }, + "payment": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pos", + "cash", + "transfer", + "e_payment_gate" + ] + }, + "posType": { + "type": "string" + }, + "transferAccount": { + "type": "string" + }, + "transferRefNumber": { + "type": "string" + }, + "gatewayType": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ], + "description": "Required when type is e_payment_gate (defaults to store defaultGateway if omitted)." + }, + "notes": { + "type": "string" + } + } + }, + "returnUrl": { + "type": "string", + "format": "uri", + "description": "Required for e_payment_gate. Absolute URL the bank callback redirects the shopper to (status, orderId, transactionId query params appended)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Cash/transfer/pos: { message, order }. E-payment: { message, order, payment: { gatewayType, transactionId, redirect: { method, url, fields } } }" + } + } } }, "/businesses/{businessId}/orders": { "get": { - "tags": ["Orders"], + "tags": [ + "Orders" + ], "summary": "My orders", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + }, { "name": "status", "in": "query", "schema": { "type": "string", - "enum": ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"] + "enum": [ + "pending", + "confirmed", + "processing", + "shipped", + "delivered", + "cancelled" + ] } } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/businesses/{businessId}/orders/{orderId}": { "get": { - "tags": ["Orders"], - "summary": "My order detail", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } } + "tags": [ + "Orders" ], - "responses": { "200": { "description": "{ order }" } } + "summary": "My order detail", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ order }" + } + } } }, "/businesses/{businessId}/favorites": { "get": { - "tags": ["Favorites"], - "summary": "List favorites", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } } + "tags": [ + "Favorites" ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "summary": "List favorites", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } }, "post": { - "tags": ["Favorites"], + "tags": [ + "Favorites" + ], "summary": "Add favorite", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["productId"], - "properties": { "productId": { "type": "string" } } + "required": [ + "productId" + ], + "properties": { + "productId": { + "type": "string" + } + } } } } }, - "responses": { "201": { "description": "{ favorite, message }" } } + "responses": { + "201": { + "description": "{ favorite, message }" + } + } } }, "/businesses/{businessId}/favorites/{productId}": { "delete": { - "tags": ["Favorites"], - "summary": "Remove favorite", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } } + "tags": [ + "Favorites" ], - "responses": { "200": { "description": "{ message }" } } + "summary": "Remove favorite", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/public/sms/send": { "post": { - "tags": ["Partner SMS"], + "tags": [ + "Partner SMS" + ], "summary": "Send SMS via Meshkee (partner gateway)", "description": "Server-to-server only. For external/partner backends (e.g. Balout) that need to send SMS through Meshkee → Gama. Not for browser/storefront JS. Requires an allowlisted `domain` + matching `X-Api-Key`. See /docs/website/SMS.md.", - "security": [{ "apiKeyAuth": [] }], + "security": [ + { + "apiKeyAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["domain", "to", "message"], + "required": [ + "domain", + "to", + "message" + ], "properties": { "domain": { "type": "string", @@ -1063,17 +2506,288 @@ "schema": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, - "serverId": { "type": "string", "example": "1136923081051406337" } + "success": { + "type": "boolean", + "example": true + }, + "serverId": { + "type": "string", + "example": "1136923081051406337" + } } } } } }, - "400": { "description": "Invalid phone or message" }, - "401": { "description": "Missing/invalid X-Api-Key or domain" }, - "429": { "description": "Rate limited (30/partner/min or 5/destination/min)" }, - "503": { "description": "SMS disabled or provider unreachable" } + "400": { + "description": "Invalid phone or message" + }, + "401": { + "description": "Missing/invalid X-Api-Key or domain" + }, + "429": { + "description": "Rate limited (30/partner/min or 5/destination/min)" + }, + "503": { + "description": "SMS disabled or provider unreachable" + } + } + } + }, + "/tenants/{domain}/instructions": { + "get": { + "tags": [ + "Instructions" + ], + "summary": "List published instructions", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "provider", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "youtube", + "aparat" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } + } + }, + "/tenants/{domain}/instructions/{slug}": { + "get": { + "tags": [ + "Instructions" + ], + "summary": "Instruction by slug", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ instruction }" + } + } + } + }, + "/tenants/{domain}/instructions/{instructionId}/comments": { + "get": { + "tags": [ + "Instructions", + "Comments" + ], + "summary": "Approved instruction comments", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "instructionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Instructions", + "Comments" + ], + "summary": "Submit instruction comment", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "instructionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "authorName", + "text" + ], + "properties": { + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ comment, message }" + } + } + } + }, + "/businesses/{businessId}/payments/methods": { + "get": { + "tags": [ + "Payments" + ], + "summary": "List enabled online payment gateways (public)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ enabled, defaultGateway, gateways: [{ id, label, labelFa }] } — no secrets" + } + } + } + }, + "/businesses/{businessId}/payments/{gateway}/callback": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Bank payment callback (public; redirects browser to returnUrl)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "gateway", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Gateway-specific fields (Mellat: ResCode, SaleOrderId, SaleReferenceId, RefId, …)" + } + } + } + }, + "responses": { + "303": { + "description": "Redirect to checkout returnUrl with status=success|failed" + } + } + }, + "get": { + "tags": [ + "Payments" + ], + "summary": "Bank payment callback (GET variant)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "gateway", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ] + } + } + ], + "responses": { + "303": { + "description": "Redirect to checkout returnUrl with status=success|failed" + } } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87c7253..6b6a4f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,8 @@ model User { ordersCreated Order[] @relation("OrderCreator") orders Order[] @relation("OrderCustomer") authoredPortfolios portfolios[] @relation("PortfolioAuthor") + authoredVideos videos[] @relation("VideoAuthor") + authoredInstructions instructions[] @relation("InstructionAuthor") shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator") shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer") transactionsCreated Transaction[] @relation("TransactionCreator") @@ -93,6 +95,8 @@ model Business { mediaAttachments MediaAttachment[] orders Order[] portfolios portfolios[] + videos videos[] + instructions instructions[] productTechnicalFieldValues ProductTechnicalFieldValue[] products Product[] shoppingCards ShoppingCard[] @@ -283,6 +287,8 @@ model Media { uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction) attachments MediaAttachment[] portfolios portfolios[] + videos videos[] + instructions instructions[] featuredProducts Product[] @relation("ProductFeaturedMedia") featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") website_slider_slides website_slider_slides[] @@ -671,6 +677,68 @@ model portfolios { @@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. model Address { id BigInt @id @default(autoincrement()) @@ -938,6 +1006,9 @@ model Transaction { gatewayType String? @map("gateway_type") @db.VarChar(100) transferAccount String? @map("transfer_account") @db.VarChar(255) 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? createdBy BigInt? @map("created_by") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@ -1340,10 +1411,19 @@ enum MediaEntityType { portfolio customer user_product + video + instruction @@map("media_entity_type") } +enum VideoProvider { + youtube + aparat + + @@map("video_provider") +} + enum VariationType { color size diff --git a/src/app.module.ts b/src/app.module.ts index 78ddb89..4e67888 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,8 @@ import { CategoriesModule } from './categories/categories.module'; import { ProductsModule } from './products/products.module'; import { BlogsModule } from './blogs/blogs.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 { CitiesModule } from './cities/cities.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 { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module'; import { PublicSmsModule } from './public-sms/public-sms.module'; +import { PaymentsModule } from './payments/payments.module'; @Module({ imports: [ @@ -58,6 +61,8 @@ import { PublicSmsModule } from './public-sms/public-sms.module'; ProductsModule, BlogsModule, PortfoliosModule, + VideosModule, + InstructionsModule, CommentsModule, ExpertReviewsModule, BusinessSettingsModule, @@ -66,6 +71,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module'; StoreModule, CartModule, OrdersModule, + PaymentsModule, CustomersModule, ShoppingCardsModule, ContactSubmissionsModule, diff --git a/src/business-admin/business-admin.controller.ts b/src/business-admin/business-admin.controller.ts index 6081585..3931aad 100644 --- a/src/business-admin/business-admin.controller.ts +++ b/src/business-admin/business-admin.controller.ts @@ -10,6 +10,7 @@ import { UpdateDomainDto } from './dto/update-domain.dto'; import { DisableBusinessDto } from './dto/disable-business.dto'; import { UpdateBusinessDto } from './dto/update-business.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 { BusinessAdminService } from './business-admin.service'; @@ -35,6 +36,16 @@ export class BusinessAdminController { 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') @UseGuards(JwtAuthGuard) getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 10494af..d95876c 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -18,6 +18,7 @@ import { UpdateBusinessDto } from './dto/update-business.dto'; import { ListBusinessesDto } from './dto/list-businesses.dto'; import { SearchBusinessesDto } from './dto/search-businesses.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 { LegacyMigrateService } from './legacy-migrate.service'; import { LegacyPurgeService } from './legacy-purge.service'; @@ -676,6 +677,50 @@ export class BusinessAdminService { 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) { await this.assertSuperAdmin(actor); diff --git a/src/business-admin/dto/legacy-migrate-lookup.dto.ts b/src/business-admin/dto/legacy-migrate-lookup.dto.ts new file mode 100644 index 0000000..1d2f604 --- /dev/null +++ b/src/business-admin/dto/legacy-migrate-lookup.dto.ts @@ -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; +} diff --git a/src/business-admin/dto/migrate-from-old.dto.ts b/src/business-admin/dto/migrate-from-old.dto.ts index 1d2e783..201a08d 100644 --- a/src/business-admin/dto/migrate-from-old.dto.ts +++ b/src/business-admin/dto/migrate-from-old.dto.ts @@ -8,6 +8,8 @@ export const MIGRATE_FROM_OLD_ENTITIES = [ 'customer', 'blog_categories', 'blog', + 'video_categories', + 'video', 'portfolio_categories', 'portfolio', ] as const; diff --git a/src/business-admin/legacy-migrate.service.ts b/src/business-admin/legacy-migrate.service.ts index a3d7351..0dd793c 100644 --- a/src/business-admin/legacy-migrate.service.ts +++ b/src/business-admin/legacy-migrate.service.ts @@ -5,6 +5,7 @@ import { MediaEntityType, MediaType, Prisma, + VideoProvider, } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { randomUUID } from 'crypto'; @@ -77,6 +78,24 @@ type OldArticleRow = RowDataPacket & { 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 & { id: number; title: string; @@ -227,6 +246,7 @@ function normalizeLegacyPasswordHash(hash: string | null | undefined): string { type ContentCategoryEntity = | typeof MediaEntityType.portfolio | typeof MediaEntityType.blog + | typeof MediaEntityType.video | typeof MediaEntityType.customer | typeof MediaEntityType.product; @@ -239,6 +259,157 @@ export class LegacyMigrateService { 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(); + 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( businessId: 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')) { results.customer_categories = await this.migrateNestedCategories( businessId, @@ -332,6 +514,21 @@ export class LegacyMigrateService { 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 (!results.customer_categories) { results[entity] = await this.migrateNestedCategories( @@ -375,6 +572,11 @@ export class LegacyMigrateService { continue; } + if (entity === 'video') { + results[entity] = await this.migrateVideos(businessId, oldBusinessId); + continue; + } + if (entity === 'customer') { results[entity] = await this.migrateCustomers(businessId, oldBusinessId); continue; @@ -413,14 +615,16 @@ export class LegacyMigrateService { table: | 'portfolio_categories' | 'article_categories' + | 'video_categories' | 'client_categories' | 'product_categories'; hasSlug: boolean; }, ): Promise { const slugSelect = opts.hasSlug ? 'slug' : 'NULL AS slug'; + const nameEnSelect = await this.legacySelectCol(opts.table, 'name_en'); const rows = await this.legacyMysql.query( - `SELECT id, name, name_en, parent_id, ${slugSelect}, \`_lft\` AS _lft + `SELECT id, name, ${nameEnSelect}, parent_id, ${slugSelect}, \`_lft\` AS _lft FROM ${opts.table} WHERE business_id = ? ORDER BY \`_lft\` ASC`, @@ -800,6 +1004,280 @@ export class LegacyMigrateService { }; } + private async migrateVideos( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const bizRows = await this.legacyMysql.query( + `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( + `SELECT ${videoSelect} + FROM ${videoTable} + WHERE business_id = ?${videoDeleted} + ORDER BY id ASC`, + [oldBizId], + ); + + const mediaRows = await this.legacyMysql.query( + `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(); + 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(); + 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(); + 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(); + + 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( + 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( businessId: bigint, oldBusinessId: bigint, @@ -1869,6 +2347,27 @@ export class LegacyMigrateService { return `${slug}-${Date.now()}`; } + private async ensureUniqueVideoSlug( + businessId: bigint, + baseSlug: string, + oldId: number, + ): Promise { + 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( businessId: bigint, oldBusinessId: bigint, @@ -2162,6 +2661,30 @@ export class LegacyMigrateService { 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 { + 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 { + if (!(await this.legacyColumnExists(tableName, 'deleted_at'))) { + return ''; + } + const prefix = tableAlias ? `${tableAlias}.` : ''; + return ` AND ${prefix}deleted_at IS NULL`; + } + private async migrateProductCategories( businessId: bigint, oldBusinessId: bigint, diff --git a/src/business-admin/legacy-purge.service.ts b/src/business-admin/legacy-purge.service.ts index d5a50d7..7c9164d 100644 --- a/src/business-admin/legacy-purge.service.ts +++ b/src/business-admin/legacy-purge.service.ts @@ -7,6 +7,7 @@ import { PurgeBusinessDataEntity } from './dto/purge-business-data.dto'; type ContentCategoryEntity = | typeof MediaEntityType.portfolio | typeof MediaEntityType.blog + | typeof MediaEntityType.video | typeof MediaEntityType.customer | typeof MediaEntityType.product; @@ -55,6 +56,12 @@ export class LegacyPurgeService { if (selected.has('blog_categories')) { 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')) { results.customer = await this.purgeCustomers(businessId); } @@ -279,6 +286,62 @@ export class LegacyPurgeService { return this.purgeCategories(businessId, MediaEntityType.blog); } + private async purgeVideos(businessId: bigint): Promise { + 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(); + 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 { + return this.purgeCategories(businessId, MediaEntityType.video); + } + private async purgeCustomers(businessId: bigint): Promise { const links = await this.prisma.businessCustomer.findMany({ where: { businessId }, diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 01351d5..04ec2df 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -9,9 +9,13 @@ import { PrismaService } from '../prisma/prisma.service'; import { BusinessSettings } from './business-settings.types'; import { mergeBusinessSettings, + mergeEPaymentSettings, + mellatPasswordSet, normalizeBusinessSettings, normalizeEnabledBusinessModules, + normalizeEPaymentSettings, normalizeHomeCharts, + sanitizeBusinessSettingsForClient, toPrismaJson, } from './business-settings.util'; import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto'; @@ -39,7 +43,9 @@ export class BusinessSettingsService { return { businessId: business.id.toString(), - settings: normalizeBusinessSettings(business.settings), + settings: this.toClientSettings( + normalizeBusinessSettings(business.settings), + ), }; } @@ -98,6 +104,56 @@ export class BusinessSettingsService { } 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 = { onlineSellEnabled: dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled, @@ -106,6 +162,7 @@ export class BusinessSettingsService { store: { orderProcessSteps: dto.store.orderProcessSteps }, }).store.orderProcessSteps : current.store.orderProcessSteps, + ePayment: ePaymentPatch ?? current.store.ePayment, }; } @@ -132,7 +189,7 @@ export class BusinessSettingsService { return { businessId: updated.id.toString(), - settings: next, + settings: this.toClientSettings(next), }; } @@ -169,6 +226,26 @@ export class BusinessSettingsService { 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( businessId: bigint, userId: bigint, diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 8e4cbec..ca0116e 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -38,10 +38,56 @@ export type OrderProcessStep = { 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. */ export type StoreSettings = { onlineSellEnabled: boolean; orderProcessSteps: OrderProcessStep[]; + ePayment: EPaymentSettings; }; /** 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', 'warehouse', 'videos', + 'instructions', 'finance', ] as const; @@ -134,6 +181,29 @@ export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [ '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 = { branding: { primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID, @@ -147,6 +217,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = { store: { onlineSellEnabled: true, orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS, + ePayment: { ...DEFAULT_EPAYMENT_SETTINGS }, }, modules: { enabled: DEFAULT_ENABLED_BUSINESS_MODULES, diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 2378cad..8e884b9 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -10,13 +10,21 @@ import { DEFAULT_BUSINESS_SETTINGS, DEFAULT_BUSINESS_THEME_MODE, DEFAULT_ENABLED_BUSINESS_MODULES, + DEFAULT_EPAYMENT_SETTINGS, DEFAULT_HOME_CHARTS, + DEFAULT_MELLAT_GATEWAY_SETTINGS, DEFAULT_ORDER_PROCESS_STEPS, + DEFAULT_STUB_GATEWAY_SETTINGS, + EPaymentSettings, HOME_CHART_IDS, + MellatGatewaySettings, + PAYMENT_GATEWAY_IDS, type DashboardLocale, type DashboardThemeMode, type HomeChartId, + type PaymentGatewayId, OrderProcessStep, + StubGatewaySettings, } from './business-settings.types'; import { defaultOrderStepColor, @@ -80,6 +88,190 @@ function readBoolean(value: unknown, fallback: boolean) { 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[] { if (!Array.isArray(value)) { return DEFAULT_ORDER_PROCESS_STEPS; @@ -152,6 +344,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings { DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled, ), orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps), + ePayment: normalizeEPaymentSettings(store.ePayment), }, modules: { // Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty. @@ -194,6 +387,10 @@ export function mergeBusinessSettings( patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled, orderProcessSteps: patch.store?.orderProcessSteps ?? current.store.orderProcessSteps, + ePayment: mergeEPaymentSettings( + current.store.ePayment, + patch.store?.ePayment, + ), }, modules: { enabled: patch.modules?.enabled ?? current.modules.enabled, diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 3c6b116..cfcb77b 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -8,6 +8,7 @@ import { IsOptional, IsString, MinLength, + ValidateIf, ValidateNested, } from 'class-validator'; import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors'; @@ -15,6 +16,7 @@ import { BUSINESS_PRIMARY_COLOR_IDS } from '../business-primary-colors'; import { BUSINESS_MODULE_IDS, HOME_CHART_IDS, + PAYMENT_GATEWAY_IDS, } from '../business-settings.types'; class BrandingSettingsDto { @@ -77,6 +79,74 @@ class OrderProcessStepDto { 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 { @IsOptional() @IsBoolean() @@ -87,6 +157,11 @@ class StoreSettingsDto { @ValidateNested({ each: true }) @Type(() => OrderProcessStepDto) orderProcessSteps?: OrderProcessStepDto[]; + + @IsOptional() + @ValidateNested() + @Type(() => EPaymentSettingsDto) + ePayment?: EPaymentSettingsDto; } class ModulesSettingsDto { diff --git a/src/cart/cart.module.ts b/src/cart/cart.module.ts index 004aab2..66b6dbb 100644 --- a/src/cart/cart.module.ts +++ b/src/cart/cart.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; import { OrdersModule } from '../orders/orders.module'; +import { PaymentsModule } from '../payments/payments.module'; import { CartController } from './cart.controller'; import { CartService } from './cart.service'; @Module({ - imports: [AuthModule, OrdersModule], + imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule], controllers: [CartController], providers: [CartService], }) diff --git a/src/cart/cart.service.ts b/src/cart/cart.service.ts index 9a104d1..9603fe9 100644 --- a/src/cart/cart.service.ts +++ b/src/cart/cart.service.ts @@ -15,6 +15,11 @@ import { UpdateCartItemDto, } from './dto/cart.dto'; 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 = { storeItem: { @@ -52,6 +57,8 @@ export class CartService { private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly orders: OrdersService, + private readonly payments: PaymentsService, + private readonly businessSettings: BusinessSettingsService, ) {} async getCart(businessIdRaw: string, actor: AuthUser) { @@ -193,6 +200,30 @@ export class CartService { 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({ businessId, userId: actor.id, @@ -207,13 +238,38 @@ export class CartService { payment: { type: dto.payment.type, posType: dto.payment.posType, - gatewayType: dto.payment.gatewayType, + gatewayType: isEPayment ? gatewayType : dto.payment.gatewayType, transferAccount: dto.payment.transferAccount, transferRefNumber: dto.payment.transferRefNumber, 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 } }); return { diff --git a/src/cart/dto/cart.dto.ts b/src/cart/dto/cart.dto.ts index 09c6650..6c1a989 100644 --- a/src/cart/dto/cart.dto.ts +++ b/src/cart/dto/cart.dto.ts @@ -2,6 +2,7 @@ import { IsInt, IsOptional, IsString, + IsUrl, MaxLength, Min, MinLength, @@ -67,6 +68,14 @@ export class CheckoutCartDto { @IsString() 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() @Type(() => CreateTransactionPaymentDto) payment!: CreateTransactionPaymentDto; diff --git a/src/comments/comments.service.ts b/src/comments/comments.service.ts index 7591139..4f65403 100644 --- a/src/comments/comments.service.ts +++ b/src/comments/comments.service.ts @@ -203,6 +203,38 @@ export class CommentsService { 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 }[]>` SELECT id FROM portfolios WHERE id = ${entityId} diff --git a/src/instructions/dto/instruction.dto.ts b/src/instructions/dto/instruction.dto.ts new file mode 100644 index 0000000..b7fd07b --- /dev/null +++ b/src/instructions/dto/instruction.dto.ts @@ -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; +} diff --git a/src/instructions/instructions.controller.ts b/src/instructions/instructions.controller.ts new file mode 100644 index 0000000..a3d2e1e --- /dev/null +++ b/src/instructions/instructions.controller.ts @@ -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); + } +} diff --git a/src/instructions/instructions.module.ts b/src/instructions/instructions.module.ts new file mode 100644 index 0000000..fdd1962 --- /dev/null +++ b/src/instructions/instructions.module.ts @@ -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 {} diff --git a/src/instructions/instructions.service.ts b/src/instructions/instructions.service.ts new file mode 100644 index 0000000..0a1db2f --- /dev/null +++ b/src/instructions/instructions.service.ts @@ -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 { + 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 { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + 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`, + ); + } + } +} diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 2893e63..293b593 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -897,6 +897,8 @@ export class OrdersService { amount: Number(transaction.amount), posType: transaction.posType, gatewayType: transaction.gatewayType, + gatewayRef: transaction.gatewayRef ?? null, + gatewayTrackId: transaction.gatewayTrackId ?? null, transferAccount: transaction.transferAccount, transferRefNumber: transaction.transferRefNumber, notes: transaction.notes, diff --git a/src/payments/gateways/mellat.gateway.ts b/src/payments/gateways/mellat.gateway.ts new file mode 100644 index 0000000..403370b --- /dev/null +++ b/src/payments/gateways/mellat.gateway.ts @@ -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, '''); +} + +function extractSoapReturn(xml: string): string { + const match = xml.match(/]*>([\s\S]*?)<\/return>/i); + return match ? match[1].trim() : ''; +} + +function readPayloadString( + payload: Record, + ...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 { + 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 { + 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, + ): Promise { + const inner = Object.entries(params) + .map(([key, value]) => `<${key}>${escapeXml(value)}`) + .join(''); + + const body = + `` + + `` + + `` + + `` + + `${inner}` + + `` + + ``; + + 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); + } +} diff --git a/src/payments/gateways/payment-gateway.types.ts b/src/payments/gateways/payment-gateway.types.ts new file mode 100644 index 0000000..8e12bc9 --- /dev/null +++ b/src/payments/gateways/payment-gateway.types.ts @@ -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; +}; + +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; +}; + +export type VerifyPaymentInput = { + businessId: bigint; + orderId: bigint; + transactionId: bigint; + amountIrt: number; + gatewayRef: string | null; + callbackPayload: Record; +}; + +export type VerifyPaymentResult = { + success: boolean; + trackId?: string; + resCode?: string; + message?: string; + meta?: Record; +}; + +export type GatewayCredentials = { + mellat: MellatGatewaySettings; +}; + +export interface PaymentGatewayAdapter { + readonly id: PaymentGatewayId; + + isConfigured(settings: EPaymentSettings): boolean; + + initiate( + credentials: GatewayCredentials, + input: InitiatePaymentInput, + ): Promise; + + verify( + credentials: GatewayCredentials, + input: VerifyPaymentInput, + ): Promise; +} diff --git a/src/payments/payment-gateway.registry.ts b/src/payments/payment-gateway.registry.ts new file mode 100644 index 0000000..485c2cf --- /dev/null +++ b/src/payments/payment-gateway.registry.ts @@ -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; + + 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()]; + } +} diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts new file mode 100644 index 0000000..a7a2da8 --- /dev/null +++ b/src/payments/payments.controller.ts @@ -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, + @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, + @Res() res: Response, + ) { + const result = await this.payments.handleGatewayCallback( + businessId, + gateway, + query ?? {}, + ); + return res.redirect(303, result.redirectUrl); + } +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts new file mode 100644 index 0000000..d0cf3f1 --- /dev/null +++ b/src/payments/payments.module.ts @@ -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 {} diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts new file mode 100644 index 0000000..a97c186 --- /dev/null +++ b/src/payments/payments.service.ts @@ -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; + verify?: Record; + [key: string]: unknown; +}; + +function isRecord(value: unknown): value is Record { + 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) { + 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, + ): 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, + ): 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('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') || + `http://localhost:${this.config.get('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, + ...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 ''; + } +} diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 3f2af85..07f5973 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; 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; @@ -62,6 +62,7 @@ export class TenantService { logoUrl: media?.logoMedia?.publicUrl ?? null, faviconUrl: media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null, + ePayment: listPublicPaymentGateways(settings.store.ePayment), }; } } diff --git a/src/videos/dto/video.dto.ts b/src/videos/dto/video.dto.ts new file mode 100644 index 0000000..68c49eb --- /dev/null +++ b/src/videos/dto/video.dto.ts @@ -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; +} diff --git a/src/videos/videos.controller.ts b/src/videos/videos.controller.ts new file mode 100644 index 0000000..c8c2328 --- /dev/null +++ b/src/videos/videos.controller.ts @@ -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); + } +} diff --git a/src/videos/videos.module.ts b/src/videos/videos.module.ts new file mode 100644 index 0000000..f2f605c --- /dev/null +++ b/src/videos/videos.module.ts @@ -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 {} diff --git a/src/videos/videos.service.ts b/src/videos/videos.service.ts new file mode 100644 index 0000000..20c052d --- /dev/null +++ b/src/videos/videos.service.ts @@ -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 { + 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 { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + 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`, + ); + } + } +} diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 05cb798..d264c22 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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) 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` + - 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) Public marketplace listings owned by customers — not catalog `products`. diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 583c737..b039dd9 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -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", "item": [ @@ -1526,7 +1633,7 @@ } }, { - "name": "Checkout cart (saved address)", + "name": "Checkout cart (e-payment / Mellat)", "event": [ { "listen": "test", @@ -1555,9 +1662,59 @@ ], "body": { "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." } } ] diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 58b3fbc..7942c1e 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -21,24 +21,64 @@ } ], "tags": [ - { "name": "Tenant" }, - { "name": "Homepage" }, - { "name": "Categories" }, - { "name": "Products" }, - { "name": "User Products" }, - { "name": "Store" }, - { "name": "Blogs" }, - { "name": "Portfolios" }, - { "name": "Comments" }, - { "name": "Expert Reviews" }, - { "name": "Contact" }, - { "name": "Auth" }, - { "name": "Addresses" }, - { "name": "Cities" }, - { "name": "Cart" }, - { "name": "Orders" }, - { "name": "Favorites" }, - { "name": "Partner SMS" } + { + "name": "Tenant" + }, + { + "name": "Homepage" + }, + { + "name": "Categories" + }, + { + "name": "Products" + }, + { + "name": "User Products" + }, + { + "name": "Store" + }, + { + "name": "Blogs" + }, + { + "name": "Portfolios" + }, + { + "name": "Comments" + }, + { + "name": "Expert Reviews" + }, + { + "name": "Contact" + }, + { + "name": "Auth" + }, + { + "name": "Addresses" + }, + { + "name": "Cities" + }, + { + "name": "Cart" + }, + { + "name": "Orders" + }, + { + "name": "Favorites" + }, + { + "name": "Partner SMS" + }, + { + "name": "Payments", + "description": "Online e-payment gateways (website checkout)" + } ], "components": { "securitySchemes": { @@ -60,23 +100,34 @@ "in": "path", "required": true, "description": "Website apex host only (e.g. sanihome.ir). No www/api/customer/business prefix.", - "schema": { "type": "string", "example": "example.com" } + "schema": { + "type": "string", + "example": "example.com" + } }, "businessId": { "name": "businessId", "in": "path", "required": true, "description": "From GET /tenants/{domain} → id", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } } }, "paths": { "/tenants/{domain}": { "get": { - "tags": ["Tenant"], + "tags": [ + "Tenant" + ], "summary": "Resolve website domain → business", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "responses": { "200": { "description": "Business branding", @@ -85,15 +136,40 @@ "schema": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "nameFa": { "type": "string" }, - "slug": { "type": "string" }, - "domain": { "type": "string" }, - "primaryColor": { "type": "string", "nullable": true }, - "defaultLocale": { "type": "string", "enum": ["en", "fa"] }, - "logoUrl": { "type": "string", "nullable": true }, - "faviconUrl": { "type": "string", "nullable": true } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nameFa": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "defaultLocale": { + "type": "string", + "enum": [ + "en", + "fa" + ] + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "faviconUrl": { + "type": "string", + "nullable": true + } } } } @@ -104,136 +180,357 @@ }, "/tenants/{domain}/website/business-info": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "About, contacts, addresses, social", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "Business public profile" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Business public profile" + } + } } }, "/tenants/{domain}/website/sliders": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage sliders + slides", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: Slider[] }" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: Slider[] }" + } + } } }, "/tenants/{domain}/website/category-groups": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage category groups", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" + } + } } }, "/tenants/{domain}/website/brand-groups": { "get": { - "tags": ["Homepage"], + "tags": [ + "Homepage" + ], "summary": "Homepage brand groups", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" + } + } } }, "/tenants/{domain}/store-specials": { "get": { - "tags": ["Homepage", "Store"], + "tags": [ + "Homepage", + "Store" + ], "summary": "Active store specials", - "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" } } + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" + } + } } }, "/tenants/{domain}/categories": { "get": { - "tags": ["Categories"], + "tags": [ + "Categories" + ], "summary": "Public categories", "parameters": [ - { "$ref": "#/components/parameters/domain" }, + { + "$ref": "#/components/parameters/domain" + }, { "name": "entityType", "in": "query", "schema": { "type": "string", - "enum": ["product", "blog", "portfolio"], + "enum": [ + "product", + "blog", + "portfolio", + "video" + ], "default": "product" } } ], - "responses": { "200": { "description": "{ items: Category[] }" } } + "responses": { + "200": { + "description": "{ items: Category[] }" + } + } } }, "/tenants/{domain}/products": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "List published products", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "name", "in": "query", "schema": { "type": "string" } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "brandId", "in": "query", "schema": { "type": "string" } }, - { "name": "tag", "in": "query", "schema": { "type": "string" } }, - { "name": "inStore", "in": "query", "schema": { "type": "boolean" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "name", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "brandId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "inStore", + "in": "query", + "schema": { + "type": "boolean" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/products/{slug}": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ product }" } } + "responses": { + "200": { + "description": "{ product }" + } + } } }, "/tenants/{domain}/products/{slug}/variations": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product variation options", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ variations }" } } + "responses": { + "200": { + "description": "{ variations }" + } + } } }, "/tenants/{domain}/products/{slug}/technical-info": { "get": { - "tags": ["Products"], + "tags": [ + "Products" + ], "summary": "Product technical specs", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ form, values }" } } + "responses": { + "200": { + "description": "{ form, values }" + } + } } }, "/tenants/{domain}/user-products": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "List published customer / stock listings", "description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } }, - { "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "cityId", "in": "query", "schema": { "type": "string" } }, - { "name": "countryId", "in": "query", "schema": { "type": "string" } }, + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "name", + "in": "query", + "description": "Search title/description (alias of q)", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "description": "Search title/description (alias of name)", + "schema": { + "type": "string" + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cityId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "countryId", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "condition", "in": "query", "schema": { "type": "string", - "enum": ["new", "stock", "needs_repair", "scrap"] + "enum": [ + "new", + "stock", + "needs_repair", + "scrap" + ] } }, - { "name": "promoted", "in": "query", "schema": { "type": "boolean" } } + { + "name": "promoted", + "in": "query", + "schema": { + "type": "boolean" + } + } ], "responses": { "200": { @@ -244,117 +541,331 @@ }, "/tenants/{domain}/user-products/{slug}": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "User product details by slug", "description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "responses": { "200": { "description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug" }, - "404": { "description": "Not found or not published" } + "404": { + "description": "Not found or not published" + } } } }, "/tenants/{domain}/user-products/{slug}/technical-info": { "get": { - "tags": ["User Products"], + "tags": [ + "User Products" + ], "summary": "User product technical form + values", "description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ form, values }" } } + "responses": { + "200": { + "description": "{ form, values }" + } + } } }, "/tenants/{domain}/store-items": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "List sellable variants", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "brandId", "in": "query", "schema": { "type": "string" } }, - { "name": "productId", "in": "query", "schema": { "type": "string" } }, - { "name": "name", "in": "query", "schema": { "type": "string" } }, - { "name": "inStock", "in": "query", "schema": { "type": "boolean" } }, - { "name": "isFestival", "in": "query", "schema": { "type": "boolean" } }, - { "name": "minPrice", "in": "query", "schema": { "type": "number" } }, - { "name": "maxPrice", "in": "query", "schema": { "type": "number" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "brandId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "productId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "inStock", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFestival", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "minPrice", + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "maxPrice", + "in": "query", + "schema": { + "type": "number" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/store-items/by-product/{productId}": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "Variants for one product", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ storeItem }" } } + "responses": { + "200": { + "description": "{ storeItem }" + } + } } }, "/tenants/{domain}/store-items/{variantId}": { "get": { - "tags": ["Store"], + "tags": [ + "Store" + ], "summary": "One variant", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "variantId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "variantId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ variant }" } } + "responses": { + "200": { + "description": "{ variant }" + } + } } }, "/tenants/{domain}/blogs": { "get": { - "tags": ["Blogs"], + "tags": [ + "Blogs" + ], "summary": "List published blogs", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["news", "article", "blog"] } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "title", "in": "query", "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "news", + "article", + "blog" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/blogs/{slug}": { "get": { - "tags": ["Blogs"], + "tags": [ + "Blogs" + ], "summary": "Blog by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ blog }" } } + "responses": { + "200": { + "description": "{ blog }" + } + } } }, "/tenants/{domain}/blogs/{blogId}/comments": { "get": { - "tags": ["Blogs", "Comments"], + "tags": [ + "Blogs", + "Comments" + ], "summary": "Approved blog comments", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Blogs", "Comments"], + "tags": [ + "Blogs", + "Comments" + ], "summary": "Submit blog comment", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "requestBody": { "required": true, @@ -362,60 +873,310 @@ "application/json": { "schema": { "type": "object", - "required": ["authorName", "text"], + "required": [ + "authorName", + "text" + ], "properties": { - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } + } + }, + "/tenants/{domain}/videos": { + "get": { + "tags": [ + "Videos" + ], + "summary": "List published videos", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "provider", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "youtube", + "aparat" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } + } + }, + "/tenants/{domain}/videos/{slug}": { + "get": { + "tags": [ + "Videos" + ], + "summary": "Video by slug", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ video }" + } + } + } + }, + "/tenants/{domain}/videos/{videoId}/comments": { + "get": { + "tags": [ + "Videos", + "Comments" + ], + "summary": "Approved video comments", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "videoId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Videos", + "Comments" + ], + "summary": "Submit video comment", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "videoId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "authorName", + "text" + ], + "properties": { + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/portfolios": { "get": { - "tags": ["Portfolios"], + "tags": [ + "Portfolios" + ], "summary": "List published portfolios", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, - { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, - { "name": "title", "in": "query", "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/tenants/{domain}/portfolios/{slug}": { "get": { - "tags": ["Portfolios"], + "tags": [ + "Portfolios" + ], "summary": "Portfolio by slug", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ portfolio }" } } + "responses": { + "200": { + "description": "{ portfolio }" + } + } } }, "/tenants/{domain}/portfolios/{portfolioId}/comments": { "get": { - "tags": ["Portfolios", "Comments"], + "tags": [ + "Portfolios", + "Comments" + ], "summary": "Approved portfolio comments", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Portfolios", "Comments"], + "tags": [ + "Portfolios", + "Comments" + ], "summary": "Submit portfolio comment", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], "requestBody": { "required": true, @@ -423,126 +1184,273 @@ "application/json": { "schema": { "type": "object", - "required": ["authorName", "text"], + "required": [ + "authorName", + "text" + ], "properties": { - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/comments": { "get": { - "tags": ["Comments"], + "tags": [ + "Comments" + ], "summary": "List approved comments for any entity", "parameters": [ - { "$ref": "#/components/parameters/domain" }, + { + "$ref": "#/components/parameters/domain" + }, { "name": "entityType", "in": "query", "required": true, - "schema": { "type": "string", "enum": ["product", "blog", "portfolio"] } + "schema": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ] + } }, - { "name": "entityId", "in": "query", "required": true, "schema": { "type": "string" } } + { + "name": "entityId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Comments"], + "tags": [ + "Comments" + ], "summary": "Submit comment (product/blog/portfolio)", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["entityType", "entityId", "authorName", "text"], + "required": [ + "entityType", + "entityId", + "authorName", + "text" + ], "properties": { - "entityType": { "type": "string", "enum": ["product", "blog", "portfolio"] }, - "entityId": { "type": "string" }, - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "text": { "type": "string" } + "entityType": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ] + }, + "entityId": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ comment, message }" } } + "responses": { + "201": { + "description": "{ comment, message }" + } + } } }, "/tenants/{domain}/expert-reviews": { "get": { - "tags": ["Expert Reviews"], + "tags": [ + "Expert Reviews" + ], "summary": "Approved expert reviews for a product", "parameters": [ - { "$ref": "#/components/parameters/domain" }, - { "name": "productId", "in": "query", "required": true, "schema": { "type": "string" } } + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } ], - "responses": { "200": { "description": "{ items }" } } + "responses": { + "200": { + "description": "{ items }" + } + } }, "post": { - "tags": ["Expert Reviews"], + "tags": [ + "Expert Reviews" + ], "summary": "Submit expert review", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["productId", "authorName", "rate", "positivePoints", "negativePoints", "text"], + "required": [ + "productId", + "authorName", + "rate", + "positivePoints", + "negativePoints", + "text" + ], "properties": { - "productId": { "type": "string" }, - "authorName": { "type": "string" }, - "authorEmail": { "type": "string" }, - "rate": { "type": "integer", "minimum": 1, "maximum": 10 }, - "positivePoints": { "type": "array", "items": { "type": "string" } }, - "negativePoints": { "type": "array", "items": { "type": "string" } }, - "text": { "type": "string" } + "productId": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "rate": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "positivePoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "negativePoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ review, message }" } } + "responses": { + "201": { + "description": "{ review, message }" + } + } } }, "/tenants/{domain}/contact-submissions": { "post": { - "tags": ["Contact"], + "tags": [ + "Contact" + ], "summary": "Contact form", - "parameters": [{ "$ref": "#/components/parameters/domain" }], + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["title", "name", "text"], + "required": [ + "title", + "name", + "text" + ], "properties": { - "title": { "type": "string" }, - "name": { "type": "string" }, - "email": { "type": "string" }, - "cellNumber": { "type": "string" }, - "text": { "type": "string" } + "title": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "cellNumber": { + "type": "string" + }, + "text": { + "type": "string" + } } } } } }, - "responses": { "201": { "description": "{ submission, message }" } } + "responses": { + "201": { + "description": "{ submission, message }" + } + } } }, "/auth/register": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Register customer on a website", "requestBody": { "required": true, @@ -550,14 +1458,35 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "password", "firstName", "lastName", "domain"], + "required": [ + "cellNumber", + "password", + "firstName", + "lastName", + "domain" + ], "properties": { - "cellNumber": { "type": "string", "description": "E.164 e.g. +98912..." }, - "password": { "type": "string", "minLength": 8 }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "email": { "type": "string" }, - "domain": { "type": "string", "description": "Same website apex as {domain}" }, + "cellNumber": { + "type": "string", + "description": "E.164 e.g. +98912..." + }, + "password": { + "type": "string", + "minLength": 8 + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "domain": { + "type": "string", + "description": "Same website apex as {domain}" + }, "acknowledgeExistingAccount": { "type": "boolean", "description": "If true, link an existing Meshkee account from another website without matching its password. Existing password and profile stay unchanged." @@ -567,12 +1496,18 @@ } } }, - "responses": { "201": { "description": "{ user, accessToken, refreshToken, registeredBusiness }" } } + "responses": { + "201": { + "description": "{ user, accessToken, refreshToken, registeredBusiness }" + } + } } }, "/auth/login": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Login", "requestBody": { "required": true, @@ -580,21 +1515,34 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "password"], + "required": [ + "cellNumber", + "password" + ], "properties": { - "cellNumber": { "type": "string" }, - "password": { "type": "string" } + "cellNumber": { + "type": "string" + }, + "password": { + "type": "string" + } } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/login-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Passwordless login with SMS OTP", "requestBody": { "required": true, @@ -602,21 +1550,36 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code"], + "required": [ + "cellNumber", + "code" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 } + "cellNumber": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 + } } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/reset-password": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Reset password with SMS OTP", "requestBody": { "required": true, @@ -624,22 +1587,41 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code", "newPassword"], + "required": [ + "cellNumber", + "code", + "newPassword" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 }, - "newPassword": { "type": "string", "minLength": 8 } + "cellNumber": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 + }, + "newPassword": { + "type": "string", + "minLength": 8 + } } } } } }, - "responses": { "200": { "description": "{ message }" } } + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/auth/refresh": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Refresh tokens", "requestBody": { "required": true, @@ -647,57 +1629,107 @@ "application/json": { "schema": { "type": "object", - "required": ["refreshToken"], - "properties": { "refreshToken": { "type": "string" } } + "required": [ + "refreshToken" + ], + "properties": { + "refreshToken": { + "type": "string" + } + } } } } }, - "responses": { "200": { "description": "{ user, accessToken, refreshToken }" } } + "responses": { + "200": { + "description": "{ user, accessToken, refreshToken }" + } + } } }, "/auth/me": { "get": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Current user", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ user }" } } + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ user }" + } + } } }, "/auth/profile": { "patch": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Update profile", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ message, user }" } } + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ message, user }" + } + } } }, "/auth/change-password": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Change password", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["currentPassword", "newPassword"], + "required": [ + "currentPassword", + "newPassword" + ], "properties": { - "currentPassword": { "type": "string" }, - "newPassword": { "type": "string", "minLength": 8 } + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string", + "minLength": 8 + } } } } } }, - "responses": { "200": { "description": "{ message }" } } + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/auth/send-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Send OTP SMS", "requestBody": { "required": true, @@ -705,9 +1737,13 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber"], + "required": [ + "cellNumber" + ], "properties": { - "cellNumber": { "type": "string" }, + "cellNumber": { + "type": "string" + }, "domain": { "type": "string", "description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name" @@ -717,12 +1753,18 @@ } } }, - "responses": { "200": { "description": "{ enabled, message, expiresInSeconds? }" } } + "responses": { + "200": { + "description": "{ enabled, message, expiresInSeconds? }" + } + } } }, "/auth/verify-otp": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "summary": "Verify OTP", "requestBody": { "required": true, @@ -730,310 +1772,711 @@ "application/json": { "schema": { "type": "object", - "required": ["cellNumber", "code"], + "required": [ + "cellNumber", + "code" + ], "properties": { - "cellNumber": { "type": "string" }, - "code": { "type": "string", "minLength": 6, "maxLength": 6 } - } - } - } - } - }, - "responses": { "200": { "description": "{ enabled, verified, message }" } } - } - }, - "/auth/addresses": { - "get": { - "tags": ["Addresses"], - "summary": "List my shipping addresses", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "{ items }" } } - }, - "post": { - "tags": ["Addresses"], - "summary": "Create address", - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["province", "city", "address"], - "properties": { - "label": { "type": "string" }, - "province": { "type": "string" }, - "city": { "type": "string" }, - "address": { "type": "string" }, - "postalCode": { "type": "string" }, - "landline": { "type": "string" } - } - } - } - } - }, - "responses": { "201": { "description": "{ address }" } } - } - }, - "/auth/addresses/{addressId}": { - "patch": { - "tags": ["Addresses"], - "summary": "Update address", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ address }" } } - }, - "delete": { - "tags": ["Addresses"], - "summary": "Delete address", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ message }" } } - } - }, - "/cities": { - "get": { - "tags": ["Cities"], - "summary": "Location tree (countries / provinces / cities)", - "parameters": [ - { - "name": "level", - "in": "query", - "schema": { "type": "string", "enum": ["country", "province", "city"] } - }, - { "name": "parentId", "in": "query", "schema": { "type": "string" } }, - { "name": "parentSlug", "in": "query", "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ items }" } } - } - }, - "/cities/{cityId}": { - "get": { - "tags": ["Cities"], - "summary": "Get one location node", - "parameters": [ - { "name": "cityId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ city }" } } - } - }, - "/businesses/{businessId}/cart": { - "get": { - "tags": ["Cart"], - "summary": "Get cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "responses": { "200": { "description": "{ cart }" } } - }, - "delete": { - "tags": ["Cart"], - "summary": "Clear cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "responses": { "200": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/items": { - "post": { - "tags": ["Cart"], - "summary": "Add variant to cart", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["storeItemVariantId"], - "properties": { - "storeItemVariantId": { "type": "string" }, - "quantity": { "type": "integer", "minimum": 1, "default": 1 } - } - } - } - } - }, - "responses": { "201": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/items/{itemId}": { - "patch": { - "tags": ["Cart"], - "summary": "Update cart line quantity", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["quantity"], - "properties": { "quantity": { "type": "integer", "minimum": 1 } } - } - } - } - }, - "responses": { "200": { "description": "{ message, cart }" } } - }, - "delete": { - "tags": ["Cart"], - "summary": "Remove cart line", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "{ message, cart }" } } - } - }, - "/businesses/{businessId}/cart/checkout": { - "post": { - "tags": ["Cart"], - "summary": "Checkout → create order", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["payment"], - "properties": { - "addressId": { "type": "string" }, - "shippingAddress": { - "type": "object", - "properties": { - "province": { "type": "string" }, - "city": { "type": "string" }, - "address": { "type": "string" }, - "postalCode": { "type": "string" }, - "landline": { "type": "string" } - } + "cellNumber": { + "type": "string" }, - "customerNotes": { "type": "string" }, - "payment": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": ["pos", "cash", "transfer", "e_payment_gate"] - }, - "posType": { "type": "string" }, - "transferAccount": { "type": "string" }, - "transferRefNumber": { "type": "string" }, - "gatewayType": { "type": "string" }, - "notes": { "type": "string" } - } + "code": { + "type": "string", + "minLength": 6, + "maxLength": 6 } } } } } }, - "responses": { "201": { "description": "{ message, order }" } } + "responses": { + "200": { + "description": "{ enabled, verified, message }" + } + } + } + }, + "/auth/addresses": { + "get": { + "tags": [ + "Addresses" + ], + "summary": "List my shipping addresses", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Addresses" + ], + "summary": "Create address", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "province", + "city", + "address" + ], + "properties": { + "label": { + "type": "string" + }, + "province": { + "type": "string" + }, + "city": { + "type": "string" + }, + "address": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "landline": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ address }" + } + } + } + }, + "/auth/addresses/{addressId}": { + "patch": { + "tags": [ + "Addresses" + ], + "summary": "Update address", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "addressId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ address }" + } + } + }, + "delete": { + "tags": [ + "Addresses" + ], + "summary": "Delete address", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "addressId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message }" + } + } + } + }, + "/cities": { + "get": { + "tags": [ + "Cities" + ], + "summary": "Location tree (countries / provinces / cities)", + "parameters": [ + { + "name": "level", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "country", + "province", + "city" + ] + } + }, + { + "name": "parentId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parentSlug", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + } + }, + "/cities/{cityId}": { + "get": { + "tags": [ + "Cities" + ], + "summary": "Get one location node", + "parameters": [ + { + "name": "cityId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ city }" + } + } + } + }, + "/businesses/{businessId}/cart": { + "get": { + "tags": [ + "Cart" + ], + "summary": "Get cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ cart }" + } + } + }, + "delete": { + "tags": [ + "Cart" + ], + "summary": "Clear cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/items": { + "post": { + "tags": [ + "Cart" + ], + "summary": "Add variant to cart", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "storeItemVariantId" + ], + "properties": { + "storeItemVariantId": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "default": 1 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/items/{itemId}": { + "patch": { + "tags": [ + "Cart" + ], + "summary": "Update cart line quantity", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "{ message, cart }" + } + } + }, + "delete": { + "tags": [ + "Cart" + ], + "summary": "Remove cart line", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message, cart }" + } + } + } + }, + "/businesses/{businessId}/cart/checkout": { + "post": { + "tags": [ + "Cart" + ], + "summary": "Checkout → create order", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "payment" + ], + "properties": { + "addressId": { + "type": "string" + }, + "shippingAddress": { + "type": "object", + "properties": { + "province": { + "type": "string" + }, + "city": { + "type": "string" + }, + "address": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "landline": { + "type": "string" + } + } + }, + "customerNotes": { + "type": "string" + }, + "payment": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pos", + "cash", + "transfer", + "e_payment_gate" + ] + }, + "posType": { + "type": "string" + }, + "transferAccount": { + "type": "string" + }, + "transferRefNumber": { + "type": "string" + }, + "gatewayType": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ], + "description": "Required when type is e_payment_gate (defaults to store defaultGateway if omitted)." + }, + "notes": { + "type": "string" + } + } + }, + "returnUrl": { + "type": "string", + "format": "uri", + "description": "Required for e_payment_gate. Absolute URL the bank callback redirects the shopper to (status, orderId, transactionId query params appended)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Cash/transfer/pos: { message, order }. E-payment: { message, order, payment: { gatewayType, transactionId, redirect: { method, url, fields } } }" + } + } } }, "/businesses/{businessId}/orders": { "get": { - "tags": ["Orders"], + "tags": [ + "Orders" + ], "summary": "My orders", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + }, { "name": "status", "in": "query", "schema": { "type": "string", - "enum": ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"] + "enum": [ + "pending", + "confirmed", + "processing", + "shipped", + "delivered", + "cancelled" + ] } } ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } } }, "/businesses/{businessId}/orders/{orderId}": { "get": { - "tags": ["Orders"], - "summary": "My order detail", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } } + "tags": [ + "Orders" ], - "responses": { "200": { "description": "{ order }" } } + "summary": "My order detail", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ order }" + } + } } }, "/businesses/{businessId}/favorites": { "get": { - "tags": ["Favorites"], - "summary": "List favorites", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "page", "in": "query", "schema": { "type": "integer" } }, - { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } } + "tags": [ + "Favorites" ], - "responses": { "200": { "description": "{ items, total, page, pageSize }" } } + "summary": "List favorites", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } }, "post": { - "tags": ["Favorites"], + "tags": [ + "Favorites" + ], "summary": "Add favorite", - "security": [{ "bearerAuth": [] }], - "parameters": [{ "$ref": "#/components/parameters/businessId" }], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["productId"], - "properties": { "productId": { "type": "string" } } + "required": [ + "productId" + ], + "properties": { + "productId": { + "type": "string" + } + } } } } }, - "responses": { "201": { "description": "{ favorite, message }" } } + "responses": { + "201": { + "description": "{ favorite, message }" + } + } } }, "/businesses/{businessId}/favorites/{productId}": { "delete": { - "tags": ["Favorites"], - "summary": "Remove favorite", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "$ref": "#/components/parameters/businessId" }, - { "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } } + "tags": [ + "Favorites" ], - "responses": { "200": { "description": "{ message }" } } + "summary": "Remove favorite", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ message }" + } + } } }, "/public/sms/send": { "post": { - "tags": ["Partner SMS"], + "tags": [ + "Partner SMS" + ], "summary": "Send SMS via Meshkee (partner gateway)", "description": "Server-to-server only. For external/partner backends (e.g. Balout) that need to send SMS through Meshkee → Gama. Not for browser/storefront JS. Requires an allowlisted `domain` + matching `X-Api-Key`. See /docs/website/SMS.md.", - "security": [{ "apiKeyAuth": [] }], + "security": [ + { + "apiKeyAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["domain", "to", "message"], + "required": [ + "domain", + "to", + "message" + ], "properties": { "domain": { "type": "string", @@ -1063,17 +2506,288 @@ "schema": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, - "serverId": { "type": "string", "example": "1136923081051406337" } + "success": { + "type": "boolean", + "example": true + }, + "serverId": { + "type": "string", + "example": "1136923081051406337" + } } } } } }, - "400": { "description": "Invalid phone or message" }, - "401": { "description": "Missing/invalid X-Api-Key or domain" }, - "429": { "description": "Rate limited (30/partner/min or 5/destination/min)" }, - "503": { "description": "SMS disabled or provider unreachable" } + "400": { + "description": "Invalid phone or message" + }, + "401": { + "description": "Missing/invalid X-Api-Key or domain" + }, + "429": { + "description": "Rate limited (30/partner/min or 5/destination/min)" + }, + "503": { + "description": "SMS disabled or provider unreachable" + } + } + } + }, + "/tenants/{domain}/instructions": { + "get": { + "tags": [ + "Instructions" + ], + "summary": "List published instructions", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "default": 12 + } + }, + { + "name": "provider", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "youtube", + "aparat" + ] + } + }, + { + "name": "categoryId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items, total, page, pageSize }" + } + } + } + }, + "/tenants/{domain}/instructions/{slug}": { + "get": { + "tags": [ + "Instructions" + ], + "summary": "Instruction by slug", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ instruction }" + } + } + } + }, + "/tenants/{domain}/instructions/{instructionId}/comments": { + "get": { + "tags": [ + "Instructions", + "Comments" + ], + "summary": "Approved instruction comments", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "instructionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ items }" + } + } + }, + "post": { + "tags": [ + "Instructions", + "Comments" + ], + "summary": "Submit instruction comment", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "instructionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "authorName", + "text" + ], + "properties": { + "authorName": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "{ comment, message }" + } + } + } + }, + "/businesses/{businessId}/payments/methods": { + "get": { + "tags": [ + "Payments" + ], + "summary": "List enabled online payment gateways (public)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + } + ], + "responses": { + "200": { + "description": "{ enabled, defaultGateway, gateways: [{ id, label, labelFa }] } — no secrets" + } + } + } + }, + "/businesses/{businessId}/payments/{gateway}/callback": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Bank payment callback (public; redirects browser to returnUrl)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "gateway", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Gateway-specific fields (Mellat: ResCode, SaleOrderId, SaleReferenceId, RefId, …)" + } + } + } + }, + "responses": { + "303": { + "description": "Redirect to checkout returnUrl with status=success|failed" + } + } + }, + "get": { + "tags": [ + "Payments" + ], + "summary": "Bank payment callback (GET variant)", + "parameters": [ + { + "$ref": "#/components/parameters/businessId" + }, + { + "name": "gateway", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "mellat", + "sep", + "snappay", + "digipay", + "zarinpal" + ] + } + } + ], + "responses": { + "303": { + "description": "Redirect to checkout returnUrl with status=success|failed" + } } } }