Fix e-payment settings payload, cart race, and media/content helpers.

Stop returning passwordSet (forbidNonWhitelisted), upsert carts safely, and harden related media/product/workshop flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-25 16:41:00 +03:30
co-authored by Cursor
parent ad6f9dcf1d
commit 20cc0bbd78
12 changed files with 220 additions and 42 deletions
@@ -10,7 +10,6 @@ import { BusinessSettings } from './business-settings.types';
import {
mergeBusinessSettings,
mergeEPaymentSettings,
mellatPasswordSet,
normalizeBusinessSettings,
normalizeEnabledBusinessModules,
normalizeEPaymentSettings,
@@ -241,23 +240,7 @@ export class BusinessSettingsService {
}
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),
},
},
},
},
};
return sanitizeBusinessSettingsForClient(settings);
}
private async assertPermission(
@@ -54,7 +54,7 @@ export type MellatGatewaySettings = {
enabled: boolean;
terminalId: string;
username: string;
/** Stored secret — redacted in API responses; use passwordSet instead. */
/** Stored secret — never returned in API responses (always empty string). */
password: string;
};
@@ -249,8 +249,6 @@ export function sanitizeBusinessSettingsForClient(
mellat: {
...mellat,
password: '',
// Clients treat non-empty passwordSet via a parallel field — see
// get/update response shaping in BusinessSettingsService.
},
},
},
@@ -258,10 +256,6 @@ export function sanitizeBusinessSettingsForClient(
};
}
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,
+5 -10
View File
@@ -279,21 +279,16 @@ export class CartService {
}
private async getOrCreateCart(businessId: bigint, userId: bigint) {
const existing = await this.prisma.cart.findUnique({
// Concurrent GETs (e.g. React Strict Mode) can race on create; upsert is safe.
const cart = await this.prisma.cart.upsert({
where: {
businessId_userId: { businessId, userId },
},
create: { businessId, userId },
update: {},
});
if (existing) {
return this.loadCart(existing.id);
}
const created = await this.prisma.cart.create({
data: { businessId, userId },
});
return this.loadCart(created.id);
return this.loadCart(cart.id);
}
private async loadCart(cartId: bigint): Promise<CartWithItems> {
+54 -1
View File
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MediaType } from '@prisma/client';
import { MediaEntityType, MediaType } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as path from 'path';
import sharp from 'sharp';
@@ -209,6 +209,7 @@ export class MediaService {
blogs,
videos,
instructions,
workshops,
brands,
userProducts,
sliders,
@@ -235,6 +236,10 @@ export class MediaService {
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
select: { featured_media_id: true },
}),
this.prisma.workshops.findMany({
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
select: { featured_media_id: true },
}),
this.prisma.brand.findMany({
where: { businessId, imageMediaId: { in: uniqueIds } },
select: { imageMediaId: true },
@@ -274,6 +279,7 @@ export class MediaService {
for (const row of blogs) addId(row.featured_media_id);
for (const row of videos) addId(row.featured_media_id);
for (const row of instructions) addId(row.featured_media_id);
for (const row of workshops) addId(row.featured_media_id);
for (const row of brands) addId(row.imageMediaId);
for (const row of userProducts) addId(row.featuredMediaId);
for (const row of sliders) addId(row.image_media_id);
@@ -304,6 +310,53 @@ export class MediaService {
return mediaRows.length;
}
async galleryMediaIds(
businessId: bigint,
entityType: MediaEntityType,
entityId: bigint,
): Promise<bigint[]> {
const rows = await this.prisma.mediaAttachment.findMany({
where: { businessId, entityType, entityId, isFeatured: false },
select: { mediaId: true },
});
return rows.map((row) => row.mediaId);
}
async allLinkedMediaIds(
businessId: bigint,
entityType: MediaEntityType,
entityId: bigint,
featuredMediaId?: bigint | null,
): Promise<bigint[]> {
const rows = await this.prisma.mediaAttachment.findMany({
where: { businessId, entityType, entityId },
select: { mediaId: true },
});
const ids = rows.map((row) => row.mediaId);
if (featuredMediaId != null) ids.push(featuredMediaId);
return ids;
}
idsNotIn(
previous: bigint[],
next: Array<bigint | null | undefined>,
): bigint[] {
const keep = new Set(
next
.filter((id): id is bigint => id != null)
.map((id) => id.toString()),
);
const seen = new Set<string>();
const dropped: bigint[] = [];
for (const id of previous) {
const key = id.toString();
if (keep.has(key) || seen.has(key)) continue;
seen.add(key);
dropped.push(id);
}
return dropped;
}
private async uploadOne(
businessId: bigint,
file: Express.Multer.File,
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { MediaModule } from '../media/media.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import {
@@ -10,7 +11,7 @@ import {
import { PortfoliosService } from './portfolios.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, SitemapModule, TenantModule],
imports: [AuthModule, BusinessSettingsModule, MediaModule, SitemapModule, TenantModule],
controllers: [PortfoliosController, PublicPortfoliosController],
providers: [PortfoliosService],
})
+33
View File
@@ -12,6 +12,7 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
@@ -56,6 +57,7 @@ export class PortfoliosService {
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
private readonly sitemap: SitemapService,
private readonly mediaService: MediaService,
) {}
async list(businessIdRaw: string, query: ListPortfoliosDto, actor: AuthUser) {
@@ -285,6 +287,13 @@ export class PortfoliosService {
? await this.nextSortOrder(businessId)
: undefined;
const previousGalleryIds = await this.mediaService.galleryMediaIds(
businessId,
MediaEntityType.portfolio,
portfolioId,
);
let nextGalleryIds: bigint[] | undefined;
const updated = await this.prisma.$transaction(async (tx) => {
const data: Prisma.portfoliosUncheckedUpdateInput = {
slug,
@@ -353,6 +362,7 @@ export class PortfoliosService {
businessId,
dto.galleryMediaIds,
);
nextGalleryIds = galleryMediaIds;
await this.syncGalleryAttachments(
tx,
businessId,
@@ -366,6 +376,21 @@ export class PortfoliosService {
await this.sitemap.invalidateForBusiness(businessId);
const dropped: bigint[] = [];
if (nextGalleryIds) {
dropped.push(
...this.mediaService.idsNotIn(previousGalleryIds, nextGalleryIds),
);
}
if (
featuredMediaId !== undefined &&
existing.featured_media_id != null &&
existing.featured_media_id !== featuredMediaId
) {
dropped.push(existing.featured_media_id);
}
await this.mediaService.deleteUnused(businessId, dropped);
return {
message: 'Portfolio updated successfully',
portfolio: await this.serializePortfolio(updated),
@@ -389,6 +414,13 @@ export class PortfoliosService {
throw new NotFoundException('Portfolio not found');
}
const mediaIds = await this.mediaService.allLinkedMediaIds(
businessId,
MediaEntityType.portfolio,
portfolioId,
existing.featured_media_id,
);
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
@@ -415,6 +447,7 @@ export class PortfoliosService {
]);
await this.sitemap.invalidateForBusiness(businessId);
await this.mediaService.deleteUnused(businessId, mediaIds);
return { message: 'Portfolio deleted successfully' };
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BrandsModule } from '../brands/brands.module';
import { CategoriesModule } from '../categories/categories.module';
import { MediaModule } from '../media/media.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { ProductAiService } from './product-ai.service';
@@ -11,7 +12,7 @@ import { ProductsController, PublicProductsController } from './products.control
import { ProductsService } from './products.service';
@Module({
imports: [AuthModule, BrandsModule, CategoriesModule, SitemapModule, TenantModule],
imports: [AuthModule, BrandsModule, CategoriesModule, MediaModule, SitemapModule, TenantModule],
controllers: [ProductsController, PublicProductsController],
providers: [
ProductsService,
+33
View File
@@ -8,6 +8,7 @@ import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BrandsService } from '../brands/brands.service';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
@@ -54,6 +55,7 @@ export class ProductsService {
private readonly brands: BrandsService,
private readonly tenant: TenantService,
private readonly sitemap: SitemapService,
private readonly mediaService: MediaService,
) {}
async list(businessIdRaw: string, query: ListProductsDto, actor: AuthUser) {
@@ -390,6 +392,13 @@ export class ProductsService {
}
}
const previousGalleryIds = await this.mediaService.galleryMediaIds(
businessId,
MediaEntityType.product,
productId,
);
let nextGalleryIds: bigint[] | undefined;
const updated = await this.prisma.$transaction(async (tx) => {
const product = await tx.product.update({
where: { id: productId },
@@ -440,6 +449,7 @@ export class ProductsService {
businessId,
dto.galleryMediaIds,
);
nextGalleryIds = galleryMediaIds;
await this.syncGalleryAttachments(
tx,
businessId,
@@ -453,6 +463,21 @@ export class ProductsService {
await this.sitemap.invalidateForBusiness(businessId);
const dropped: bigint[] = [];
if (nextGalleryIds) {
dropped.push(
...this.mediaService.idsNotIn(previousGalleryIds, nextGalleryIds),
);
}
if (
featuredMediaId !== undefined &&
existing.featuredMediaId != null &&
existing.featuredMediaId !== featuredMediaId
) {
dropped.push(existing.featuredMediaId);
}
await this.mediaService.deleteUnused(businessId, dropped);
return {
message: 'Product updated successfully',
product: await this.serializeProduct(updated),
@@ -472,6 +497,13 @@ export class ProductsService {
throw new NotFoundException('Product not found');
}
const mediaIds = await this.mediaService.allLinkedMediaIds(
businessId,
MediaEntityType.product,
productId,
existing.featuredMediaId,
);
await this.prisma.$transaction([
this.prisma.mediaAttachment.deleteMany({
where: {
@@ -491,6 +523,7 @@ export class ProductsService {
]);
await this.sitemap.invalidateForBusiness(businessId);
await this.mediaService.deleteUnused(businessId, mediaIds);
return { message: 'Product deleted successfully' };
}
+53 -3
View File
@@ -456,7 +456,13 @@ export class UserProductsService {
const existing = await this.prisma.userProduct.findFirst({
where: { id: productId, businessId, userId: actor.id },
select: { id: true, title: true, slug: true, metadata: true },
select: {
id: true,
title: true,
slug: true,
metadata: true,
featuredMediaId: true,
},
});
if (!existing) {
@@ -475,6 +481,7 @@ export class UserProductsService {
title: string;
slug: string;
metadata: unknown;
featuredMediaId: bigint | null;
},
) {
const titleFa = dto.titleFa.trim();
@@ -525,6 +532,12 @@ export class UserProductsService {
);
}
const previousGalleryIds = await this.mediaService.galleryMediaIds(
businessId,
MediaEntityType.user_product,
productId,
);
const updated = await this.prisma.$transaction(async (tx) => {
const product = await tx.userProduct.update({
where: { id: productId },
@@ -594,6 +607,20 @@ export class UserProductsService {
select: { id: true, name: true, nameFa: true },
});
const dropped: bigint[] = [];
if (galleryMediaIds !== null) {
dropped.push(
...this.mediaService.idsNotIn(previousGalleryIds, galleryMediaIds),
);
}
if (
existing.featuredMediaId != null &&
existing.featuredMediaId !== featuredMediaId
) {
dropped.push(existing.featuredMediaId);
}
await this.mediaService.deleteUnused(businessId, dropped);
return {
message: 'User product updated successfully',
product: this.serializeAdminListItem(updated, category ?? undefined),
@@ -619,14 +646,28 @@ export class UserProductsService {
const product = await this.prisma.userProduct.findFirst({
where: { id: productId, businessId, userId: actor.id },
select: { id: true },
select: { id: true, featuredMediaId: true },
});
if (!product) {
throw new NotFoundException('User product not found');
}
const mediaIds = await this.mediaService.allLinkedMediaIds(
businessId,
MediaEntityType.user_product,
productId,
product.featuredMediaId,
);
await this.prisma.$transaction(async (tx) => {
await tx.mediaAttachment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.user_product,
entityId: productId,
},
});
await tx.categoryAssignment.deleteMany({
where: {
businessId,
@@ -637,6 +678,8 @@ export class UserProductsService {
await tx.userProduct.delete({ where: { id: productId } });
});
await this.mediaService.deleteUnused(businessId, mediaIds);
return { message: 'User product deleted successfully' };
}
@@ -784,7 +827,14 @@ export class UserProductsService {
const existing = await this.prisma.userProduct.findFirst({
where: { id: productId, businessId },
select: { id: true, title: true, slug: true, metadata: true, userId: true },
select: {
id: true,
title: true,
slug: true,
metadata: true,
userId: true,
featuredMediaId: true,
},
});
if (!existing) {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MediaModule } from '../media/media.module';
import { TenantModule } from '../tenant/tenant.module';
import {
PublicWorkshopsController,
@@ -8,7 +9,7 @@ import {
import { WorkshopsService } from './workshops.service';
@Module({
imports: [AuthModule, TenantModule],
imports: [AuthModule, MediaModule, TenantModule],
controllers: [WorkshopsController, PublicWorkshopsController],
providers: [WorkshopsService],
})
+34
View File
@@ -13,6 +13,7 @@ import {
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -61,6 +62,7 @@ export class WorkshopsService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly mediaService: MediaService,
) {}
async list(businessIdRaw: string, query: ListWorkshopsDto, actor: AuthUser) {
@@ -278,6 +280,13 @@ export class WorkshopsService {
}
}
const previousGalleryIds = await this.mediaService.galleryMediaIds(
businessId,
MediaEntityType.workshop,
workshopId,
);
let nextGalleryIds: bigint[] | undefined;
const updated = await this.prisma.$transaction(async (tx) => {
const workshop = await tx.workshops.update({
where: { id: workshopId },
@@ -341,6 +350,7 @@ export class WorkshopsService {
businessId,
dto.galleryMediaIds,
);
nextGalleryIds = galleryMediaIds;
await this.syncGalleryAttachments(
tx,
businessId,
@@ -352,6 +362,21 @@ export class WorkshopsService {
return workshop;
});
const dropped: bigint[] = [];
if (nextGalleryIds) {
dropped.push(
...this.mediaService.idsNotIn(previousGalleryIds, nextGalleryIds),
);
}
if (
featuredMediaId !== undefined &&
existing.featured_media_id != null &&
existing.featured_media_id !== featuredMediaId
) {
dropped.push(existing.featured_media_id);
}
await this.mediaService.deleteUnused(businessId, dropped);
return {
message: 'Workshop updated successfully',
workshop: await this.serializeWorkshop(updated),
@@ -371,6 +396,13 @@ export class WorkshopsService {
throw new NotFoundException('Workshop not found');
}
const mediaIds = await this.mediaService.allLinkedMediaIds(
businessId,
MediaEntityType.workshop,
workshopId,
existing.featured_media_id,
);
await this.prisma.$transaction([
this.prisma.workshop_registrations.deleteMany({
where: { workshop_id: workshopId, business_id: businessId },
@@ -392,6 +424,8 @@ export class WorkshopsService {
this.prisma.workshops.delete({ where: { id: workshopId } }),
]);
await this.mediaService.deleteUnused(businessId, mediaIds);
return { message: 'Workshop deleted successfully' };
}