Add Mellat e-payment, videos/instructions CMS, and legacy domain lookup.

Store gateway credentials per business, initiate/verify Mellat at checkout, and resolve old CMS ids from domains in the migrate modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-13 09:51:17 +03:30
co-authored by Cursor
parent 513673a7da
commit d5e502cf77
44 changed files with 9303 additions and 996 deletions
+3
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
-- Videos module (embedded YouTube / Aparat)
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'video';
CREATE TYPE video_provider AS ENUM ('youtube', 'aparat');
CREATE TABLE videos (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
author_id BIGINT,
title VARCHAR(255) NOT NULL,
title_fa VARCHAR(255),
title_en VARCHAR(255),
slug VARCHAR(255) NOT NULL,
excerpt TEXT,
content JSONB NOT NULL DEFAULT '{}',
video_provider video_provider NOT NULL,
embed_code TEXT NOT NULL,
status content_status NOT NULL DEFAULT 'draft',
featured_media_id BIGINT,
published_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT videos_business_slug_unique UNIQUE (business_id, slug),
CONSTRAINT videos_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT videos_author_id_fkey
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT videos_featured_media_id_fkey
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL
);
CREATE INDEX idx_videos_business_id ON videos (business_id);
CREATE INDEX idx_videos_business_status ON videos (business_id, status);
CREATE INDEX idx_videos_business_published ON videos (business_id, published_at DESC);
CREATE INDEX idx_videos_author_id ON videos (author_id);
CREATE TRIGGER videos_set_updated_at
BEFORE UPDATE ON videos
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- Permissions
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View videos', 'videos.read', 'videos', 'View videos'),
('Create videos', 'videos.create', 'videos', 'Create videos'),
('Update videos', 'videos.update', 'videos', 'Edit videos'),
('Delete videos', 'videos.delete', 'videos', 'Delete videos'),
('Publish videos', 'videos.publish', 'videos', 'Publish and unpublish videos')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'videos.%'
WHERE r.slug IN ('business_owner', 'owner', 'admin')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('videos.read', 'videos.create', 'videos.update', 'videos.publish')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'videos.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
ALTER TABLE videos ADD COLUMN IF NOT EXISTS old_id BIGINT;
CREATE UNIQUE INDEX IF NOT EXISTS videos_business_old_id_unique
ON videos (business_id, old_id)
WHERE old_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos (old_id);
+72
View File
@@ -0,0 +1,72 @@
-- Instructions module (rich text + optional embedded video)
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'instruction';
CREATE TABLE instructions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
author_id BIGINT,
title VARCHAR(255) NOT NULL,
title_fa VARCHAR(255),
title_en VARCHAR(255),
slug VARCHAR(255) NOT NULL,
excerpt TEXT,
content JSONB NOT NULL DEFAULT '{}',
video_provider video_provider,
embed_code TEXT,
status content_status NOT NULL DEFAULT 'draft',
featured_media_id BIGINT,
published_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
old_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT instructions_business_slug_unique UNIQUE (business_id, slug),
CONSTRAINT instructions_business_old_id_unique UNIQUE (business_id, old_id),
CONSTRAINT instructions_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT instructions_author_id_fkey
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT instructions_featured_media_id_fkey
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL
);
CREATE INDEX idx_instructions_business_id ON instructions (business_id);
CREATE INDEX idx_instructions_business_status ON instructions (business_id, status);
CREATE INDEX idx_instructions_business_published ON instructions (business_id, published_at DESC);
CREATE INDEX idx_instructions_author_id ON instructions (author_id);
CREATE TRIGGER instructions_set_updated_at
BEFORE UPDATE ON instructions
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View instructions', 'instructions.read', 'instructions', 'View instructions'),
('Create instructions', 'instructions.create', 'instructions', 'Create instructions'),
('Update instructions', 'instructions.update', 'instructions', 'Edit instructions'),
('Delete instructions', 'instructions.delete', 'instructions', 'Delete instructions'),
('Publish instructions', 'instructions.publish', 'instructions', 'Publish and unpublish instructions')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'instructions.%'
WHERE r.slug IN ('business_owner', 'owner', 'admin')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('instructions.read', 'instructions.create', 'instructions.update', 'instructions.publish')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'instructions.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,14 @@
-- Meshkee CMS — e-payment gateway tracking on transactions
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS gateway_ref VARCHAR(255),
ADD COLUMN IF NOT EXISTS gateway_track_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS gateway_meta JSONB;
CREATE INDEX IF NOT EXISTS idx_transactions_gateway_ref
ON transactions (business_id, gateway_ref)
WHERE gateway_ref IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_transactions_gateway_track
ON transactions (business_id, gateway_track_id)
WHERE gateway_track_id IS NOT NULL;
+12 -2
View File
@@ -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
+4
View File
@@ -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`.
@@ -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."
}
}
]
File diff suppressed because it is too large Load Diff
+80
View File
@@ -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
+6
View File
@@ -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,
@@ -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) {
@@ -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);
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsPositive } from 'class-validator';
export class LegacyMigrateLookupDto {
/** When set, look up domains/brand for this old CMS business id. */
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
oldBusinessId?: number;
}
@@ -8,6 +8,8 @@ export const MIGRATE_FROM_OLD_ENTITIES = [
'customer',
'blog_categories',
'blog',
'video_categories',
'video',
'portfolio_categories',
'portfolio',
] as const;
+524 -1
View File
@@ -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<string>();
for (const raw of hosts) {
const host = raw.trim().toLowerCase().replace(/\.$/, '');
if (!host) continue;
const apex = host.startsWith('www.') ? host.slice(4) : host;
if (!apex) continue;
out.add(apex);
out.add(`www.${apex}`);
}
return [...out];
}
private async fetchLegacyBusinessDetail(oldBusinessId: number): Promise<{
oldBusinessId: number;
domains: string[];
brandEn: string | null;
brandFa: string | null;
slug: string | null;
} | null> {
const businesses = await this.legacyMysql.query<
(RowDataPacket & {
id: number;
brand_en: string | null;
brand_fa: string | null;
slug: string | null;
})[]
>(
`SELECT id, brand_en, brand_fa, slug
FROM businesses
WHERE id = ? AND deleted_at IS NULL
LIMIT 1`,
[oldBusinessId],
);
if (!businesses.length) {
return null;
}
const domains = await this.legacyMysql.query<
(RowDataPacket & { domain: string })[]
>(
`SELECT domain
FROM domains
WHERE business_id = ? AND deleted_at IS NULL
ORDER BY id ASC`,
[oldBusinessId],
);
const biz = businesses[0];
return {
oldBusinessId: Number(biz.id),
domains: domains.map((d) => String(d.domain)),
brandEn: biz.brand_en ? String(biz.brand_en) : null,
brandFa: biz.brand_fa ? String(biz.brand_fa) : null,
slug: biz.slug ? String(biz.slug) : null,
};
}
async migrateEntities(
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<MigrateEntityCounts> {
const slugSelect = opts.hasSlug ? 'slug' : 'NULL AS slug';
const nameEnSelect = await this.legacySelectCol(opts.table, 'name_en');
const rows = await this.legacyMysql.query<OldPortfolioCategoryRow[]>(
`SELECT id, name, name_en, parent_id, ${slugSelect}, \`_lft\` AS _lft
`SELECT id, name, ${nameEnSelect}, parent_id, ${slugSelect}, \`_lft\` AS _lft
FROM ${opts.table}
WHERE business_id = ?
ORDER BY \`_lft\` ASC`,
@@ -800,6 +1004,280 @@ export class LegacyMigrateService {
};
}
private async migrateVideos(
businessId: bigint,
oldBusinessId: bigint,
): Promise<MigrateEntityCounts> {
const oldBizId = Number(oldBusinessId);
const bizRows = await this.legacyMysql.query<RowDataPacket[]>(
`SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`,
[oldBizId],
);
const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null;
const videoTable = 'videos';
const videoDeleted = await this.legacySoftDeleteClause(videoTable);
const videoSelect = [
'id',
await this.legacySelectCol(videoTable, 'title'),
await this.legacySelectCol(videoTable, 'name_en'),
await this.legacySelectCol(videoTable, 'url_title'),
await this.legacySelectCol(videoTable, 'abstract'),
await this.legacySelectCol(videoTable, 'summary'),
await this.legacySelectCol(videoTable, 'content'),
await this.legacySelectCol(videoTable, 'description'),
await this.legacySelectCol(videoTable, 'embed'),
await this.legacySelectCol(videoTable, 'verified'),
await this.legacySelectCol(videoTable, 'publish_at'),
await this.legacySelectCol(videoTable, 'video_category_id'),
await this.legacySelectCol(videoTable, 'user_id'),
await this.legacySelectCol(videoTable, 'created_at'),
await this.legacySelectCol(videoTable, 'updated_at'),
].join(', ');
const videos = await this.legacyMysql.query<OldVideoRow[]>(
`SELECT ${videoSelect}
FROM ${videoTable}
WHERE business_id = ?${videoDeleted}
ORDER BY id ASC`,
[oldBizId],
);
const mediaRows = await this.legacyMysql.query<OldMediaRow[]>(
`SELECT id, model_id, collection_name, file_name, mime_type, size, order_column
FROM media
WHERE model_type = 'video'
AND model_id IN (
SELECT id FROM videos WHERE business_id = ?${videoDeleted}
)
ORDER BY model_id ASC, order_column ASC, id ASC`,
[oldBizId],
);
const pivots = await this.legacyMysql.query<
(RowDataPacket & {
video_id: number;
video_category_id: number;
})[]
>(
`SELECT video_id, video_category_id
FROM video_video_category
WHERE video_id IN (
SELECT id FROM videos WHERE business_id = ?${videoDeleted}
)`,
[oldBizId],
).catch(() => [] as (RowDataPacket & {
video_id: number;
video_category_id: number;
})[]);
const mediaByVideo = new Map<number, OldMediaRow[]>();
for (const row of mediaRows) {
const list = mediaByVideo.get(row.model_id) ?? [];
list.push(row);
mediaByVideo.set(row.model_id, list);
}
const categoriesByVideo = new Map<number, number[]>();
for (const row of pivots) {
const list = categoriesByVideo.get(row.video_id) ?? [];
list.push(row.video_category_id);
categoriesByVideo.set(row.video_id, list);
}
const categoryMap = await this.loadCategoryOldIdMap(
businessId,
MediaEntityType.video,
);
const existingVideos = await this.prisma.videos.findMany({
where: { business_id: businessId, old_id: { not: null } },
select: { id: true, old_id: true, author_id: true },
});
const existingByOldId = new Map(
existingVideos
.filter((v) => v.old_id != null)
.map((v) => [
Number(v.old_id),
{ id: v.id, authorId: v.author_id },
]),
);
const existingMedia = await this.prisma.media.findMany({
where: { businessId, oldId: { not: null } },
select: { id: true, oldId: true },
});
const mediaOldToNew = new Map<number, bigint>();
for (const row of existingMedia) {
if (row.oldId != null) {
mediaOldToNew.set(Number(row.oldId), row.id);
}
}
const probeMedia = mediaRows
.filter((m) => Number(m.size) > 0)
.slice(0, 12)
.map((m) => ({ id: m.id, fileName: m.file_name }));
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
oldBizId,
oldSlug,
probeMedia,
);
let created = 0;
let skipped = 0;
let imagesCopied = 0;
let imagesResized = 0;
let imagesFailed = 0;
const authorCache = new Map<number, bigint | null>();
for (const row of videos) {
const embedCode = (row.embed ?? '').trim();
if (!embedCode) {
skipped += 1;
continue;
}
const authorId = await this.resolveLegacyAuthorId(row.user_id, authorCache);
const existing = existingByOldId.get(row.id);
if (existing) {
skipped += 1;
if (existing.authorId == null && authorId != null) {
await this.prisma.videos.update({
where: { id: existing.id },
data: { author_id: authorId },
});
}
continue;
}
const titleFa = (row.title ?? '').trim() || row.name_en?.trim() || 'Video';
const titleEn = row.name_en?.trim() || null;
const baseSlug =
this.normalizeSlug((row.url_title ?? '').trim()) ||
slugify(titleEn || titleFa);
const slug = await this.ensureUniqueVideoSlug(businessId, baseSlug, row.id);
const videoMedia = mediaByVideo.get(row.id) ?? [];
let featuredMediaId: bigint | null = null;
for (const media of videoMedia) {
const size = Number(media.size) || 0;
if (size <= 0) {
imagesFailed += 1;
continue;
}
let newMediaId = mediaOldToNew.get(media.id) ?? null;
if (!newMediaId) {
try {
const copied = await this.copyLegacyMedia({
businessId,
oldMedia: media,
storagePrefix,
});
newMediaId = copied.id;
mediaOldToNew.set(media.id, newMediaId);
imagesCopied += 1;
if (copied.resized) {
imagesResized += 1;
}
} catch {
imagesFailed += 1;
continue;
}
}
if (
(media.collection_name === 'main_image' ||
media.collection_name === 'cover') &&
!featuredMediaId
) {
featuredMediaId = newMediaId;
}
}
const createdAt = row.created_at ? new Date(row.created_at) : new Date();
const publishedAt = row.publish_at
? new Date(row.publish_at)
: createdAt;
const isVerified = isTruthyFlag(row.verified);
const abstract = row.abstract?.trim() || row.summary?.trim() || null;
const descriptionHtml = row.content ?? row.description ?? '';
const video = await this.prisma.videos.create({
data: {
business_id: businessId,
author_id: authorId,
title: titleFa,
title_fa: titleFa,
title_en: titleEn,
slug,
excerpt: abstract,
content: {
html: descriptionHtml,
} as Prisma.InputJsonValue,
video_provider: this.resolveVideoProviderFromEmbed(embedCode),
embed_code: embedCode,
status: isVerified ? ContentStatus.published : ContentStatus.draft,
featured_media_id: featuredMediaId,
published_at: isVerified ? publishedAt : null,
metadata: {
migratedFromOldId: row.id,
legacyTable: 'videos',
verified: isVerified,
} as Prisma.InputJsonValue,
old_id: BigInt(row.id),
created_at: createdAt,
updated_at: row.updated_at ? new Date(row.updated_at) : createdAt,
},
});
const oldCategoryIds = new Set<number>(
categoriesByVideo.get(row.id) ?? [],
);
if (row.video_category_id != null) {
oldCategoryIds.add(row.video_category_id);
}
for (const oldCategoryId of oldCategoryIds) {
const newCategoryId = categoryMap.get(oldCategoryId);
if (!newCategoryId) continue;
try {
await this.prisma.categoryAssignment.create({
data: {
businessId,
categoryId: newCategoryId,
entityType: MediaEntityType.video,
entityId: video.id,
},
});
} catch {
// duplicate assignment on re-run
}
}
created += 1;
}
return {
created,
skipped,
total: videos.length,
imagesCopied,
imagesResized,
imagesFailed,
};
}
private resolveVideoProviderFromEmbed(embedCode: string): VideoProvider {
const normalized = embedCode.toLowerCase();
if (normalized.includes('aparat.com') || normalized.includes('aparat.ir')) {
return VideoProvider.aparat;
}
return VideoProvider.youtube;
}
private async migrateArticles(
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<string> {
let slug = baseSlug || `video-old-${oldId}`;
const clash = await this.prisma.videos.findFirst({
where: { business_id: businessId, slug },
select: { id: true },
});
if (!clash) return slug;
slug = `${baseSlug}-old-${oldId}`.replace(/-+/g, '-');
const stillClash = await this.prisma.videos.findFirst({
where: { business_id: businessId, slug },
select: { id: true },
});
if (!stillClash) return slug;
return `${slug}-${Date.now()}`;
}
private async migrateProducts(
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<string> {
if (await this.legacyColumnExists(tableName, columnName)) {
return columnName;
}
return `NULL AS ${alias}`;
}
/** Soft-delete filter when the legacy table has `deleted_at`. */
private async legacySoftDeleteClause(
tableName: string,
tableAlias?: string,
): Promise<string> {
if (!(await this.legacyColumnExists(tableName, 'deleted_at'))) {
return '';
}
const prefix = tableAlias ? `${tableAlias}.` : '';
return ` AND ${prefix}deleted_at IS NULL`;
}
private async migrateProductCategories(
businessId: bigint,
oldBusinessId: bigint,
@@ -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<PurgeEntityCounts> {
const videos = await this.prisma.videos.findMany({
where: { business_id: businessId },
select: { id: true, featured_media_id: true },
});
const videoIds = videos.map((v) => v.id);
if (!videoIds.length) {
return { deleted: 0, imagesDeleted: 0 };
}
const mediaIds = new Set<bigint>();
for (const row of videos) {
if (row.featured_media_id != null) {
mediaIds.add(row.featured_media_id);
}
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: { in: videoIds },
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: { in: videoIds },
},
}),
this.prisma.videos.updateMany({
where: { business_id: businessId, id: { in: videoIds } },
data: { featured_media_id: null },
}),
this.prisma.videos.deleteMany({
where: { business_id: businessId, id: { in: videoIds } },
}),
]);
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
return {
deleted: videoIds.length,
imagesDeleted,
};
}
private async purgeVideoCategories(
businessId: bigint,
): Promise<PurgeEntityCounts> {
return this.purgeCategories(businessId, MediaEntityType.video);
}
private async purgeCustomers(businessId: bigint): Promise<PurgeEntityCounts> {
const links = await this.prisma.businessCustomer.findMany({
where: { businessId },
@@ -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,
@@ -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,
@@ -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,
@@ -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 {
+3 -1
View File
@@ -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],
})
+57 -1
View File
@@ -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 {
+9
View File
@@ -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;
+32
View File
@@ -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}
+199
View File
@@ -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;
}
+120
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+843
View File
@@ -0,0 +1,843 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContentStatus,
MediaEntityType,
Prisma,
VideoProvider,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreateInstructionCommentDto,
CreateInstructionDto,
ListPublicInstructionsDto,
ListInstructionsDto,
UpdateInstructionDto,
} from './dto/instruction.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'instruction'
);
}
type InstructionWithRelations = Prisma.instructionsGetPayload<{
include: {
media: true;
author: {
select: { id: true; firstName: true; lastName: true; email: true };
};
};
}>;
const instructionInclude = {
media: true,
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
} satisfies Prisma.instructionsInclude;
@Injectable()
export class InstructionsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async list(businessIdRaw: string, query: ListInstructionsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.instructions.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: instructionInclude,
}),
this.prisma.instructions.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) => this.serializeInstruction(item, { includeComments: true })),
);
return { items: serialized, total, page, pageSize };
}
async getOne(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.read');
const video = await this.findInstructionOrThrow(businessId, instructionId);
return {
instruction: await this.serializeInstruction(video, { includeComments: true }),
};
}
async create(businessIdRaw: string, dto: CreateInstructionDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.create');
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
const titleEn = dto.titleEn?.trim() || null;
if (!titleFa) {
throw new BadRequestException('titleFa is required');
}
const slug = await this.ensureUniqueInstructionSlug(
businessId,
dto.slug ?? slugify(titleEn || titleFa),
);
const status = dto.status ?? ContentStatus.published;
const featuredMediaId = dto.featuredMediaId
? BigInt(dto.featuredMediaId)
: null;
if (featuredMediaId) {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
await this.assertAuthorBelongsToBusiness(businessId, authorId);
if (dto.categoryId) {
await this.assertCategoryBelongsToBusiness(
businessId,
BigInt(dto.categoryId),
);
}
const embedCode = dto.embedCode?.trim() || null;
const videoProvider = embedCode ? dto.videoProvider : null;
if (embedCode && !videoProvider) {
throw new BadRequestException(
'videoProvider is required when embedCode is set',
);
}
if (!embedCode && !(dto.descriptionHtml ?? '').trim()) {
throw new BadRequestException(
'Provide descriptionHtml or embedCode',
);
}
const created = await this.prisma.$transaction(async (tx) => {
const item = await tx.instructions.create({
data: {
business_id: businessId,
author_id: authorId,
title: titleFa,
title_fa: titleFa,
title_en: titleEn,
slug,
excerpt: dto.abstract?.trim() || null,
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
video_provider: videoProvider,
embed_code: embedCode,
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
},
include: instructionInclude,
});
if (dto.categoryId) {
await tx.categoryAssignment.create({
data: {
businessId,
categoryId: BigInt(dto.categoryId),
entityType: MediaEntityType.instruction,
entityId: item.id,
},
});
}
return item;
});
return {
message: 'Instruction created successfully',
instruction: await this.serializeInstruction(created),
};
}
async update(
businessIdRaw: string,
instructionIdRaw: string,
dto: UpdateInstructionDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.update');
const existing = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Instruction not found');
}
const nextTitleFa =
dto.titleFa !== undefined || dto.title !== undefined
? this.resolveTitleFa(dto.titleFa, dto.title)
: undefined;
if (
(dto.titleFa !== undefined || dto.title !== undefined) &&
!nextTitleFa
) {
throw new BadRequestException('titleFa is required');
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueInstructionSlug(businessId, dto.slug, instructionId);
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
const slugBase =
(dto.titleEn !== undefined
? dto.titleEn?.trim() || null
: existing.title_en) || nextTitleFa;
slug = await this.ensureUniqueInstructionSlug(businessId, slugify(slugBase), instructionId);
}
let featuredMediaId: bigint | null | undefined = undefined;
if (dto.featuredMediaId !== undefined) {
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
featuredMediaId = null;
} else {
featuredMediaId = BigInt(dto.featuredMediaId);
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
}
let authorId: bigint | null | undefined = undefined;
if (dto.authorId !== undefined) {
if (dto.authorId === null || dto.authorId === '') {
authorId = null;
} else {
authorId = BigInt(dto.authorId);
await this.assertAuthorBelongsToBusiness(businessId, authorId);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
const nextContent = { ...existingContent };
if (dto.descriptionHtml !== undefined) {
nextContent.html = dto.descriptionHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
if (
dto.status === ContentStatus.published &&
existing.status !== ContentStatus.published
) {
publishedAt = new Date();
}
if (dto.status !== ContentStatus.published) {
publishedAt = null;
}
}
const updated = await this.prisma.$transaction(async (tx) => {
const nextEmbed =
dto.embedCode !== undefined ? dto.embedCode?.trim() || null : undefined;
let nextProvider: VideoProvider | null | undefined = undefined;
if (dto.videoProvider !== undefined) {
nextProvider = dto.videoProvider ?? null;
} else if (nextEmbed !== undefined && !nextEmbed) {
nextProvider = null;
}
const video = await tx.instructions.update({
where: { id: instructionId },
data: {
...(nextTitleFa !== undefined
? { title: nextTitleFa, title_fa: nextTitleFa }
: {}),
...(dto.titleEn !== undefined
? { title_en: dto.titleEn?.trim() || null }
: {}),
...(dto.abstract !== undefined
? { excerpt: dto.abstract?.trim() || null }
: {}),
...(nextProvider !== undefined ? { video_provider: nextProvider } : {}),
...(nextEmbed !== undefined ? { embed_code: nextEmbed } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined
? { featured_media_id: featuredMediaId }
: {}),
...(authorId !== undefined ? { author_id: authorId } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: instructionInclude,
});
if (dto.categoryId !== undefined) {
await tx.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
});
if (dto.categoryId) {
const categoryId = BigInt(dto.categoryId);
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
await tx.categoryAssignment.create({
data: {
businessId,
categoryId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
});
}
}
return video;
});
return {
message: 'Instruction updated successfully',
instruction: await this.serializeInstruction(updated),
};
}
async remove(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.delete');
const existing = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Instruction not found');
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
}),
this.prisma.instructions.delete({ where: { id: instructionId } }),
]);
return { message: 'Instruction deleted successfully' };
}
async listPublic(host: string, query: ListPublicInstructionsDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, {
...query,
status: ContentStatus.published,
});
const [items, total] = await Promise.all([
this.prisma.instructions.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: instructionInclude,
}),
this.prisma.instructions.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializeInstruction(item, {
includeComments: true,
approvedCommentsOnly: true,
}),
),
);
return { items: serialized, total, page, pageSize };
}
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const video = await this.prisma.instructions.findFirst({
where: {
business_id: businessId,
slug,
status: ContentStatus.published,
},
include: instructionInclude,
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
return {
instruction: await this.serializeInstruction(video, {
includeComments: true,
approvedCommentsOnly: true,
}),
};
}
async listCommentsPublic(host: string, instructionIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const instructionId = BigInt(instructionIdRaw);
await this.assertPublishedInstructionExists(businessId, instructionId);
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
async createCommentPublic(
host: string,
instructionIdRaw: string,
dto: CreateInstructionCommentDto,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const instructionId = BigInt(instructionIdRaw);
await this.assertPublishedInstructionExists(businessId, instructionId);
const autoApprove =
await this.businessSettings.isCommentsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.comment.create({
data: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: { approver: true },
});
return {
comment: this.serializeComment(created),
message: autoApprove
? 'Comment submitted and is approved'
: 'Comment submitted and is pending approval',
};
}
async listCommentsAdmin(
businessIdRaw: string,
instructionIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'comments.read');
const video = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
select: { id: true },
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
private resolveTitleFa(titleFa?: string, title?: string): string {
return (titleFa ?? title ?? '').trim();
}
private async buildWhere(
businessId: bigint,
query: (ListInstructionsDto | ListPublicInstructionsDto) & { status?: ContentStatus },
): Promise<Prisma.instructionsWhereInput> {
let entityIds: bigint[] | undefined;
if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.instruction,
},
select: { entityId: true },
});
entityIds = assignments.map((item) => item.entityId);
if (entityIds.length === 0) {
return { id: { in: [] } };
}
}
return {
business_id: businessId,
...(query.status ? { status: query.status } : {}),
...(query.provider ? { video_provider: query.provider } : {}),
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
OR: [
{
title_fa: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title_en: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title: { contains: query.title.trim(), mode: 'insensitive' },
},
],
}
: {}),
};
}
private async findInstructionOrThrow(businessId: bigint, instructionId: bigint) {
const video = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
include: instructionInclude,
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
return video;
}
private async assertPublishedInstructionExists(
businessId: bigint,
instructionId: bigint,
) {
const video = await this.prisma.instructions.findFirst({
where: {
id: instructionId,
business_id: businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
}
private async serializeInstruction(
video: InstructionWithRelations,
options: {
includeComments?: boolean;
approvedCommentsOnly?: boolean;
} = {},
) {
const content = this.asRecord(video.content);
const metadata = this.asRecord(video.metadata);
const [categoryAssignment, commentData] = await Promise.all([
this.prisma.categoryAssignment.findFirst({
where: {
businessId: video.business_id,
entityType: MediaEntityType.instruction,
entityId: video.id,
},
include: { category: true },
}),
options.includeComments
? this.loadComments(
video.business_id,
video.id,
options.approvedCommentsOnly,
)
: Promise.resolve({ commentCount: 0, comments: [] }),
]);
return {
id: video.id.toString(),
businessId: video.business_id.toString(),
title: video.title,
titleFa: video.title_fa ?? video.title,
titleEn: video.title_en,
slug: video.slug,
abstract: video.excerpt ?? '',
descriptionHtml: (content.html as string | undefined) ?? '',
videoProvider: video.video_provider ?? null,
embedCode: video.embed_code ?? '',
status: video.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
id: video.author.id.toString(),
firstName: video.author.firstName,
lastName: video.author.lastName,
email: video.author.email,
}
: null,
coverImageUrl: video.media?.publicUrl ?? null,
featuredMediaId: video.featured_media_id?.toString() ?? null,
commentCount: commentData.commentCount,
comments: commentData.comments,
publishedAt: video.published_at,
createdAt: video.created_at,
updatedAt: video.updated_at,
};
}
private async loadComments(
businessId: bigint,
instructionId: bigint,
approvedOnly?: boolean,
) {
const where: Prisma.CommentWhereInput = {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
...(approvedOnly ? { isApproved: true } : {}),
};
const [commentCount, comments] = await Promise.all([
this.prisma.comment.count({ where }),
this.prisma.comment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: approvedOnly ? 50 : undefined,
include: { approver: true },
}),
]);
return {
commentCount,
comments: comments.map((item) => this.serializeComment(item)),
};
}
private serializeComment(
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
) {
return {
id: comment.id.toString(),
businessId: comment.businessId.toString(),
entityType: comment.entityType,
entityId: comment.entityId.toString(),
authorName: comment.authorName,
authorEmail: comment.authorEmail,
text: comment.text,
isApproved: comment.isApproved,
approvedAt: comment.approvedAt,
approvedBy: comment.approvedBy?.toString() ?? null,
approver: comment.approver
? {
id: comment.approver.id.toString(),
firstName: comment.approver.firstName,
lastName: comment.approver.lastName,
}
: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
private buildContent(descriptionHtml?: string) {
return {
html: descriptionHtml ?? '',
};
}
private buildMetadata(tags?: string[]) {
return {
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
};
}
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async assertMediaBelongsToBusiness(
businessId: bigint,
mediaId: bigint,
) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Media not found for this business');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
businessId,
entityType: MediaEntityType.instruction,
isActive: true,
},
});
if (!category) {
throw new BadRequestException('Instruction category not found for this business');
}
}
private async assertAuthorBelongsToBusiness(
businessId: bigint,
authorId: bigint,
) {
const member = await this.prisma.businessUser.findFirst({
where: { businessId, userId: authorId },
});
if (!member) {
throw new BadRequestException(
'Author must be a team member of this business',
);
}
}
private async ensureUniqueInstructionSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.instructions.findFirst({
where: {
business_id: businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}
+2
View File
@@ -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,
+246
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function extractSoapReturn(xml: string): string {
const match = xml.match(/<return[^>]*>([\s\S]*?)<\/return>/i);
return match ? match[1].trim() : '';
}
function readPayloadString(
payload: Record<string, unknown>,
...keys: string[]
): string {
for (const key of keys) {
const value = payload[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
}
return '';
}
@Injectable()
export class MellatGateway implements PaymentGatewayAdapter {
readonly id = 'mellat' as const;
private readonly logger = new Logger(MellatGateway.name);
isConfigured(settings: EPaymentSettings): boolean {
return isGatewayReady(settings, 'mellat');
}
async initiate(
credentials: GatewayCredentials,
input: InitiatePaymentInput,
): Promise<InitiatePaymentResult> {
const mellat = credentials.mellat;
const now = new Date();
const localDate = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('');
const localTime = [
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
// Mellat amount is Rials; Meshkee stores IRT (Toman).
const amountRials = Math.round(input.amountIrt * 10);
const raw = await this.soapCall('bpPayRequest', {
terminalId: mellat.terminalId,
userName: mellat.username,
userPassword: mellat.password,
orderId: input.transactionId.toString(),
amount: String(amountRials),
localDate,
localTime,
additionalData: input.description?.slice(0, 1000) ?? '',
callBackUrl: input.callbackUrl,
payerId: '0',
});
const [resCode, refId = ''] = raw.split(',', 2);
if (resCode !== '0' || !refId) {
this.logger.warn(
`bpPayRequest failed business=${input.businessId} order=${input.orderId} code=${resCode}`,
);
throw new BadGatewayException(
`Mellat payment init failed (code ${resCode || 'unknown'})`,
);
}
return {
gatewayRef: refId,
redirect: {
method: 'POST',
url: MELLAT_PAYMENT_URL,
fields: { RefId: refId },
},
meta: { resCode, amountRials },
};
}
async verify(
credentials: GatewayCredentials,
input: VerifyPaymentInput,
): Promise<VerifyPaymentResult> {
const mellat = credentials.mellat;
const resCode = readPayloadString(input.callbackPayload, 'ResCode', 'resCode');
const saleOrderId = readPayloadString(
input.callbackPayload,
'SaleOrderId',
'saleOrderId',
);
const saleReferenceId = readPayloadString(
input.callbackPayload,
'SaleReferenceId',
'saleReferenceId',
);
const refId = readPayloadString(input.callbackPayload, 'RefId', 'refId');
if (resCode !== '0') {
return {
success: false,
resCode: resCode || 'unknown',
message: `Mellat payment declined (ResCode ${resCode || 'unknown'})`,
meta: { saleOrderId, saleReferenceId, refId },
};
}
if (!saleOrderId || !saleReferenceId) {
return {
success: false,
resCode,
message: 'Mellat callback missing SaleOrderId or SaleReferenceId',
meta: { saleOrderId, saleReferenceId, refId },
};
}
const verifyRaw = await this.soapCall('bpVerifyRequest', {
terminalId: mellat.terminalId,
userName: mellat.username,
userPassword: mellat.password,
orderId: saleOrderId,
saleOrderId,
saleReferenceId,
});
if (verifyRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(verifyRaw)) {
return {
success: false,
resCode: verifyRaw,
trackId: saleReferenceId,
message: `Mellat verify failed (code ${verifyRaw})`,
meta: { saleOrderId, saleReferenceId, refId, verifyRaw },
};
}
const settleRaw = await this.soapCall('bpSettleRequest', {
terminalId: mellat.terminalId,
userName: mellat.username,
userPassword: mellat.password,
orderId: saleOrderId,
saleOrderId,
saleReferenceId,
});
if (settleRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(settleRaw)) {
this.logger.warn(
`bpSettleRequest non-success business=${input.businessId} code=${settleRaw} saleRef=${saleReferenceId}`,
);
// Payment was verified; settle failure is logged but treated as success
// so the customer is not charged twice on retry.
}
return {
success: true,
resCode: '0',
trackId: saleReferenceId,
message: 'Payment verified',
meta: {
saleOrderId,
saleReferenceId,
refId,
verifyRaw,
settleRaw,
},
};
}
private async soapCall(
method: string,
params: Record<string, string>,
): Promise<string> {
const inner = Object.entries(params)
.map(([key, value]) => `<${key}>${escapeXml(value)}</${key}>`)
.join('');
const body =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ` +
`xmlns:int="http://interfaces.core.sw.bps.com/">` +
`<soapenv:Header/>` +
`<soapenv:Body>` +
`<int:${method}>${inner}</int:${method}>` +
`</soapenv:Body>` +
`</soapenv:Envelope>`;
let response: Response;
try {
response = await fetch(MELLAT_SOAP_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '',
},
body,
});
} catch (err) {
this.logger.error(`Mellat SOAP network error (${method})`, err);
throw new BadGatewayException('Unable to reach Mellat payment gateway');
}
const text = await response.text();
if (!response.ok) {
this.logger.error(
`Mellat SOAP HTTP ${response.status} (${method}): ${text.slice(0, 400)}`,
);
throw new BadGatewayException('Mellat payment gateway returned an error');
}
return extractSoapReturn(text);
}
}
@@ -0,0 +1,65 @@
import type {
EPaymentSettings,
MellatGatewaySettings,
PaymentGatewayId,
} from '../../business-settings/business-settings.types';
export type PaymentRedirect = {
method: 'GET' | 'POST';
url: string;
/** Form fields for POST redirects (e.g. Mellat RefId). */
fields?: Record<string, string>;
};
export type InitiatePaymentInput = {
businessId: bigint;
orderId: bigint;
/** Used as gateway order id when the provider requires a numeric id. */
transactionId: bigint;
amountIrt: number;
callbackUrl: string;
description?: string;
};
export type InitiatePaymentResult = {
redirect: PaymentRedirect;
gatewayRef: string;
meta?: Record<string, unknown>;
};
export type VerifyPaymentInput = {
businessId: bigint;
orderId: bigint;
transactionId: bigint;
amountIrt: number;
gatewayRef: string | null;
callbackPayload: Record<string, unknown>;
};
export type VerifyPaymentResult = {
success: boolean;
trackId?: string;
resCode?: string;
message?: string;
meta?: Record<string, unknown>;
};
export type GatewayCredentials = {
mellat: MellatGatewaySettings;
};
export interface PaymentGatewayAdapter {
readonly id: PaymentGatewayId;
isConfigured(settings: EPaymentSettings): boolean;
initiate(
credentials: GatewayCredentials,
input: InitiatePaymentInput,
): Promise<InitiatePaymentResult>;
verify(
credentials: GatewayCredentials,
input: VerifyPaymentInput,
): Promise<VerifyPaymentResult>;
}
+27
View File
@@ -0,0 +1,27 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
import { MellatGateway } from './gateways/mellat.gateway';
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
@Injectable()
export class PaymentGatewayRegistry {
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
constructor(mellat: MellatGateway) {
this.adapters = new Map([[mellat.id, mellat]]);
}
get(gatewayId: string): PaymentGatewayAdapter {
const adapter = this.adapters.get(gatewayId as PaymentGatewayId);
if (!adapter) {
throw new BadRequestException(
`Payment gateway "${gatewayId}" is not supported`,
);
}
return adapter;
}
listIds(): PaymentGatewayId[] {
return [...this.adapters.keys()];
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { PaymentsService } from './payments.service';
/**
* Public bank callback + optional status helpers.
* Bank gateways POST/GET here without JWT.
*/
@Controller('businesses/:businessId/payments')
export class PaymentsController {
constructor(private readonly payments: PaymentsService) {}
@Get('methods')
methods(@Param('businessId') businessId: string) {
return this.payments.getPublicMethods(BigInt(businessId));
}
@Post(':gateway/callback')
async callbackPost(
@Param('businessId') businessId: string,
@Param('gateway') gateway: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
) {
const result = await this.payments.handleGatewayCallback(
businessId,
gateway,
body ?? {},
);
return res.redirect(303, result.redirectUrl);
}
@Get(':gateway/callback')
async callbackGet(
@Param('businessId') businessId: string,
@Param('gateway') gateway: string,
@Query() query: Record<string, unknown>,
@Res() res: Response,
) {
const result = await this.payments.handleGatewayCallback(
businessId,
gateway,
query ?? {},
);
return res.redirect(303, result.redirectUrl);
}
}
+14
View File
@@ -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 {}
+378
View File
@@ -0,0 +1,378 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import {
PAYMENT_GATEWAY_IDS,
type PaymentGatewayId,
} from '../business-settings/business-settings.types';
import {
isGatewayReady,
listPublicPaymentGateways,
} from '../business-settings/business-settings.util';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
import type { PaymentRedirect } from './gateways/payment-gateway.types';
type GatewayMeta = {
returnUrl?: string;
initiatedAt?: string;
callback?: Record<string, unknown>;
verify?: Record<string, unknown>;
[key: string]: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asGatewayMeta(value: unknown): GatewayMeta {
return isRecord(value) ? (value as GatewayMeta) : {};
}
function appendQuery(url: string, params: Record<string, string>) {
const target = new URL(url);
for (const [key, value] of Object.entries(params)) {
target.searchParams.set(key, value);
}
return target.toString();
}
function isSafeReturnUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly settings: BusinessSettingsService,
private readonly gateways: PaymentGatewayRegistry,
private readonly config: ConfigService,
) {}
async getPublicMethods(businessId: bigint) {
const normalized = await this.settings.getNormalizedSettings(businessId);
return listPublicPaymentGateways(normalized.store.ePayment);
}
resolveGatewayType(
requested: string | undefined,
ePayment: ReturnType<typeof listPublicPaymentGateways>,
): PaymentGatewayId {
const requestedId = requested?.trim().toLowerCase() || '';
if (requestedId) {
if (!(PAYMENT_GATEWAY_IDS as readonly string[]).includes(requestedId)) {
throw new BadRequestException(
`Unknown payment gateway "${requestedId}"`,
);
}
if (!ePayment.gateways.some((g) => g.id === requestedId)) {
throw new BadRequestException(
`Payment gateway "${requestedId}" is not enabled for this store`,
);
}
return requestedId as PaymentGatewayId;
}
if (!ePayment.defaultGateway) {
throw new BadRequestException('No online payment gateway is configured');
}
return ePayment.defaultGateway;
}
async initiateForCheckout(input: {
businessId: bigint;
order: {
id: string;
total: number;
transactions?: Array<{
id: string;
type: string;
status: string;
gatewayType: string | null;
}>;
};
gatewayType: PaymentGatewayId;
returnUrl: string;
}): Promise<{
transactionId: string;
gatewayType: PaymentGatewayId;
redirect: PaymentRedirect;
}> {
if (!isSafeReturnUrl(input.returnUrl)) {
throw new BadRequestException(
'returnUrl must be an absolute http(s) URL',
);
}
const normalized = await this.settings.getNormalizedSettings(
input.businessId,
);
const ePayment = normalized.store.ePayment;
if (!isGatewayReady(ePayment, input.gatewayType)) {
throw new BadRequestException(
`Payment gateway "${input.gatewayType}" is not ready`,
);
}
const transaction = (input.order.transactions ?? []).find(
(row) =>
row.type === 'e_payment_gate' &&
row.status === 'pending' &&
row.gatewayType === input.gatewayType,
);
if (!transaction) {
throw new BadRequestException(
'Pending e-payment transaction not found for this order',
);
}
const adapter = this.gateways.get(input.gatewayType);
const callbackUrl = this.buildCallbackUrl(
input.businessId,
input.gatewayType,
);
const result = await adapter.initiate(
{ mellat: ePayment.gateways.mellat },
{
businessId: input.businessId,
orderId: BigInt(input.order.id),
transactionId: BigInt(transaction.id),
amountIrt: input.order.total,
callbackUrl,
description: `Order ${input.order.id}`,
},
);
const meta: GatewayMeta = {
returnUrl: input.returnUrl,
initiatedAt: new Date().toISOString(),
...(result.meta ?? {}),
};
await this.prisma.transaction.update({
where: { id: BigInt(transaction.id) },
data: {
gatewayRef: result.gatewayRef,
gatewayMeta: meta as Prisma.InputJsonValue,
},
});
return {
transactionId: transaction.id,
gatewayType: input.gatewayType,
redirect: result.redirect,
};
}
async handleGatewayCallback(
businessIdRaw: string,
gatewayTypeRaw: string,
payload: Record<string, unknown>,
): Promise<{ redirectUrl: string }> {
const businessId = BigInt(businessIdRaw);
const gatewayType = gatewayTypeRaw.trim().toLowerCase();
const adapter = this.gateways.get(gatewayType);
const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId');
const refId = this.readString(payload, 'RefId', 'refId');
const transaction = await this.findCallbackTransaction(
businessId,
gatewayType,
saleOrderId,
refId,
);
if (!transaction) {
throw new NotFoundException('Payment transaction not found');
}
const meta = asGatewayMeta(transaction.gatewayMeta);
const returnUrl =
typeof meta.returnUrl === 'string' && isSafeReturnUrl(meta.returnUrl)
? meta.returnUrl
: null;
const failRedirect = (status: string, message?: string) => {
if (!returnUrl) {
throw new BadRequestException(
message || 'Payment failed and no returnUrl was stored',
);
}
return {
redirectUrl: appendQuery(returnUrl, {
status,
orderId: transaction.orderId?.toString() ?? '',
transactionId: transaction.id.toString(),
...(message ? { message } : {}),
}),
};
};
if (transaction.status === 'completed') {
return failRedirect('success');
}
if (transaction.status === 'failed' || transaction.status === 'refunded') {
return failRedirect('failed', 'Payment already marked as failed');
}
const settings = await this.settings.getNormalizedSettings(businessId);
if (!isGatewayReady(settings.store.ePayment, gatewayType as PaymentGatewayId)) {
await this.markFailed(transaction.id, {
...meta,
callback: payload,
error: 'Gateway no longer configured',
});
return failRedirect('failed', 'Payment gateway is not configured');
}
if (!transaction.orderId) {
await this.markFailed(transaction.id, {
...meta,
callback: payload,
error: 'Transaction has no order',
});
return failRedirect('failed', 'Order missing for payment');
}
const verify = await adapter.verify(
{ mellat: settings.store.ePayment.gateways.mellat },
{
businessId,
orderId: transaction.orderId,
transactionId: transaction.id,
amountIrt: Number(transaction.amount),
gatewayRef: transaction.gatewayRef,
callbackPayload: payload,
},
);
if (!verify.success) {
await this.markFailed(transaction.id, {
...meta,
callback: payload,
verify: verify.meta ?? { message: verify.message, resCode: verify.resCode },
});
this.logger.warn(
`Payment failed business=${businessId} tx=${transaction.id} code=${verify.resCode}`,
);
return failRedirect('failed', verify.message || 'Payment failed');
}
await this.prisma.transaction.update({
where: { id: transaction.id },
data: {
status: 'completed',
gatewayTrackId: verify.trackId ?? transaction.gatewayTrackId,
gatewayMeta: {
...meta,
callback: payload,
verify: verify.meta ?? {},
completedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
},
});
// Keep order as pending for fulfillment; payment completion is on the transaction.
if (!returnUrl) {
throw new BadRequestException(
'Payment completed but no returnUrl was stored',
);
}
return {
redirectUrl: appendQuery(returnUrl, {
status: 'success',
orderId: transaction.orderId.toString(),
transactionId: transaction.id.toString(),
...(verify.trackId ? { trackId: verify.trackId } : {}),
}),
};
}
private buildCallbackUrl(businessId: bigint, gatewayType: string) {
const base =
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
`http://localhost:${this.config.get<string>('PORT') || '3000'}`;
return `${base}/api/v1/businesses/${businessId.toString()}/payments/${gatewayType}/callback`;
}
private async findCallbackTransaction(
businessId: bigint,
gatewayType: string,
saleOrderId: string,
refId: string,
) {
if (saleOrderId && /^\d+$/.test(saleOrderId)) {
const byId = await this.prisma.transaction.findFirst({
where: {
id: BigInt(saleOrderId),
businessId,
type: 'e_payment_gate',
gatewayType,
},
});
if (byId) return byId;
}
if (refId) {
return this.prisma.transaction.findFirst({
where: {
businessId,
type: 'e_payment_gate',
gatewayType,
gatewayRef: refId,
},
orderBy: { createdAt: 'desc' },
});
}
return null;
}
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
await this.prisma.transaction.update({
where: { id: transactionId },
data: {
status: 'failed',
gatewayMeta: {
...meta,
failedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
},
});
}
private readString(
payload: Record<string, unknown>,
...keys: string[]
): string {
for (const key of keys) {
const value = payload[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
}
return '';
}
}
+2 -1
View File
@@ -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),
};
}
}
+197
View File
@@ -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;
}
+120
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+825
View File
@@ -0,0 +1,825 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContentStatus,
MediaEntityType,
Prisma,
VideoProvider,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreateVideoCommentDto,
CreateVideoDto,
ListPublicVideosDto,
ListVideosDto,
UpdateVideoDto,
} from './dto/video.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'video'
);
}
type VideoWithRelations = Prisma.videosGetPayload<{
include: {
media: true;
author: {
select: { id: true; firstName: true; lastName: true; email: true };
};
};
}>;
const videoInclude = {
media: true,
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
} satisfies Prisma.videosInclude;
@Injectable()
export class VideosService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async list(businessIdRaw: string, query: ListVideosDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'videos.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.videos.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: videoInclude,
}),
this.prisma.videos.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) => this.serializeVideo(item, { includeComments: true })),
);
return { items: serialized, total, page, pageSize };
}
async getOne(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const videoId = BigInt(videoIdRaw);
await this.assertPermission(businessId, actor.id, 'videos.read');
const video = await this.findVideoOrThrow(businessId, videoId);
return {
video: await this.serializeVideo(video, { includeComments: true }),
};
}
async create(businessIdRaw: string, dto: CreateVideoDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'videos.create');
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
const titleEn = dto.titleEn?.trim() || null;
if (!titleFa) {
throw new BadRequestException('titleFa is required');
}
const slug = await this.ensureUniqueSlug(
businessId,
dto.slug ?? slugify(titleEn || titleFa),
);
const status = dto.status ?? ContentStatus.published;
const featuredMediaId = dto.featuredMediaId
? BigInt(dto.featuredMediaId)
: null;
if (featuredMediaId) {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
await this.assertAuthorBelongsToBusiness(businessId, authorId);
if (dto.categoryId) {
await this.assertCategoryBelongsToBusiness(
businessId,
BigInt(dto.categoryId),
);
}
const created = await this.prisma.$transaction(async (tx) => {
const video = await tx.videos.create({
data: {
business_id: businessId,
author_id: authorId,
title: titleFa,
title_fa: titleFa,
title_en: titleEn,
slug,
excerpt: dto.abstract?.trim() || null,
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
video_provider: dto.videoProvider,
embed_code: dto.embedCode.trim(),
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
},
include: videoInclude,
});
if (dto.categoryId) {
await tx.categoryAssignment.create({
data: {
businessId,
categoryId: BigInt(dto.categoryId),
entityType: MediaEntityType.video,
entityId: video.id,
},
});
}
return video;
});
return {
message: 'Video created successfully',
video: await this.serializeVideo(created),
};
}
async update(
businessIdRaw: string,
videoIdRaw: string,
dto: UpdateVideoDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const videoId = BigInt(videoIdRaw);
await this.assertPermission(businessId, actor.id, 'videos.update');
const existing = await this.prisma.videos.findFirst({
where: { id: videoId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Video not found');
}
const nextTitleFa =
dto.titleFa !== undefined || dto.title !== undefined
? this.resolveTitleFa(dto.titleFa, dto.title)
: undefined;
if (
(dto.titleFa !== undefined || dto.title !== undefined) &&
!nextTitleFa
) {
throw new BadRequestException('titleFa is required');
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(businessId, dto.slug, videoId);
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
const slugBase =
(dto.titleEn !== undefined
? dto.titleEn?.trim() || null
: existing.title_en) || nextTitleFa;
slug = await this.ensureUniqueSlug(businessId, slugify(slugBase), videoId);
}
let featuredMediaId: bigint | null | undefined = undefined;
if (dto.featuredMediaId !== undefined) {
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
featuredMediaId = null;
} else {
featuredMediaId = BigInt(dto.featuredMediaId);
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
}
let authorId: bigint | null | undefined = undefined;
if (dto.authorId !== undefined) {
if (dto.authorId === null || dto.authorId === '') {
authorId = null;
} else {
authorId = BigInt(dto.authorId);
await this.assertAuthorBelongsToBusiness(businessId, authorId);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
const nextContent = { ...existingContent };
if (dto.descriptionHtml !== undefined) {
nextContent.html = dto.descriptionHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
if (
dto.status === ContentStatus.published &&
existing.status !== ContentStatus.published
) {
publishedAt = new Date();
}
if (dto.status !== ContentStatus.published) {
publishedAt = null;
}
}
const updated = await this.prisma.$transaction(async (tx) => {
const video = await tx.videos.update({
where: { id: videoId },
data: {
...(nextTitleFa !== undefined
? { title: nextTitleFa, title_fa: nextTitleFa }
: {}),
...(dto.titleEn !== undefined
? { title_en: dto.titleEn?.trim() || null }
: {}),
...(dto.abstract !== undefined
? { excerpt: dto.abstract?.trim() || null }
: {}),
...(dto.videoProvider !== undefined
? { video_provider: dto.videoProvider }
: {}),
...(dto.embedCode !== undefined
? { embed_code: dto.embedCode.trim() }
: {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined
? { featured_media_id: featuredMediaId }
: {}),
...(authorId !== undefined ? { author_id: authorId } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: videoInclude,
});
if (dto.categoryId !== undefined) {
await tx.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
},
});
if (dto.categoryId) {
const categoryId = BigInt(dto.categoryId);
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
await tx.categoryAssignment.create({
data: {
businessId,
categoryId,
entityType: MediaEntityType.video,
entityId: videoId,
},
});
}
}
return video;
});
return {
message: 'Video updated successfully',
video: await this.serializeVideo(updated),
};
}
async remove(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const videoId = BigInt(videoIdRaw);
await this.assertPermission(businessId, actor.id, 'videos.delete');
const existing = await this.prisma.videos.findFirst({
where: { id: videoId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Video not found');
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
},
}),
this.prisma.videos.delete({ where: { id: videoId } }),
]);
return { message: 'Video deleted successfully' };
}
async listPublic(host: string, query: ListPublicVideosDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, {
...query,
status: ContentStatus.published,
});
const [items, total] = await Promise.all([
this.prisma.videos.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: videoInclude,
}),
this.prisma.videos.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializeVideo(item, {
includeComments: true,
approvedCommentsOnly: true,
}),
),
);
return { items: serialized, total, page, pageSize };
}
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const video = await this.prisma.videos.findFirst({
where: {
business_id: businessId,
slug,
status: ContentStatus.published,
},
include: videoInclude,
});
if (!video) {
throw new NotFoundException('Video not found');
}
return {
video: await this.serializeVideo(video, {
includeComments: true,
approvedCommentsOnly: true,
}),
};
}
async listCommentsPublic(host: string, videoIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const videoId = BigInt(videoIdRaw);
await this.assertPublishedVideoExists(businessId, videoId);
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
async createCommentPublic(
host: string,
videoIdRaw: string,
dto: CreateVideoCommentDto,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const videoId = BigInt(videoIdRaw);
await this.assertPublishedVideoExists(businessId, videoId);
const autoApprove =
await this.businessSettings.isCommentsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.comment.create({
data: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: { approver: true },
});
return {
comment: this.serializeComment(created),
message: autoApprove
? 'Comment submitted and is approved'
: 'Comment submitted and is pending approval',
};
}
async listCommentsAdmin(
businessIdRaw: string,
videoIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const videoId = BigInt(videoIdRaw);
await this.assertPermission(businessId, actor.id, 'comments.read');
const video = await this.prisma.videos.findFirst({
where: { id: videoId, business_id: businessId },
select: { id: true },
});
if (!video) {
throw new NotFoundException('Video not found');
}
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
private resolveTitleFa(titleFa?: string, title?: string): string {
return (titleFa ?? title ?? '').trim();
}
private async buildWhere(
businessId: bigint,
query: (ListVideosDto | ListPublicVideosDto) & { status?: ContentStatus },
): Promise<Prisma.videosWhereInput> {
let entityIds: bigint[] | undefined;
if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.video,
},
select: { entityId: true },
});
entityIds = assignments.map((item) => item.entityId);
if (entityIds.length === 0) {
return { id: { in: [] } };
}
}
return {
business_id: businessId,
...(query.status ? { status: query.status } : {}),
...(query.provider ? { video_provider: query.provider } : {}),
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
OR: [
{
title_fa: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title_en: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title: { contains: query.title.trim(), mode: 'insensitive' },
},
],
}
: {}),
};
}
private async findVideoOrThrow(businessId: bigint, videoId: bigint) {
const video = await this.prisma.videos.findFirst({
where: { id: videoId, business_id: businessId },
include: videoInclude,
});
if (!video) {
throw new NotFoundException('Video not found');
}
return video;
}
private async assertPublishedVideoExists(
businessId: bigint,
videoId: bigint,
) {
const video = await this.prisma.videos.findFirst({
where: {
id: videoId,
business_id: businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!video) {
throw new NotFoundException('Video not found');
}
}
private async serializeVideo(
video: VideoWithRelations,
options: {
includeComments?: boolean;
approvedCommentsOnly?: boolean;
} = {},
) {
const content = this.asRecord(video.content);
const metadata = this.asRecord(video.metadata);
const [categoryAssignment, commentData] = await Promise.all([
this.prisma.categoryAssignment.findFirst({
where: {
businessId: video.business_id,
entityType: MediaEntityType.video,
entityId: video.id,
},
include: { category: true },
}),
options.includeComments
? this.loadComments(
video.business_id,
video.id,
options.approvedCommentsOnly,
)
: Promise.resolve({ commentCount: 0, comments: [] }),
]);
return {
id: video.id.toString(),
businessId: video.business_id.toString(),
title: video.title,
titleFa: video.title_fa ?? video.title,
titleEn: video.title_en,
slug: video.slug,
abstract: video.excerpt ?? '',
descriptionHtml: (content.html as string | undefined) ?? '',
videoProvider: video.video_provider,
embedCode: video.embed_code,
status: video.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
id: video.author.id.toString(),
firstName: video.author.firstName,
lastName: video.author.lastName,
email: video.author.email,
}
: null,
coverImageUrl: video.media?.publicUrl ?? null,
featuredMediaId: video.featured_media_id?.toString() ?? null,
commentCount: commentData.commentCount,
comments: commentData.comments,
publishedAt: video.published_at,
createdAt: video.created_at,
updatedAt: video.updated_at,
};
}
private async loadComments(
businessId: bigint,
videoId: bigint,
approvedOnly?: boolean,
) {
const where: Prisma.CommentWhereInput = {
businessId,
entityType: MediaEntityType.video,
entityId: videoId,
...(approvedOnly ? { isApproved: true } : {}),
};
const [commentCount, comments] = await Promise.all([
this.prisma.comment.count({ where }),
this.prisma.comment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: approvedOnly ? 50 : undefined,
include: { approver: true },
}),
]);
return {
commentCount,
comments: comments.map((item) => this.serializeComment(item)),
};
}
private serializeComment(
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
) {
return {
id: comment.id.toString(),
businessId: comment.businessId.toString(),
entityType: comment.entityType,
entityId: comment.entityId.toString(),
authorName: comment.authorName,
authorEmail: comment.authorEmail,
text: comment.text,
isApproved: comment.isApproved,
approvedAt: comment.approvedAt,
approvedBy: comment.approvedBy?.toString() ?? null,
approver: comment.approver
? {
id: comment.approver.id.toString(),
firstName: comment.approver.firstName,
lastName: comment.approver.lastName,
}
: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
private buildContent(descriptionHtml?: string) {
return {
html: descriptionHtml ?? '',
};
}
private buildMetadata(tags?: string[]) {
return {
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
};
}
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async assertMediaBelongsToBusiness(
businessId: bigint,
mediaId: bigint,
) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Media not found for this business');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
businessId,
entityType: MediaEntityType.video,
isActive: true,
},
});
if (!category) {
throw new BadRequestException('Video category not found for this business');
}
}
private async assertAuthorBelongsToBusiness(
businessId: bigint,
authorId: bigint,
) {
const member = await this.prisma.businessUser.findFirst({
where: { businessId, userId: authorId },
});
if (!member) {
throw new BadRequestException(
'Author must be a team member of this business',
);
}
}
private async ensureUniqueSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.videos.findFirst({
where: {
business_id: businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}
+4
View File
@@ -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`.
@@ -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."
}
}
]
File diff suppressed because it is too large Load Diff