diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 3167dbc..21edaa6 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -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( diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 37c93c2..7ebe132 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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; }; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 58f0e49..07d4bf1 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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, diff --git a/src/cart/cart.service.ts b/src/cart/cart.service.ts index 9603fe9..43d2cac 100644 --- a/src/cart/cart.service.ts +++ b/src/cart/cart.service.ts @@ -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 { diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 2eef634..b2e4cda 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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 { + 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 { + 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[] { + const keep = new Set( + next + .filter((id): id is bigint => id != null) + .map((id) => id.toString()), + ); + const seen = new Set(); + 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, diff --git a/src/portfolios/portfolios.module.ts b/src/portfolios/portfolios.module.ts index 2482002..e112cd3 100644 --- a/src/portfolios/portfolios.module.ts +++ b/src/portfolios/portfolios.module.ts @@ -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], }) diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts index 9243f1a..dfb8e1e 100644 --- a/src/portfolios/portfolios.service.ts +++ b/src/portfolios/portfolios.service.ts @@ -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' }; } diff --git a/src/products/products.module.ts b/src/products/products.module.ts index c388559..70a0d3e 100644 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -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, diff --git a/src/products/products.service.ts b/src/products/products.service.ts index b21763e..853fe5f 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -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' }; } diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts index 1a84099..a860391 100644 --- a/src/user-products/user-products.service.ts +++ b/src/user-products/user-products.service.ts @@ -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) { diff --git a/src/workshops/workshops.module.ts b/src/workshops/workshops.module.ts index ef32f1c..e72af8f 100644 --- a/src/workshops/workshops.module.ts +++ b/src/workshops/workshops.module.ts @@ -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], }) diff --git a/src/workshops/workshops.service.ts b/src/workshops/workshops.service.ts index 08d087b..d66ef38 100644 --- a/src/workshops/workshops.service.ts +++ b/src/workshops/workshops.service.ts @@ -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' }; }