Add Mellat e-payment, videos/instructions CMS, and legacy domain lookup.
Store gateway credentials per business, initiate/verify Mellat at checkout, and resolve old CMS ids from domains in the migrate modal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
513673a7da
commit
d5e502cf77
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ContentStatus, VideoProvider } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListInstructionsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
provider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class ListPublicInstructionsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
provider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class CreateInstructionDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
/** @deprecated use titleFa */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
videoProvider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
embedCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class UpdateInstructionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string | null;
|
||||
|
||||
/** @deprecated use titleFa */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
videoProvider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
embedCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class CreateInstructionCommentDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
authorName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InstructionsService } from './instructions.service';
|
||||
import {
|
||||
CreateInstructionCommentDto,
|
||||
CreateInstructionDto,
|
||||
ListPublicInstructionsDto,
|
||||
ListInstructionsDto,
|
||||
UpdateInstructionDto,
|
||||
} from './dto/instruction.dto';
|
||||
|
||||
@Controller('businesses/:businessId/instructions')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class InstructionsController {
|
||||
constructor(private readonly service: InstructionsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('instructions.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListInstructionsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':instructionId')
|
||||
@RequireBusinessPermission('instructions.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('instructionId') instructionId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, instructionId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('instructions.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateInstructionDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':instructionId')
|
||||
@RequireBusinessPermission('instructions.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('instructionId') instructionId: string,
|
||||
@Body() dto: UpdateInstructionDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, instructionId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':instructionId')
|
||||
@RequireBusinessPermission('instructions.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('instructionId') instructionId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, instructionId, user);
|
||||
}
|
||||
|
||||
@Get(':instructionId/comments')
|
||||
@RequireBusinessPermission('comments.read')
|
||||
listComments(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('instructionId') instructionId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listCommentsAdmin(businessId, instructionId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/instructions')
|
||||
export class PublicInstructionsController {
|
||||
constructor(private readonly service: InstructionsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicInstructionsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':instructionId/comments')
|
||||
listComments(@Param('host') host: string, @Param('instructionId') instructionId: string) {
|
||||
return this.service.listCommentsPublic(host, instructionId);
|
||||
}
|
||||
|
||||
@Post(':instructionId/comments')
|
||||
createComment(
|
||||
@Param('host') host: string,
|
||||
@Param('instructionId') instructionId: string,
|
||||
@Body() dto: CreateInstructionCommentDto,
|
||||
) {
|
||||
return this.service.createCommentPublic(host, instructionId, dto);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { PublicInstructionsController, InstructionsController } from './instructions.controller';
|
||||
import { InstructionsService } from './instructions.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||
controllers: [InstructionsController, PublicInstructionsController],
|
||||
providers: [InstructionsService],
|
||||
})
|
||||
export class InstructionsModule {}
|
||||
@@ -0,0 +1,843 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContentStatus,
|
||||
MediaEntityType,
|
||||
Prisma,
|
||||
VideoProvider,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateInstructionCommentDto,
|
||||
CreateInstructionDto,
|
||||
ListPublicInstructionsDto,
|
||||
ListInstructionsDto,
|
||||
UpdateInstructionDto,
|
||||
} from './dto/instruction.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'instruction'
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionWithRelations = Prisma.instructionsGetPayload<{
|
||||
include: {
|
||||
media: true;
|
||||
author: {
|
||||
select: { id: true; firstName: true; lastName: true; email: true };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const instructionInclude = {
|
||||
media: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.instructionsInclude;
|
||||
|
||||
@Injectable()
|
||||
export class InstructionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListInstructionsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'instructions.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.instructions.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: instructionInclude,
|
||||
}),
|
||||
this.prisma.instructions.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) => this.serializeInstruction(item, { includeComments: true })),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'instructions.read');
|
||||
|
||||
const video = await this.findInstructionOrThrow(businessId, instructionId);
|
||||
|
||||
return {
|
||||
instruction: await this.serializeInstruction(video, { includeComments: true }),
|
||||
};
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateInstructionDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'instructions.create');
|
||||
|
||||
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
|
||||
const titleEn = dto.titleEn?.trim() || null;
|
||||
if (!titleFa) {
|
||||
throw new BadRequestException('titleFa is required');
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueInstructionSlug(
|
||||
businessId,
|
||||
dto.slug ?? slugify(titleEn || titleFa),
|
||||
);
|
||||
|
||||
const status = dto.status ?? ContentStatus.published;
|
||||
const featuredMediaId = dto.featuredMediaId
|
||||
? BigInt(dto.featuredMediaId)
|
||||
: null;
|
||||
|
||||
if (featuredMediaId) {
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
|
||||
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
|
||||
if (dto.categoryId) {
|
||||
await this.assertCategoryBelongsToBusiness(
|
||||
businessId,
|
||||
BigInt(dto.categoryId),
|
||||
);
|
||||
}
|
||||
|
||||
const embedCode = dto.embedCode?.trim() || null;
|
||||
const videoProvider = embedCode ? dto.videoProvider : null;
|
||||
if (embedCode && !videoProvider) {
|
||||
throw new BadRequestException(
|
||||
'videoProvider is required when embedCode is set',
|
||||
);
|
||||
}
|
||||
if (!embedCode && !(dto.descriptionHtml ?? '').trim()) {
|
||||
throw new BadRequestException(
|
||||
'Provide descriptionHtml or embedCode',
|
||||
);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const item = await tx.instructions.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
author_id: authorId,
|
||||
title: titleFa,
|
||||
title_fa: titleFa,
|
||||
title_en: titleEn,
|
||||
slug,
|
||||
excerpt: dto.abstract?.trim() || null,
|
||||
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
|
||||
video_provider: videoProvider,
|
||||
embed_code: embedCode,
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: instructionInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: item.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Instruction created successfully',
|
||||
instruction: await this.serializeInstruction(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
instructionIdRaw: string,
|
||||
dto: UpdateInstructionDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'instructions.update');
|
||||
|
||||
const existing = await this.prisma.instructions.findFirst({
|
||||
where: { id: instructionId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
|
||||
const nextTitleFa =
|
||||
dto.titleFa !== undefined || dto.title !== undefined
|
||||
? this.resolveTitleFa(dto.titleFa, dto.title)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
(dto.titleFa !== undefined || dto.title !== undefined) &&
|
||||
!nextTitleFa
|
||||
) {
|
||||
throw new BadRequestException('titleFa is required');
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueInstructionSlug(businessId, dto.slug, instructionId);
|
||||
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
|
||||
const slugBase =
|
||||
(dto.titleEn !== undefined
|
||||
? dto.titleEn?.trim() || null
|
||||
: existing.title_en) || nextTitleFa;
|
||||
slug = await this.ensureUniqueInstructionSlug(businessId, slugify(slugBase), instructionId);
|
||||
}
|
||||
|
||||
let featuredMediaId: bigint | null | undefined = undefined;
|
||||
if (dto.featuredMediaId !== undefined) {
|
||||
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||
featuredMediaId = null;
|
||||
} else {
|
||||
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
let authorId: bigint | null | undefined = undefined;
|
||||
if (dto.authorId !== undefined) {
|
||||
if (dto.authorId === null || dto.authorId === '') {
|
||||
authorId = null;
|
||||
} else {
|
||||
authorId = BigInt(dto.authorId);
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
}
|
||||
}
|
||||
|
||||
const existingContent = this.asRecord(existing.content);
|
||||
const existingMetadata = this.asRecord(existing.metadata);
|
||||
|
||||
const nextContent = { ...existingContent };
|
||||
if (dto.descriptionHtml !== undefined) {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
if (
|
||||
dto.status === ContentStatus.published &&
|
||||
existing.status !== ContentStatus.published
|
||||
) {
|
||||
publishedAt = new Date();
|
||||
}
|
||||
if (dto.status !== ContentStatus.published) {
|
||||
publishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const nextEmbed =
|
||||
dto.embedCode !== undefined ? dto.embedCode?.trim() || null : undefined;
|
||||
let nextProvider: VideoProvider | null | undefined = undefined;
|
||||
if (dto.videoProvider !== undefined) {
|
||||
nextProvider = dto.videoProvider ?? null;
|
||||
} else if (nextEmbed !== undefined && !nextEmbed) {
|
||||
nextProvider = null;
|
||||
}
|
||||
|
||||
const video = await tx.instructions.update({
|
||||
where: { id: instructionId },
|
||||
data: {
|
||||
...(nextTitleFa !== undefined
|
||||
? { title: nextTitleFa, title_fa: nextTitleFa }
|
||||
: {}),
|
||||
...(dto.titleEn !== undefined
|
||||
? { title_en: dto.titleEn?.trim() || null }
|
||||
: {}),
|
||||
...(dto.abstract !== undefined
|
||||
? { excerpt: dto.abstract?.trim() || null }
|
||||
: {}),
|
||||
...(nextProvider !== undefined ? { video_provider: nextProvider } : {}),
|
||||
...(nextEmbed !== undefined ? { embed_code: nextEmbed } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(featuredMediaId !== undefined
|
||||
? { featured_media_id: featuredMediaId }
|
||||
: {}),
|
||||
...(authorId !== undefined ? { author_id: authorId } : {}),
|
||||
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
||||
slug,
|
||||
content: nextContent as Prisma.InputJsonValue,
|
||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||
},
|
||||
include: instructionInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return video;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Instruction updated successfully',
|
||||
instruction: await this.serializeInstruction(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'instructions.delete');
|
||||
|
||||
const existing = await this.prisma.instructions.findFirst({
|
||||
where: { id: instructionId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.comment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
},
|
||||
}),
|
||||
this.prisma.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
},
|
||||
}),
|
||||
this.prisma.instructions.delete({ where: { id: instructionId } }),
|
||||
]);
|
||||
|
||||
return { message: 'Instruction deleted successfully' };
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicInstructionsDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, {
|
||||
...query,
|
||||
status: ContentStatus.published,
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.instructions.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: instructionInclude,
|
||||
}),
|
||||
this.prisma.instructions.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.serializeInstruction(item, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPublicBySlug(host: string, slug: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const video = await this.prisma.instructions.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: instructionInclude,
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
|
||||
return {
|
||||
instruction: await this.serializeInstruction(video, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsPublic(host: string, instructionIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
|
||||
await this.assertPublishedInstructionExists(businessId, instructionId);
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
isApproved: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
async createCommentPublic(
|
||||
host: string,
|
||||
instructionIdRaw: string,
|
||||
dto: CreateInstructionCommentDto,
|
||||
) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
|
||||
await this.assertPublishedInstructionExists(businessId, instructionId);
|
||||
|
||||
const autoApprove =
|
||||
await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||
const approvedAt = autoApprove ? new Date() : null;
|
||||
|
||||
const created = await this.prisma.comment.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
authorName: dto.authorName.trim(),
|
||||
authorEmail: dto.authorEmail?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
isApproved: autoApprove,
|
||||
approvedAt,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return {
|
||||
comment: this.serializeComment(created),
|
||||
message: autoApprove
|
||||
? 'Comment submitted and is approved'
|
||||
: 'Comment submitted and is pending approval',
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsAdmin(
|
||||
businessIdRaw: string,
|
||||
instructionIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const instructionId = BigInt(instructionIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||
|
||||
const video = await this.prisma.instructions.findFirst({
|
||||
where: { id: instructionId, business_id: businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
private resolveTitleFa(titleFa?: string, title?: string): string {
|
||||
return (titleFa ?? title ?? '').trim();
|
||||
}
|
||||
|
||||
private async buildWhere(
|
||||
businessId: bigint,
|
||||
query: (ListInstructionsDto | ListPublicInstructionsDto) & { status?: ContentStatus },
|
||||
): Promise<Prisma.instructionsWhereInput> {
|
||||
let entityIds: bigint[] | undefined;
|
||||
|
||||
if (query.categoryId) {
|
||||
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
categoryId: BigInt(query.categoryId),
|
||||
entityType: MediaEntityType.instruction,
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
|
||||
entityIds = assignments.map((item) => item.entityId);
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
business_id: businessId,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.provider ? { video_provider: query.provider } : {}),
|
||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||
...(query.title?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
title_fa: {
|
||||
contains: query.title.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
title_en: {
|
||||
contains: query.title.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async findInstructionOrThrow(businessId: bigint, instructionId: bigint) {
|
||||
const video = await this.prisma.instructions.findFirst({
|
||||
where: { id: instructionId, business_id: businessId },
|
||||
include: instructionInclude,
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
private async assertPublishedInstructionExists(
|
||||
businessId: bigint,
|
||||
instructionId: bigint,
|
||||
) {
|
||||
const video = await this.prisma.instructions.findFirst({
|
||||
where: {
|
||||
id: instructionId,
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Instruction not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async serializeInstruction(
|
||||
video: InstructionWithRelations,
|
||||
options: {
|
||||
includeComments?: boolean;
|
||||
approvedCommentsOnly?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const content = this.asRecord(video.content);
|
||||
const metadata = this.asRecord(video.metadata);
|
||||
|
||||
const [categoryAssignment, commentData] = await Promise.all([
|
||||
this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId: video.business_id,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: video.id,
|
||||
},
|
||||
include: { category: true },
|
||||
}),
|
||||
options.includeComments
|
||||
? this.loadComments(
|
||||
video.business_id,
|
||||
video.id,
|
||||
options.approvedCommentsOnly,
|
||||
)
|
||||
: Promise.resolve({ commentCount: 0, comments: [] }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: video.id.toString(),
|
||||
businessId: video.business_id.toString(),
|
||||
title: video.title,
|
||||
titleFa: video.title_fa ?? video.title,
|
||||
titleEn: video.title_en,
|
||||
slug: video.slug,
|
||||
abstract: video.excerpt ?? '',
|
||||
descriptionHtml: (content.html as string | undefined) ?? '',
|
||||
videoProvider: video.video_provider ?? null,
|
||||
embedCode: video.embed_code ?? '',
|
||||
status: video.status,
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
authorId: video.author_id?.toString() ?? null,
|
||||
author: video.author
|
||||
? {
|
||||
id: video.author.id.toString(),
|
||||
firstName: video.author.firstName,
|
||||
lastName: video.author.lastName,
|
||||
email: video.author.email,
|
||||
}
|
||||
: null,
|
||||
coverImageUrl: video.media?.publicUrl ?? null,
|
||||
featuredMediaId: video.featured_media_id?.toString() ?? null,
|
||||
commentCount: commentData.commentCount,
|
||||
comments: commentData.comments,
|
||||
publishedAt: video.published_at,
|
||||
createdAt: video.created_at,
|
||||
updatedAt: video.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadComments(
|
||||
businessId: bigint,
|
||||
instructionId: bigint,
|
||||
approvedOnly?: boolean,
|
||||
) {
|
||||
const where: Prisma.CommentWhereInput = {
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
entityId: instructionId,
|
||||
...(approvedOnly ? { isApproved: true } : {}),
|
||||
};
|
||||
|
||||
const [commentCount, comments] = await Promise.all([
|
||||
this.prisma.comment.count({ where }),
|
||||
this.prisma.comment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: approvedOnly ? 50 : undefined,
|
||||
include: { approver: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
commentCount,
|
||||
comments: comments.map((item) => this.serializeComment(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeComment(
|
||||
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: comment.id.toString(),
|
||||
businessId: comment.businessId.toString(),
|
||||
entityType: comment.entityType,
|
||||
entityId: comment.entityId.toString(),
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
text: comment.text,
|
||||
isApproved: comment.isApproved,
|
||||
approvedAt: comment.approvedAt,
|
||||
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||
approver: comment.approver
|
||||
? {
|
||||
id: comment.approver.id.toString(),
|
||||
firstName: comment.approver.firstName,
|
||||
lastName: comment.approver.lastName,
|
||||
}
|
||||
: null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private buildContent(descriptionHtml?: string) {
|
||||
return {
|
||||
html: descriptionHtml ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private buildMetadata(tags?: string[]) {
|
||||
return {
|
||||
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async assertMediaBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
mediaId: bigint,
|
||||
) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
});
|
||||
if (!media) {
|
||||
throw new BadRequestException('Media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCategoryBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
categoryId: bigint,
|
||||
) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.instruction,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
throw new BadRequestException('Instruction category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAuthorBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
authorId: bigint,
|
||||
) {
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { businessId, userId: authorId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new BadRequestException(
|
||||
'Author must be a team member of this business',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUniqueInstructionSlug(
|
||||
businessId: bigint,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.instructions.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,6 +897,8 @@ export class OrdersService {
|
||||
amount: Number(transaction.amount),
|
||||
posType: transaction.posType,
|
||||
gatewayType: transaction.gatewayType,
|
||||
gatewayRef: transaction.gatewayRef ?? null,
|
||||
gatewayTrackId: transaction.gatewayTrackId ?? null,
|
||||
transferAccount: transaction.transferAccount,
|
||||
transferRefNumber: transaction.transferRefNumber,
|
||||
notes: transaction.notes,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { EPaymentSettings } from '../../business-settings/business-settings.types';
|
||||
import { isGatewayReady } from '../../business-settings/business-settings.util';
|
||||
import type {
|
||||
GatewayCredentials,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentResult,
|
||||
PaymentGatewayAdapter,
|
||||
VerifyPaymentInput,
|
||||
VerifyPaymentResult,
|
||||
} from './payment-gateway.types';
|
||||
|
||||
const MELLAT_SOAP_URL =
|
||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw';
|
||||
const MELLAT_PAYMENT_URL =
|
||||
'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||
|
||||
/** ResCode values that mean the payment was already verified successfully. */
|
||||
const MELLAT_ALREADY_VERIFIED = new Set(['43']);
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function extractSoapReturn(xml: string): string {
|
||||
const match = xml.match(/<return[^>]*>([\s\S]*?)<\/return>/i);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function readPayloadString(
|
||||
payload: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MellatGateway implements PaymentGatewayAdapter {
|
||||
readonly id = 'mellat' as const;
|
||||
private readonly logger = new Logger(MellatGateway.name);
|
||||
|
||||
isConfigured(settings: EPaymentSettings): boolean {
|
||||
return isGatewayReady(settings, 'mellat');
|
||||
}
|
||||
|
||||
async initiate(
|
||||
credentials: GatewayCredentials,
|
||||
input: InitiatePaymentInput,
|
||||
): Promise<InitiatePaymentResult> {
|
||||
const mellat = credentials.mellat;
|
||||
const now = new Date();
|
||||
const localDate = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('');
|
||||
const localTime = [
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('');
|
||||
|
||||
// Mellat amount is Rials; Meshkee stores IRT (Toman).
|
||||
const amountRials = Math.round(input.amountIrt * 10);
|
||||
|
||||
const raw = await this.soapCall('bpPayRequest', {
|
||||
terminalId: mellat.terminalId,
|
||||
userName: mellat.username,
|
||||
userPassword: mellat.password,
|
||||
orderId: input.transactionId.toString(),
|
||||
amount: String(amountRials),
|
||||
localDate,
|
||||
localTime,
|
||||
additionalData: input.description?.slice(0, 1000) ?? '',
|
||||
callBackUrl: input.callbackUrl,
|
||||
payerId: '0',
|
||||
});
|
||||
|
||||
const [resCode, refId = ''] = raw.split(',', 2);
|
||||
if (resCode !== '0' || !refId) {
|
||||
this.logger.warn(
|
||||
`bpPayRequest failed business=${input.businessId} order=${input.orderId} code=${resCode}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Mellat payment init failed (code ${resCode || 'unknown'})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayRef: refId,
|
||||
redirect: {
|
||||
method: 'POST',
|
||||
url: MELLAT_PAYMENT_URL,
|
||||
fields: { RefId: refId },
|
||||
},
|
||||
meta: { resCode, amountRials },
|
||||
};
|
||||
}
|
||||
|
||||
async verify(
|
||||
credentials: GatewayCredentials,
|
||||
input: VerifyPaymentInput,
|
||||
): Promise<VerifyPaymentResult> {
|
||||
const mellat = credentials.mellat;
|
||||
const resCode = readPayloadString(input.callbackPayload, 'ResCode', 'resCode');
|
||||
const saleOrderId = readPayloadString(
|
||||
input.callbackPayload,
|
||||
'SaleOrderId',
|
||||
'saleOrderId',
|
||||
);
|
||||
const saleReferenceId = readPayloadString(
|
||||
input.callbackPayload,
|
||||
'SaleReferenceId',
|
||||
'saleReferenceId',
|
||||
);
|
||||
const refId = readPayloadString(input.callbackPayload, 'RefId', 'refId');
|
||||
|
||||
if (resCode !== '0') {
|
||||
return {
|
||||
success: false,
|
||||
resCode: resCode || 'unknown',
|
||||
message: `Mellat payment declined (ResCode ${resCode || 'unknown'})`,
|
||||
meta: { saleOrderId, saleReferenceId, refId },
|
||||
};
|
||||
}
|
||||
|
||||
if (!saleOrderId || !saleReferenceId) {
|
||||
return {
|
||||
success: false,
|
||||
resCode,
|
||||
message: 'Mellat callback missing SaleOrderId or SaleReferenceId',
|
||||
meta: { saleOrderId, saleReferenceId, refId },
|
||||
};
|
||||
}
|
||||
|
||||
const verifyRaw = await this.soapCall('bpVerifyRequest', {
|
||||
terminalId: mellat.terminalId,
|
||||
userName: mellat.username,
|
||||
userPassword: mellat.password,
|
||||
orderId: saleOrderId,
|
||||
saleOrderId,
|
||||
saleReferenceId,
|
||||
});
|
||||
|
||||
if (verifyRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(verifyRaw)) {
|
||||
return {
|
||||
success: false,
|
||||
resCode: verifyRaw,
|
||||
trackId: saleReferenceId,
|
||||
message: `Mellat verify failed (code ${verifyRaw})`,
|
||||
meta: { saleOrderId, saleReferenceId, refId, verifyRaw },
|
||||
};
|
||||
}
|
||||
|
||||
const settleRaw = await this.soapCall('bpSettleRequest', {
|
||||
terminalId: mellat.terminalId,
|
||||
userName: mellat.username,
|
||||
userPassword: mellat.password,
|
||||
orderId: saleOrderId,
|
||||
saleOrderId,
|
||||
saleReferenceId,
|
||||
});
|
||||
|
||||
if (settleRaw !== '0' && !MELLAT_ALREADY_VERIFIED.has(settleRaw)) {
|
||||
this.logger.warn(
|
||||
`bpSettleRequest non-success business=${input.businessId} code=${settleRaw} saleRef=${saleReferenceId}`,
|
||||
);
|
||||
// Payment was verified; settle failure is logged but treated as success
|
||||
// so the customer is not charged twice on retry.
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
resCode: '0',
|
||||
trackId: saleReferenceId,
|
||||
message: 'Payment verified',
|
||||
meta: {
|
||||
saleOrderId,
|
||||
saleReferenceId,
|
||||
refId,
|
||||
verifyRaw,
|
||||
settleRaw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async soapCall(
|
||||
method: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const inner = Object.entries(params)
|
||||
.map(([key, value]) => `<${key}>${escapeXml(value)}</${key}>`)
|
||||
.join('');
|
||||
|
||||
const body =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ` +
|
||||
`xmlns:int="http://interfaces.core.sw.bps.com/">` +
|
||||
`<soapenv:Header/>` +
|
||||
`<soapenv:Body>` +
|
||||
`<int:${method}>${inner}</int:${method}>` +
|
||||
`</soapenv:Body>` +
|
||||
`</soapenv:Envelope>`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(MELLAT_SOAP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: '',
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Mellat SOAP network error (${method})`, err);
|
||||
throw new BadGatewayException('Unable to reach Mellat payment gateway');
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
this.logger.error(
|
||||
`Mellat SOAP HTTP ${response.status} (${method}): ${text.slice(0, 400)}`,
|
||||
);
|
||||
throw new BadGatewayException('Mellat payment gateway returned an error');
|
||||
}
|
||||
|
||||
return extractSoapReturn(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
EPaymentSettings,
|
||||
MellatGatewaySettings,
|
||||
PaymentGatewayId,
|
||||
} from '../../business-settings/business-settings.types';
|
||||
|
||||
export type PaymentRedirect = {
|
||||
method: 'GET' | 'POST';
|
||||
url: string;
|
||||
/** Form fields for POST redirects (e.g. Mellat RefId). */
|
||||
fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type InitiatePaymentInput = {
|
||||
businessId: bigint;
|
||||
orderId: bigint;
|
||||
/** Used as gateway order id when the provider requires a numeric id. */
|
||||
transactionId: bigint;
|
||||
amountIrt: number;
|
||||
callbackUrl: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type InitiatePaymentResult = {
|
||||
redirect: PaymentRedirect;
|
||||
gatewayRef: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type VerifyPaymentInput = {
|
||||
businessId: bigint;
|
||||
orderId: bigint;
|
||||
transactionId: bigint;
|
||||
amountIrt: number;
|
||||
gatewayRef: string | null;
|
||||
callbackPayload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type VerifyPaymentResult = {
|
||||
success: boolean;
|
||||
trackId?: string;
|
||||
resCode?: string;
|
||||
message?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GatewayCredentials = {
|
||||
mellat: MellatGatewaySettings;
|
||||
};
|
||||
|
||||
export interface PaymentGatewayAdapter {
|
||||
readonly id: PaymentGatewayId;
|
||||
|
||||
isConfigured(settings: EPaymentSettings): boolean;
|
||||
|
||||
initiate(
|
||||
credentials: GatewayCredentials,
|
||||
input: InitiatePaymentInput,
|
||||
): Promise<InitiatePaymentResult>;
|
||||
|
||||
verify(
|
||||
credentials: GatewayCredentials,
|
||||
input: VerifyPaymentInput,
|
||||
): Promise<VerifyPaymentResult>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||
import { MellatGateway } from './gateways/mellat.gateway';
|
||||
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentGatewayRegistry {
|
||||
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
|
||||
|
||||
constructor(mellat: MellatGateway) {
|
||||
this.adapters = new Map([[mellat.id, mellat]]);
|
||||
}
|
||||
|
||||
get(gatewayId: string): PaymentGatewayAdapter {
|
||||
const adapter = this.adapters.get(gatewayId as PaymentGatewayId);
|
||||
if (!adapter) {
|
||||
throw new BadRequestException(
|
||||
`Payment gateway "${gatewayId}" is not supported`,
|
||||
);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
listIds(): PaymentGatewayId[] {
|
||||
return [...this.adapters.keys()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
/**
|
||||
* Public bank callback + optional status helpers.
|
||||
* Bank gateways POST/GET here without JWT.
|
||||
*/
|
||||
@Controller('businesses/:businessId/payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Get('methods')
|
||||
methods(@Param('businessId') businessId: string) {
|
||||
return this.payments.getPublicMethods(BigInt(businessId));
|
||||
}
|
||||
|
||||
@Post(':gateway/callback')
|
||||
async callbackPost(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('gateway') gateway: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const result = await this.payments.handleGatewayCallback(
|
||||
businessId,
|
||||
gateway,
|
||||
body ?? {},
|
||||
);
|
||||
return res.redirect(303, result.redirectUrl);
|
||||
}
|
||||
|
||||
@Get(':gateway/callback')
|
||||
async callbackGet(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('gateway') gateway: string,
|
||||
@Query() query: Record<string, unknown>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const result = await this.payments.handleGatewayCallback(
|
||||
businessId,
|
||||
gateway,
|
||||
query ?? {},
|
||||
);
|
||||
return res.redirect(303, result.redirectUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { MellatGateway } from './gateways/mellat.gateway';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@Module({
|
||||
imports: [BusinessSettingsModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [MellatGateway, PaymentGatewayRegistry, PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
PAYMENT_GATEWAY_IDS,
|
||||
type PaymentGatewayId,
|
||||
} from '../business-settings/business-settings.types';
|
||||
import {
|
||||
isGatewayReady,
|
||||
listPublicPaymentGateways,
|
||||
} from '../business-settings/business-settings.util';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
import type { PaymentRedirect } from './gateways/payment-gateway.types';
|
||||
|
||||
type GatewayMeta = {
|
||||
returnUrl?: string;
|
||||
initiatedAt?: string;
|
||||
callback?: Record<string, unknown>;
|
||||
verify?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asGatewayMeta(value: unknown): GatewayMeta {
|
||||
return isRecord(value) ? (value as GatewayMeta) : {};
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params: Record<string, string>) {
|
||||
const target = new URL(url);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
target.searchParams.set(key, value);
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function isSafeReturnUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: BusinessSettingsService,
|
||||
private readonly gateways: PaymentGatewayRegistry,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getPublicMethods(businessId: bigint) {
|
||||
const normalized = await this.settings.getNormalizedSettings(businessId);
|
||||
return listPublicPaymentGateways(normalized.store.ePayment);
|
||||
}
|
||||
|
||||
resolveGatewayType(
|
||||
requested: string | undefined,
|
||||
ePayment: ReturnType<typeof listPublicPaymentGateways>,
|
||||
): PaymentGatewayId {
|
||||
const requestedId = requested?.trim().toLowerCase() || '';
|
||||
if (requestedId) {
|
||||
if (!(PAYMENT_GATEWAY_IDS as readonly string[]).includes(requestedId)) {
|
||||
throw new BadRequestException(
|
||||
`Unknown payment gateway "${requestedId}"`,
|
||||
);
|
||||
}
|
||||
if (!ePayment.gateways.some((g) => g.id === requestedId)) {
|
||||
throw new BadRequestException(
|
||||
`Payment gateway "${requestedId}" is not enabled for this store`,
|
||||
);
|
||||
}
|
||||
return requestedId as PaymentGatewayId;
|
||||
}
|
||||
|
||||
if (!ePayment.defaultGateway) {
|
||||
throw new BadRequestException('No online payment gateway is configured');
|
||||
}
|
||||
return ePayment.defaultGateway;
|
||||
}
|
||||
|
||||
async initiateForCheckout(input: {
|
||||
businessId: bigint;
|
||||
order: {
|
||||
id: string;
|
||||
total: number;
|
||||
transactions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
gatewayType: string | null;
|
||||
}>;
|
||||
};
|
||||
gatewayType: PaymentGatewayId;
|
||||
returnUrl: string;
|
||||
}): Promise<{
|
||||
transactionId: string;
|
||||
gatewayType: PaymentGatewayId;
|
||||
redirect: PaymentRedirect;
|
||||
}> {
|
||||
if (!isSafeReturnUrl(input.returnUrl)) {
|
||||
throw new BadRequestException(
|
||||
'returnUrl must be an absolute http(s) URL',
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = await this.settings.getNormalizedSettings(
|
||||
input.businessId,
|
||||
);
|
||||
const ePayment = normalized.store.ePayment;
|
||||
|
||||
if (!isGatewayReady(ePayment, input.gatewayType)) {
|
||||
throw new BadRequestException(
|
||||
`Payment gateway "${input.gatewayType}" is not ready`,
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = (input.order.transactions ?? []).find(
|
||||
(row) =>
|
||||
row.type === 'e_payment_gate' &&
|
||||
row.status === 'pending' &&
|
||||
row.gatewayType === input.gatewayType,
|
||||
);
|
||||
|
||||
if (!transaction) {
|
||||
throw new BadRequestException(
|
||||
'Pending e-payment transaction not found for this order',
|
||||
);
|
||||
}
|
||||
|
||||
const adapter = this.gateways.get(input.gatewayType);
|
||||
const callbackUrl = this.buildCallbackUrl(
|
||||
input.businessId,
|
||||
input.gatewayType,
|
||||
);
|
||||
|
||||
const result = await adapter.initiate(
|
||||
{ mellat: ePayment.gateways.mellat },
|
||||
{
|
||||
businessId: input.businessId,
|
||||
orderId: BigInt(input.order.id),
|
||||
transactionId: BigInt(transaction.id),
|
||||
amountIrt: input.order.total,
|
||||
callbackUrl,
|
||||
description: `Order ${input.order.id}`,
|
||||
},
|
||||
);
|
||||
|
||||
const meta: GatewayMeta = {
|
||||
returnUrl: input.returnUrl,
|
||||
initiatedAt: new Date().toISOString(),
|
||||
...(result.meta ?? {}),
|
||||
};
|
||||
|
||||
await this.prisma.transaction.update({
|
||||
where: { id: BigInt(transaction.id) },
|
||||
data: {
|
||||
gatewayRef: result.gatewayRef,
|
||||
gatewayMeta: meta as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
transactionId: transaction.id,
|
||||
gatewayType: input.gatewayType,
|
||||
redirect: result.redirect,
|
||||
};
|
||||
}
|
||||
|
||||
async handleGatewayCallback(
|
||||
businessIdRaw: string,
|
||||
gatewayTypeRaw: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const gatewayType = gatewayTypeRaw.trim().toLowerCase();
|
||||
const adapter = this.gateways.get(gatewayType);
|
||||
|
||||
const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId');
|
||||
const refId = this.readString(payload, 'RefId', 'refId');
|
||||
|
||||
const transaction = await this.findCallbackTransaction(
|
||||
businessId,
|
||||
gatewayType,
|
||||
saleOrderId,
|
||||
refId,
|
||||
);
|
||||
|
||||
if (!transaction) {
|
||||
throw new NotFoundException('Payment transaction not found');
|
||||
}
|
||||
|
||||
const meta = asGatewayMeta(transaction.gatewayMeta);
|
||||
const returnUrl =
|
||||
typeof meta.returnUrl === 'string' && isSafeReturnUrl(meta.returnUrl)
|
||||
? meta.returnUrl
|
||||
: null;
|
||||
|
||||
const failRedirect = (status: string, message?: string) => {
|
||||
if (!returnUrl) {
|
||||
throw new BadRequestException(
|
||||
message || 'Payment failed and no returnUrl was stored',
|
||||
);
|
||||
}
|
||||
return {
|
||||
redirectUrl: appendQuery(returnUrl, {
|
||||
status,
|
||||
orderId: transaction.orderId?.toString() ?? '',
|
||||
transactionId: transaction.id.toString(),
|
||||
...(message ? { message } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
if (transaction.status === 'completed') {
|
||||
return failRedirect('success');
|
||||
}
|
||||
|
||||
if (transaction.status === 'failed' || transaction.status === 'refunded') {
|
||||
return failRedirect('failed', 'Payment already marked as failed');
|
||||
}
|
||||
|
||||
const settings = await this.settings.getNormalizedSettings(businessId);
|
||||
if (!isGatewayReady(settings.store.ePayment, gatewayType as PaymentGatewayId)) {
|
||||
await this.markFailed(transaction.id, {
|
||||
...meta,
|
||||
callback: payload,
|
||||
error: 'Gateway no longer configured',
|
||||
});
|
||||
return failRedirect('failed', 'Payment gateway is not configured');
|
||||
}
|
||||
|
||||
if (!transaction.orderId) {
|
||||
await this.markFailed(transaction.id, {
|
||||
...meta,
|
||||
callback: payload,
|
||||
error: 'Transaction has no order',
|
||||
});
|
||||
return failRedirect('failed', 'Order missing for payment');
|
||||
}
|
||||
|
||||
const verify = await adapter.verify(
|
||||
{ mellat: settings.store.ePayment.gateways.mellat },
|
||||
{
|
||||
businessId,
|
||||
orderId: transaction.orderId,
|
||||
transactionId: transaction.id,
|
||||
amountIrt: Number(transaction.amount),
|
||||
gatewayRef: transaction.gatewayRef,
|
||||
callbackPayload: payload,
|
||||
},
|
||||
);
|
||||
|
||||
if (!verify.success) {
|
||||
await this.markFailed(transaction.id, {
|
||||
...meta,
|
||||
callback: payload,
|
||||
verify: verify.meta ?? { message: verify.message, resCode: verify.resCode },
|
||||
});
|
||||
this.logger.warn(
|
||||
`Payment failed business=${businessId} tx=${transaction.id} code=${verify.resCode}`,
|
||||
);
|
||||
return failRedirect('failed', verify.message || 'Payment failed');
|
||||
}
|
||||
|
||||
await this.prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
gatewayTrackId: verify.trackId ?? transaction.gatewayTrackId,
|
||||
gatewayMeta: {
|
||||
...meta,
|
||||
callback: payload,
|
||||
verify: verify.meta ?? {},
|
||||
completedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
||||
if (!returnUrl) {
|
||||
throw new BadRequestException(
|
||||
'Payment completed but no returnUrl was stored',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: appendQuery(returnUrl, {
|
||||
status: 'success',
|
||||
orderId: transaction.orderId.toString(),
|
||||
transactionId: transaction.id.toString(),
|
||||
...(verify.trackId ? { trackId: verify.trackId } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildCallbackUrl(businessId: bigint, gatewayType: string) {
|
||||
const base =
|
||||
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
|
||||
`http://localhost:${this.config.get<string>('PORT') || '3000'}`;
|
||||
|
||||
return `${base}/api/v1/businesses/${businessId.toString()}/payments/${gatewayType}/callback`;
|
||||
}
|
||||
|
||||
private async findCallbackTransaction(
|
||||
businessId: bigint,
|
||||
gatewayType: string,
|
||||
saleOrderId: string,
|
||||
refId: string,
|
||||
) {
|
||||
if (saleOrderId && /^\d+$/.test(saleOrderId)) {
|
||||
const byId = await this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
id: BigInt(saleOrderId),
|
||||
businessId,
|
||||
type: 'e_payment_gate',
|
||||
gatewayType,
|
||||
},
|
||||
});
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
if (refId) {
|
||||
return this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
type: 'e_payment_gate',
|
||||
gatewayType,
|
||||
gatewayRef: refId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
||||
await this.prisma.transaction.update({
|
||||
where: { id: transactionId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
gatewayMeta: {
|
||||
...meta,
|
||||
failedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private readString(
|
||||
payload: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ContentStatus, VideoProvider } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListVideosDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
provider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class ListPublicVideosDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
provider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class CreateVideoDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
/** @deprecated use titleFa */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string;
|
||||
|
||||
@IsEnum(VideoProvider)
|
||||
videoProvider!: VideoProvider;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
embedCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class UpdateVideoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string | null;
|
||||
|
||||
/** @deprecated use titleFa */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoProvider)
|
||||
videoProvider?: VideoProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
embedCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class CreateVideoCommentDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
authorName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { VideosService } from './videos.service';
|
||||
import {
|
||||
CreateVideoCommentDto,
|
||||
CreateVideoDto,
|
||||
ListPublicVideosDto,
|
||||
ListVideosDto,
|
||||
UpdateVideoDto,
|
||||
} from './dto/video.dto';
|
||||
|
||||
@Controller('businesses/:businessId/videos')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class VideosController {
|
||||
constructor(private readonly service: VideosService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('videos.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListVideosDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':videoId')
|
||||
@RequireBusinessPermission('videos.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('videoId') videoId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, videoId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('videos.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateVideoDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':videoId')
|
||||
@RequireBusinessPermission('videos.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('videoId') videoId: string,
|
||||
@Body() dto: UpdateVideoDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, videoId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':videoId')
|
||||
@RequireBusinessPermission('videos.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('videoId') videoId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, videoId, user);
|
||||
}
|
||||
|
||||
@Get(':videoId/comments')
|
||||
@RequireBusinessPermission('comments.read')
|
||||
listComments(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('videoId') videoId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listCommentsAdmin(businessId, videoId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/videos')
|
||||
export class PublicVideosController {
|
||||
constructor(private readonly service: VideosService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':videoId/comments')
|
||||
listComments(@Param('host') host: string, @Param('videoId') videoId: string) {
|
||||
return this.service.listCommentsPublic(host, videoId);
|
||||
}
|
||||
|
||||
@Post(':videoId/comments')
|
||||
createComment(
|
||||
@Param('host') host: string,
|
||||
@Param('videoId') videoId: string,
|
||||
@Body() dto: CreateVideoCommentDto,
|
||||
) {
|
||||
return this.service.createCommentPublic(host, videoId, dto);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { PublicVideosController, VideosController } from './videos.controller';
|
||||
import { VideosService } from './videos.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||
controllers: [VideosController, PublicVideosController],
|
||||
providers: [VideosService],
|
||||
})
|
||||
export class VideosModule {}
|
||||
@@ -0,0 +1,825 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContentStatus,
|
||||
MediaEntityType,
|
||||
Prisma,
|
||||
VideoProvider,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateVideoCommentDto,
|
||||
CreateVideoDto,
|
||||
ListPublicVideosDto,
|
||||
ListVideosDto,
|
||||
UpdateVideoDto,
|
||||
} from './dto/video.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'video'
|
||||
);
|
||||
}
|
||||
|
||||
type VideoWithRelations = Prisma.videosGetPayload<{
|
||||
include: {
|
||||
media: true;
|
||||
author: {
|
||||
select: { id: true; firstName: true; lastName: true; email: true };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const videoInclude = {
|
||||
media: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.videosInclude;
|
||||
|
||||
@Injectable()
|
||||
export class VideosService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListVideosDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'videos.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.videos.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: videoInclude,
|
||||
}),
|
||||
this.prisma.videos.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) => this.serializeVideo(item, { includeComments: true })),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'videos.read');
|
||||
|
||||
const video = await this.findVideoOrThrow(businessId, videoId);
|
||||
|
||||
return {
|
||||
video: await this.serializeVideo(video, { includeComments: true }),
|
||||
};
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateVideoDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'videos.create');
|
||||
|
||||
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
|
||||
const titleEn = dto.titleEn?.trim() || null;
|
||||
if (!titleFa) {
|
||||
throw new BadRequestException('titleFa is required');
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.slug ?? slugify(titleEn || titleFa),
|
||||
);
|
||||
|
||||
const status = dto.status ?? ContentStatus.published;
|
||||
const featuredMediaId = dto.featuredMediaId
|
||||
? BigInt(dto.featuredMediaId)
|
||||
: null;
|
||||
|
||||
if (featuredMediaId) {
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
|
||||
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
|
||||
if (dto.categoryId) {
|
||||
await this.assertCategoryBelongsToBusiness(
|
||||
businessId,
|
||||
BigInt(dto.categoryId),
|
||||
);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const video = await tx.videos.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
author_id: authorId,
|
||||
title: titleFa,
|
||||
title_fa: titleFa,
|
||||
title_en: titleEn,
|
||||
slug,
|
||||
excerpt: dto.abstract?.trim() || null,
|
||||
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
|
||||
video_provider: dto.videoProvider,
|
||||
embed_code: dto.embedCode.trim(),
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: videoInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: video.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return video;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Video created successfully',
|
||||
video: await this.serializeVideo(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
videoIdRaw: string,
|
||||
dto: UpdateVideoDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'videos.update');
|
||||
|
||||
const existing = await this.prisma.videos.findFirst({
|
||||
where: { id: videoId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
|
||||
const nextTitleFa =
|
||||
dto.titleFa !== undefined || dto.title !== undefined
|
||||
? this.resolveTitleFa(dto.titleFa, dto.title)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
(dto.titleFa !== undefined || dto.title !== undefined) &&
|
||||
!nextTitleFa
|
||||
) {
|
||||
throw new BadRequestException('titleFa is required');
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(businessId, dto.slug, videoId);
|
||||
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
|
||||
const slugBase =
|
||||
(dto.titleEn !== undefined
|
||||
? dto.titleEn?.trim() || null
|
||||
: existing.title_en) || nextTitleFa;
|
||||
slug = await this.ensureUniqueSlug(businessId, slugify(slugBase), videoId);
|
||||
}
|
||||
|
||||
let featuredMediaId: bigint | null | undefined = undefined;
|
||||
if (dto.featuredMediaId !== undefined) {
|
||||
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||
featuredMediaId = null;
|
||||
} else {
|
||||
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
let authorId: bigint | null | undefined = undefined;
|
||||
if (dto.authorId !== undefined) {
|
||||
if (dto.authorId === null || dto.authorId === '') {
|
||||
authorId = null;
|
||||
} else {
|
||||
authorId = BigInt(dto.authorId);
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
}
|
||||
}
|
||||
|
||||
const existingContent = this.asRecord(existing.content);
|
||||
const existingMetadata = this.asRecord(existing.metadata);
|
||||
|
||||
const nextContent = { ...existingContent };
|
||||
if (dto.descriptionHtml !== undefined) {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
if (
|
||||
dto.status === ContentStatus.published &&
|
||||
existing.status !== ContentStatus.published
|
||||
) {
|
||||
publishedAt = new Date();
|
||||
}
|
||||
if (dto.status !== ContentStatus.published) {
|
||||
publishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const video = await tx.videos.update({
|
||||
where: { id: videoId },
|
||||
data: {
|
||||
...(nextTitleFa !== undefined
|
||||
? { title: nextTitleFa, title_fa: nextTitleFa }
|
||||
: {}),
|
||||
...(dto.titleEn !== undefined
|
||||
? { title_en: dto.titleEn?.trim() || null }
|
||||
: {}),
|
||||
...(dto.abstract !== undefined
|
||||
? { excerpt: dto.abstract?.trim() || null }
|
||||
: {}),
|
||||
...(dto.videoProvider !== undefined
|
||||
? { video_provider: dto.videoProvider }
|
||||
: {}),
|
||||
...(dto.embedCode !== undefined
|
||||
? { embed_code: dto.embedCode.trim() }
|
||||
: {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(featuredMediaId !== undefined
|
||||
? { featured_media_id: featuredMediaId }
|
||||
: {}),
|
||||
...(authorId !== undefined ? { author_id: authorId } : {}),
|
||||
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
||||
slug,
|
||||
content: nextContent as Prisma.InputJsonValue,
|
||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||
},
|
||||
include: videoInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return video;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Video updated successfully',
|
||||
video: await this.serializeVideo(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, videoIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'videos.delete');
|
||||
|
||||
const existing = await this.prisma.videos.findFirst({
|
||||
where: { id: videoId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.comment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
},
|
||||
}),
|
||||
this.prisma.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
},
|
||||
}),
|
||||
this.prisma.videos.delete({ where: { id: videoId } }),
|
||||
]);
|
||||
|
||||
return { message: 'Video deleted successfully' };
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicVideosDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, {
|
||||
...query,
|
||||
status: ContentStatus.published,
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.videos.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: videoInclude,
|
||||
}),
|
||||
this.prisma.videos.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.serializeVideo(item, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPublicBySlug(host: string, slug: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const video = await this.prisma.videos.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: videoInclude,
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
|
||||
return {
|
||||
video: await this.serializeVideo(video, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsPublic(host: string, videoIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
|
||||
await this.assertPublishedVideoExists(businessId, videoId);
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
isApproved: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
async createCommentPublic(
|
||||
host: string,
|
||||
videoIdRaw: string,
|
||||
dto: CreateVideoCommentDto,
|
||||
) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
|
||||
await this.assertPublishedVideoExists(businessId, videoId);
|
||||
|
||||
const autoApprove =
|
||||
await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||
const approvedAt = autoApprove ? new Date() : null;
|
||||
|
||||
const created = await this.prisma.comment.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
authorName: dto.authorName.trim(),
|
||||
authorEmail: dto.authorEmail?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
isApproved: autoApprove,
|
||||
approvedAt,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return {
|
||||
comment: this.serializeComment(created),
|
||||
message: autoApprove
|
||||
? 'Comment submitted and is approved'
|
||||
: 'Comment submitted and is pending approval',
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsAdmin(
|
||||
businessIdRaw: string,
|
||||
videoIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const videoId = BigInt(videoIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||
|
||||
const video = await this.prisma.videos.findFirst({
|
||||
where: { id: videoId, business_id: businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
private resolveTitleFa(titleFa?: string, title?: string): string {
|
||||
return (titleFa ?? title ?? '').trim();
|
||||
}
|
||||
|
||||
private async buildWhere(
|
||||
businessId: bigint,
|
||||
query: (ListVideosDto | ListPublicVideosDto) & { status?: ContentStatus },
|
||||
): Promise<Prisma.videosWhereInput> {
|
||||
let entityIds: bigint[] | undefined;
|
||||
|
||||
if (query.categoryId) {
|
||||
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
categoryId: BigInt(query.categoryId),
|
||||
entityType: MediaEntityType.video,
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
|
||||
entityIds = assignments.map((item) => item.entityId);
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
business_id: businessId,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.provider ? { video_provider: query.provider } : {}),
|
||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||
...(query.title?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
title_fa: {
|
||||
contains: query.title.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
title_en: {
|
||||
contains: query.title.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async findVideoOrThrow(businessId: bigint, videoId: bigint) {
|
||||
const video = await this.prisma.videos.findFirst({
|
||||
where: { id: videoId, business_id: businessId },
|
||||
include: videoInclude,
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
private async assertPublishedVideoExists(
|
||||
businessId: bigint,
|
||||
videoId: bigint,
|
||||
) {
|
||||
const video = await this.prisma.videos.findFirst({
|
||||
where: {
|
||||
id: videoId,
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('Video not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async serializeVideo(
|
||||
video: VideoWithRelations,
|
||||
options: {
|
||||
includeComments?: boolean;
|
||||
approvedCommentsOnly?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const content = this.asRecord(video.content);
|
||||
const metadata = this.asRecord(video.metadata);
|
||||
|
||||
const [categoryAssignment, commentData] = await Promise.all([
|
||||
this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId: video.business_id,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: video.id,
|
||||
},
|
||||
include: { category: true },
|
||||
}),
|
||||
options.includeComments
|
||||
? this.loadComments(
|
||||
video.business_id,
|
||||
video.id,
|
||||
options.approvedCommentsOnly,
|
||||
)
|
||||
: Promise.resolve({ commentCount: 0, comments: [] }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: video.id.toString(),
|
||||
businessId: video.business_id.toString(),
|
||||
title: video.title,
|
||||
titleFa: video.title_fa ?? video.title,
|
||||
titleEn: video.title_en,
|
||||
slug: video.slug,
|
||||
abstract: video.excerpt ?? '',
|
||||
descriptionHtml: (content.html as string | undefined) ?? '',
|
||||
videoProvider: video.video_provider,
|
||||
embedCode: video.embed_code,
|
||||
status: video.status,
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
authorId: video.author_id?.toString() ?? null,
|
||||
author: video.author
|
||||
? {
|
||||
id: video.author.id.toString(),
|
||||
firstName: video.author.firstName,
|
||||
lastName: video.author.lastName,
|
||||
email: video.author.email,
|
||||
}
|
||||
: null,
|
||||
coverImageUrl: video.media?.publicUrl ?? null,
|
||||
featuredMediaId: video.featured_media_id?.toString() ?? null,
|
||||
commentCount: commentData.commentCount,
|
||||
comments: commentData.comments,
|
||||
publishedAt: video.published_at,
|
||||
createdAt: video.created_at,
|
||||
updatedAt: video.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadComments(
|
||||
businessId: bigint,
|
||||
videoId: bigint,
|
||||
approvedOnly?: boolean,
|
||||
) {
|
||||
const where: Prisma.CommentWhereInput = {
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
entityId: videoId,
|
||||
...(approvedOnly ? { isApproved: true } : {}),
|
||||
};
|
||||
|
||||
const [commentCount, comments] = await Promise.all([
|
||||
this.prisma.comment.count({ where }),
|
||||
this.prisma.comment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: approvedOnly ? 50 : undefined,
|
||||
include: { approver: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
commentCount,
|
||||
comments: comments.map((item) => this.serializeComment(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeComment(
|
||||
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: comment.id.toString(),
|
||||
businessId: comment.businessId.toString(),
|
||||
entityType: comment.entityType,
|
||||
entityId: comment.entityId.toString(),
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
text: comment.text,
|
||||
isApproved: comment.isApproved,
|
||||
approvedAt: comment.approvedAt,
|
||||
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||
approver: comment.approver
|
||||
? {
|
||||
id: comment.approver.id.toString(),
|
||||
firstName: comment.approver.firstName,
|
||||
lastName: comment.approver.lastName,
|
||||
}
|
||||
: null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private buildContent(descriptionHtml?: string) {
|
||||
return {
|
||||
html: descriptionHtml ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private buildMetadata(tags?: string[]) {
|
||||
return {
|
||||
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async assertMediaBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
mediaId: bigint,
|
||||
) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
});
|
||||
if (!media) {
|
||||
throw new BadRequestException('Media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCategoryBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
categoryId: bigint,
|
||||
) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.video,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
throw new BadRequestException('Video category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAuthorBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
authorId: bigint,
|
||||
) {
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { businessId, userId: authorId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new BadRequestException(
|
||||
'Author must be a team member of this business',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.videos.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
||||
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."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2205
-491
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user