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
+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