Align storefront/sitemap URLs with catalog products, add by-id public APIs, and include user-product detail views in the product visit chart. Co-authored-by: Cursor <cursoragent@cursor.com>
1747 lines
46 KiB
TypeScript
1747 lines
46 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
CityLevel,
|
|
ContentStatus,
|
|
MediaEntityType,
|
|
Prisma,
|
|
TechnicalFieldType,
|
|
} from '@prisma/client';
|
|
import { AuthUser } from '../auth/auth.types';
|
|
import { PermissionsService } from '../auth/permissions.service';
|
|
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
|
import {
|
|
applySeoMetaToMetadata,
|
|
normalizeSeoMetaInput,
|
|
readSeoMetaFromMetadata,
|
|
} from '../common/seo-meta.util';
|
|
import { MediaService } from '../media/media.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
CreateUserProductDto,
|
|
ListAdminUserProductsDto,
|
|
ListMyUserProductsDto,
|
|
ListPublicUserProductsDto,
|
|
UpdateUserProductDto,
|
|
UpdateUserProductStatusDto,
|
|
UserProductTechnicalValueDto,
|
|
} from './dto/user-product.dto';
|
|
import { TenantService } from '../tenant/tenant.service';
|
|
import { SitemapService } from '../sitemap/sitemap.service';
|
|
import { slugifyForUrl } from '../sitemap/seo-slug.util';
|
|
|
|
function buildUserProductSlug(titleFa: string, titleEn?: string | null): string {
|
|
return slugifyForUrl(
|
|
titleFa.trim() || titleEn?.trim() || '',
|
|
'user-product',
|
|
);
|
|
}
|
|
|
|
type FormField = {
|
|
id: string;
|
|
label: string;
|
|
key: string;
|
|
type: TechnicalFieldType;
|
|
isRequired: boolean;
|
|
sortOrder: number;
|
|
options: { id: string; label: string; value: string; sortOrder: number }[];
|
|
};
|
|
|
|
const userProductListInclude = {
|
|
featuredMedia: true,
|
|
city: true,
|
|
country: true,
|
|
} satisfies Prisma.UserProductInclude;
|
|
|
|
type UserProductListRow = Prisma.UserProductGetPayload<{
|
|
include: typeof userProductListInclude;
|
|
}>;
|
|
|
|
const userProductAdminListInclude = {
|
|
...userProductListInclude,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
firstNameEn: true,
|
|
lastNameEn: true,
|
|
cellNumber: true,
|
|
},
|
|
},
|
|
} satisfies Prisma.UserProductInclude;
|
|
|
|
type UserProductAdminListRow = Prisma.UserProductGetPayload<{
|
|
include: typeof userProductAdminListInclude;
|
|
}>;
|
|
|
|
const userProductDetailInclude = {
|
|
...userProductListInclude,
|
|
technicalFieldValues: {
|
|
include: {
|
|
selectedOptions: true,
|
|
},
|
|
},
|
|
} satisfies Prisma.UserProductInclude;
|
|
|
|
type UserProductDetailRow = Prisma.UserProductGetPayload<{
|
|
include: typeof userProductDetailInclude;
|
|
}>;
|
|
|
|
const userProductAdminDetailInclude = {
|
|
...userProductAdminListInclude,
|
|
technicalFieldValues: {
|
|
include: {
|
|
selectedOptions: true,
|
|
},
|
|
},
|
|
} satisfies Prisma.UserProductInclude;
|
|
|
|
type UserProductAdminDetailRow = Prisma.UserProductGetPayload<{
|
|
include: typeof userProductAdminDetailInclude;
|
|
}>;
|
|
|
|
@Injectable()
|
|
export class UserProductsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionsService,
|
|
private readonly technicalFormService: CategoryTechnicalFormService,
|
|
private readonly mediaService: MediaService,
|
|
private readonly tenant: TenantService,
|
|
private readonly sitemap: SitemapService,
|
|
) {}
|
|
|
|
async listPublic(host: string, query: ListPublicUserProductsDto) {
|
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
|
const businessId = business.id;
|
|
|
|
const page = query.page ?? 1;
|
|
const pageSize = Math.min(Math.max(query.pageSize ?? 12, 1), 100);
|
|
const skip = (page - 1) * pageSize;
|
|
const where = await this.buildPublicWhere(businessId, query);
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.userProduct.findMany({
|
|
where,
|
|
orderBy: [
|
|
{ publishedAt: 'desc' },
|
|
{ createdAt: 'desc' },
|
|
],
|
|
skip,
|
|
take: pageSize,
|
|
include: userProductListInclude,
|
|
}),
|
|
this.prisma.userProduct.count({ where }),
|
|
]);
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(
|
|
businessId,
|
|
items.map((item) => item.id),
|
|
);
|
|
|
|
const serialized = items.map((item) =>
|
|
this.serializeListItem(item, categoryByEntity.get(item.id.toString())),
|
|
);
|
|
|
|
const promotedOnly = query.promoted === true;
|
|
const ordered = promotedOnly
|
|
? serialized
|
|
: [
|
|
...serialized.filter((item) => item.promoted),
|
|
...serialized.filter((item) => !item.promoted),
|
|
];
|
|
|
|
return {
|
|
items: ordered,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async getPublicBySlug(host: string, slug: string) {
|
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: {
|
|
businessId: business.id,
|
|
slug,
|
|
status: ContentStatus.published,
|
|
},
|
|
include: userProductDetailInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.serializePublicDetail(business.id, product);
|
|
}
|
|
|
|
async getPublicById(host: string, productIdRaw: string) {
|
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
|
let productId: bigint;
|
|
try {
|
|
productId = BigInt(productIdRaw);
|
|
} catch {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: {
|
|
businessId: business.id,
|
|
id: productId,
|
|
status: ContentStatus.published,
|
|
},
|
|
include: userProductDetailInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.serializePublicDetail(business.id, product);
|
|
}
|
|
|
|
async getPublicTechnicalInfoBySlug(host: string, slug: string) {
|
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: {
|
|
businessId: business.id,
|
|
slug,
|
|
status: ContentStatus.published,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.getPublicTechnicalInfoForProduct(business.id, product.id);
|
|
}
|
|
|
|
async getPublicTechnicalInfoById(host: string, productIdRaw: string) {
|
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
|
let productId: bigint;
|
|
try {
|
|
productId = BigInt(productIdRaw);
|
|
} catch {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: {
|
|
businessId: business.id,
|
|
id: productId,
|
|
status: ContentStatus.published,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.getPublicTechnicalInfoForProduct(business.id, product.id);
|
|
}
|
|
|
|
private async serializePublicDetail(
|
|
businessId: bigint,
|
|
product: UserProductDetailRow,
|
|
) {
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
product.id,
|
|
]);
|
|
const gallery = await this.loadGalleryAttachments(businessId, product.id);
|
|
|
|
return {
|
|
product: this.serializeDetail(
|
|
product,
|
|
categoryByEntity.get(product.id.toString()),
|
|
gallery,
|
|
),
|
|
};
|
|
}
|
|
|
|
private async getPublicTechnicalInfoForProduct(
|
|
businessId: bigint,
|
|
productId: bigint,
|
|
) {
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
productId,
|
|
]);
|
|
const category = categoryByEntity.get(productId.toString());
|
|
if (!category) {
|
|
return {
|
|
form: null,
|
|
values: [],
|
|
message: 'User product has no category assigned',
|
|
};
|
|
}
|
|
|
|
const form = await this.technicalFormService.getFormForCategory(
|
|
businessId,
|
|
category.id,
|
|
);
|
|
if (!form) {
|
|
return { form: null, values: [] };
|
|
}
|
|
|
|
const detail = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId },
|
|
include: userProductDetailInclude,
|
|
});
|
|
if (!detail) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const serialized = this.serializeDetail(detail, category, []);
|
|
return {
|
|
form,
|
|
values: serialized.technicalValues,
|
|
};
|
|
}
|
|
|
|
async list(
|
|
businessIdRaw: string,
|
|
query: ListMyUserProductsDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
|
|
const page = query.page ?? 1;
|
|
const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100);
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: Prisma.UserProductWhereInput = {
|
|
businessId,
|
|
userId: actor.id,
|
|
};
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.userProduct.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
include: userProductListInclude,
|
|
}),
|
|
this.prisma.userProduct.count({ where }),
|
|
]);
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(
|
|
businessId,
|
|
items.map((item) => item.id),
|
|
);
|
|
|
|
return {
|
|
items: items.map((item) =>
|
|
this.serializeListItem(item, categoryByEntity.get(item.id.toString())),
|
|
),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async create(
|
|
businessIdRaw: string,
|
|
dto: CreateUserProductDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
return this.createForUser(businessId, dto, actor.id);
|
|
}
|
|
|
|
private async createForUser(
|
|
businessId: bigint,
|
|
dto: CreateUserProductDto,
|
|
userId: bigint,
|
|
) {
|
|
const titleFa = dto.titleFa.trim();
|
|
if (!titleFa) {
|
|
throw new BadRequestException('titleFa is required');
|
|
}
|
|
|
|
const categoryId = BigInt(dto.categoryId);
|
|
await this.assertProductCategory(businessId, categoryId);
|
|
|
|
const countryId = BigInt(dto.countryId);
|
|
const cityId = BigInt(dto.cityId);
|
|
await this.assertCountryAndCity(countryId, cityId);
|
|
|
|
let featuredMediaId: bigint | null = null;
|
|
if (dto.featuredMediaId) {
|
|
featuredMediaId = BigInt(dto.featuredMediaId);
|
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
|
}
|
|
|
|
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
|
businessId,
|
|
dto.galleryMediaIds ?? [],
|
|
);
|
|
|
|
const form = await this.technicalFormService.getFormForCategory(
|
|
businessId,
|
|
categoryId,
|
|
);
|
|
const technicalValues = dto.technicalValues ?? [];
|
|
this.validateTechnicalValues(form?.fields ?? [], technicalValues);
|
|
|
|
const slug = await this.ensureUniqueSlug(
|
|
businessId,
|
|
buildUserProductSlug(titleFa, dto.titleEn),
|
|
);
|
|
const priceCurrency = dto.priceCurrency ?? 'IRT';
|
|
const content: Prisma.InputJsonValue = {
|
|
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
|
};
|
|
const metadata = applySeoMetaToMetadata(
|
|
{
|
|
priceByExpert: dto.priceByExpert === true,
|
|
},
|
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
|
) as Prisma.InputJsonValue;
|
|
|
|
const created = await this.prisma.$transaction(async (tx) => {
|
|
const product = await tx.userProduct.create({
|
|
data: {
|
|
businessId,
|
|
userId,
|
|
title: titleFa,
|
|
slug,
|
|
description: dto.description?.trim() || null,
|
|
content,
|
|
price:
|
|
dto.price === undefined || dto.price === null
|
|
? null
|
|
: dto.price,
|
|
priceCurrency,
|
|
status: ContentStatus.draft,
|
|
featuredMediaId,
|
|
countryId,
|
|
cityId,
|
|
deliveryNote: dto.deliveryNote?.trim() || null,
|
|
condition: dto.condition,
|
|
technicalNotes: dto.technicalNotes?.trim() || null,
|
|
metadata,
|
|
},
|
|
include: userProductListInclude,
|
|
});
|
|
|
|
await tx.categoryAssignment.create({
|
|
data: {
|
|
businessId,
|
|
categoryId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: product.id,
|
|
},
|
|
});
|
|
|
|
await this.syncGalleryAttachments(
|
|
tx,
|
|
businessId,
|
|
product.id,
|
|
galleryMediaIds,
|
|
);
|
|
|
|
if (form?.fields.length) {
|
|
await this.saveTechnicalValues(
|
|
tx,
|
|
businessId,
|
|
product.id,
|
|
form.fields,
|
|
technicalValues,
|
|
);
|
|
}
|
|
|
|
return product;
|
|
});
|
|
|
|
const category = await this.prisma.category.findFirst({
|
|
where: { id: categoryId, businessId },
|
|
select: { id: true, name: true, nameFa: true },
|
|
});
|
|
|
|
const withUser = await this.prisma.userProduct.findFirst({
|
|
where: { id: created.id },
|
|
include: userProductAdminListInclude,
|
|
});
|
|
|
|
await this.sitemap.invalidateForBusiness(businessId);
|
|
|
|
return {
|
|
message: 'User product created successfully',
|
|
product: withUser
|
|
? this.serializeAdminListItem(withUser, category ?? undefined)
|
|
: this.serializeListItem(created, category ?? undefined),
|
|
};
|
|
}
|
|
|
|
async getOne(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId, userId: actor.id },
|
|
include: userProductDetailInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
product.id,
|
|
]);
|
|
const gallery = await this.loadGalleryAttachments(businessId, product.id);
|
|
|
|
return {
|
|
product: this.serializeDetail(
|
|
product,
|
|
categoryByEntity.get(product.id.toString()),
|
|
gallery,
|
|
),
|
|
};
|
|
}
|
|
|
|
async update(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
dto: UpdateUserProductDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
|
|
const existing = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId, userId: actor.id },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
metadata: true,
|
|
featuredMediaId: true,
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.updateExisting(businessId, productId, dto, existing);
|
|
}
|
|
|
|
private async updateExisting(
|
|
businessId: bigint,
|
|
productId: bigint,
|
|
dto: UpdateUserProductDto,
|
|
existing: {
|
|
id: bigint;
|
|
title: string;
|
|
slug: string;
|
|
metadata: unknown;
|
|
featuredMediaId: bigint | null;
|
|
},
|
|
) {
|
|
const titleFa = dto.titleFa.trim();
|
|
if (!titleFa) {
|
|
throw new BadRequestException('titleFa is required');
|
|
}
|
|
|
|
const categoryId = BigInt(dto.categoryId);
|
|
await this.assertProductCategory(businessId, categoryId);
|
|
|
|
const countryId = BigInt(dto.countryId);
|
|
const cityId = BigInt(dto.cityId);
|
|
await this.assertCountryAndCity(countryId, cityId);
|
|
|
|
let featuredMediaId: bigint | null = null;
|
|
if (dto.featuredMediaId) {
|
|
featuredMediaId = BigInt(dto.featuredMediaId);
|
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
|
}
|
|
|
|
const galleryMediaIds =
|
|
dto.galleryMediaIds !== undefined
|
|
? await this.resolveGalleryMediaIds(businessId, dto.galleryMediaIds)
|
|
: null;
|
|
|
|
const form = await this.technicalFormService.getFormForCategory(
|
|
businessId,
|
|
categoryId,
|
|
);
|
|
const technicalValues = dto.technicalValues ?? [];
|
|
this.validateTechnicalValues(form?.fields ?? [], technicalValues);
|
|
|
|
const existingMetadata = this.asRecord(existing.metadata);
|
|
const content: Prisma.InputJsonValue = {
|
|
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
|
};
|
|
const metadata = applySeoMetaToMetadata(
|
|
{
|
|
...existingMetadata,
|
|
priceByExpert: dto.priceByExpert === true,
|
|
},
|
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
|
) as Prisma.InputJsonValue;
|
|
|
|
let slug = existing.slug;
|
|
if (titleFa !== existing.title) {
|
|
slug = await this.ensureUniqueSlug(
|
|
businessId,
|
|
buildUserProductSlug(titleFa, dto.titleEn),
|
|
productId,
|
|
);
|
|
}
|
|
|
|
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 },
|
|
data: {
|
|
title: titleFa,
|
|
slug,
|
|
description: dto.description?.trim() || null,
|
|
content,
|
|
price:
|
|
dto.price === undefined || dto.price === null ? null : dto.price,
|
|
priceCurrency: dto.priceCurrency ?? 'IRT',
|
|
featuredMediaId,
|
|
countryId,
|
|
cityId,
|
|
deliveryNote: dto.deliveryNote?.trim() || null,
|
|
condition: dto.condition,
|
|
technicalNotes: dto.technicalNotes?.trim() || null,
|
|
metadata,
|
|
},
|
|
include: userProductAdminListInclude,
|
|
});
|
|
|
|
await tx.categoryAssignment.deleteMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
},
|
|
});
|
|
await tx.categoryAssignment.create({
|
|
data: {
|
|
businessId,
|
|
categoryId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
},
|
|
});
|
|
|
|
if (galleryMediaIds !== null) {
|
|
await this.syncGalleryAttachments(
|
|
tx,
|
|
businessId,
|
|
productId,
|
|
galleryMediaIds,
|
|
);
|
|
}
|
|
|
|
await tx.userProductTechnicalFieldValue.deleteMany({
|
|
where: { userProductId: productId },
|
|
});
|
|
|
|
if (form?.fields.length) {
|
|
await this.saveTechnicalValues(
|
|
tx,
|
|
businessId,
|
|
productId,
|
|
form.fields,
|
|
technicalValues,
|
|
);
|
|
}
|
|
|
|
return product;
|
|
});
|
|
|
|
const category = await this.prisma.category.findFirst({
|
|
where: { id: categoryId, businessId },
|
|
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);
|
|
|
|
await this.sitemap.invalidateForBusiness(businessId);
|
|
|
|
return {
|
|
message: 'User product updated successfully',
|
|
product: this.serializeAdminListItem(updated, category ?? undefined),
|
|
};
|
|
}
|
|
|
|
async uploadMedia(
|
|
businessIdRaw: string,
|
|
files: Express.Multer.File[],
|
|
actor: AuthUser,
|
|
) {
|
|
return this.mediaService.uploadManyForCustomer(businessIdRaw, files, actor);
|
|
}
|
|
|
|
async remove(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId, userId: actor.id },
|
|
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,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
},
|
|
});
|
|
await tx.userProduct.delete({ where: { id: productId } });
|
|
});
|
|
|
|
await this.mediaService.deleteUnused(businessId, mediaIds);
|
|
|
|
await this.sitemap.invalidateForBusiness(businessId);
|
|
|
|
return { message: 'User product deleted successfully' };
|
|
}
|
|
|
|
async promote(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId, userId: actor.id },
|
|
include: userProductListInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const metadata = this.asRecord(product.metadata);
|
|
const nextMetadata: Prisma.InputJsonValue = {
|
|
...metadata,
|
|
promoted: true,
|
|
promotedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const updated = await this.prisma.userProduct.update({
|
|
where: { id: productId },
|
|
data: { metadata: nextMetadata },
|
|
include: userProductListInclude,
|
|
});
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
updated.id,
|
|
]);
|
|
|
|
return {
|
|
message: 'User product promoted successfully',
|
|
product: this.serializeListItem(
|
|
updated,
|
|
categoryByEntity.get(updated.id.toString()),
|
|
),
|
|
};
|
|
}
|
|
|
|
async adminList(
|
|
businessIdRaw: string,
|
|
query: ListAdminUserProductsDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.read');
|
|
|
|
const page = query.page ?? 1;
|
|
const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100);
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: Prisma.UserProductWhereInput = {
|
|
businessId,
|
|
...(query.status ? { status: query.status } : {}),
|
|
};
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.userProduct.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
include: userProductAdminListInclude,
|
|
}),
|
|
this.prisma.userProduct.count({ where }),
|
|
]);
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(
|
|
businessId,
|
|
items.map((item) => item.id),
|
|
);
|
|
|
|
return {
|
|
items: items.map((item) =>
|
|
this.serializeAdminListItem(
|
|
item,
|
|
categoryByEntity.get(item.id.toString()),
|
|
),
|
|
),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async adminGetOne(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.read');
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId },
|
|
include: userProductAdminDetailInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
product.id,
|
|
]);
|
|
const gallery = await this.loadGalleryAttachments(businessId, product.id);
|
|
|
|
return {
|
|
product: this.serializeAdminDetail(
|
|
product,
|
|
categoryByEntity.get(product.id.toString()),
|
|
gallery,
|
|
),
|
|
};
|
|
}
|
|
|
|
async adminCreate(
|
|
businessIdRaw: string,
|
|
dto: CreateUserProductDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.create');
|
|
return this.createForUser(businessId, dto, actor.id);
|
|
}
|
|
|
|
async adminUpdate(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
dto: UpdateUserProductDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.update');
|
|
|
|
const existing = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
metadata: true,
|
|
userId: true,
|
|
featuredMediaId: true,
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
return this.updateExisting(businessId, productId, dto, existing);
|
|
}
|
|
|
|
async adminUpdateStatus(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
dto: UpdateUserProductStatusDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.update');
|
|
|
|
const existing = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId },
|
|
select: { id: true, status: true, publishedAt: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const status = dto.status;
|
|
const publishedAt =
|
|
status === ContentStatus.published
|
|
? existing.publishedAt ?? new Date()
|
|
: null;
|
|
|
|
const updated = await this.prisma.userProduct.update({
|
|
where: { id: productId },
|
|
data: { status, publishedAt },
|
|
include: userProductAdminListInclude,
|
|
});
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
updated.id,
|
|
]);
|
|
|
|
await this.sitemap.invalidateForBusiness(businessId);
|
|
|
|
return {
|
|
message: 'User product status updated successfully',
|
|
product: this.serializeAdminListItem(
|
|
updated,
|
|
categoryByEntity.get(updated.id.toString()),
|
|
),
|
|
};
|
|
}
|
|
|
|
async adminPromote(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.update');
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId },
|
|
include: userProductAdminListInclude,
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
const metadata = this.asRecord(product.metadata);
|
|
const nextMetadata: Prisma.InputJsonValue = {
|
|
...metadata,
|
|
promoted: true,
|
|
promotedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const updated = await this.prisma.userProduct.update({
|
|
where: { id: productId },
|
|
data: { metadata: nextMetadata },
|
|
include: userProductAdminListInclude,
|
|
});
|
|
|
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
|
updated.id,
|
|
]);
|
|
|
|
return {
|
|
message: 'User product promoted successfully',
|
|
product: this.serializeAdminListItem(
|
|
updated,
|
|
categoryByEntity.get(updated.id.toString()),
|
|
),
|
|
};
|
|
}
|
|
|
|
async adminRemove(
|
|
businessIdRaw: string,
|
|
productIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const productId = BigInt(productIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.delete');
|
|
|
|
const product = await this.prisma.userProduct.findFirst({
|
|
where: { id: productId, businessId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException('User product not found');
|
|
}
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.categoryAssignment.deleteMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
},
|
|
});
|
|
await tx.userProduct.delete({ where: { id: productId } });
|
|
});
|
|
|
|
await this.sitemap.invalidateForBusiness(businessId);
|
|
|
|
return { message: 'User product deleted successfully' };
|
|
}
|
|
|
|
async listCategoriesForAdmin(businessIdRaw: string, actor: AuthUser) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.read');
|
|
return this.listProductCategories(businessId);
|
|
}
|
|
|
|
async getCategoryTechnicalFormForAdmin(
|
|
businessIdRaw: string,
|
|
categoryIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const categoryId = BigInt(categoryIdRaw);
|
|
await this.assertAdminAccess(businessId, actor, 'user_products.read');
|
|
await this.assertProductCategory(businessId, categoryId);
|
|
|
|
const form = await this.technicalFormService.getFormForCategory(
|
|
businessId,
|
|
categoryId,
|
|
);
|
|
|
|
return { form };
|
|
}
|
|
|
|
async listCategories(businessIdRaw: string, actor: AuthUser) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
return this.listProductCategories(businessId);
|
|
}
|
|
|
|
async getCategoryTechnicalForm(
|
|
businessIdRaw: string,
|
|
categoryIdRaw: string,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const categoryId = BigInt(categoryIdRaw);
|
|
await this.assertCustomerAccess(businessId, actor);
|
|
await this.assertProductCategory(businessId, categoryId);
|
|
|
|
const form = await this.technicalFormService.getFormForCategory(
|
|
businessId,
|
|
categoryId,
|
|
);
|
|
|
|
return { form };
|
|
}
|
|
|
|
private validateTechnicalValues(
|
|
fields: FormField[],
|
|
values: UserProductTechnicalValueDto[],
|
|
) {
|
|
if (!fields.length) {
|
|
if (values.length) {
|
|
throw new BadRequestException(
|
|
'This category has no technical form fields',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
|
const submittedIds = new Set<string>();
|
|
|
|
for (const entry of values) {
|
|
if (submittedIds.has(entry.fieldId)) {
|
|
throw new BadRequestException(
|
|
`Duplicate technical value for field "${entry.fieldId}"`,
|
|
);
|
|
}
|
|
submittedIds.add(entry.fieldId);
|
|
|
|
const field = fieldMap.get(entry.fieldId);
|
|
if (!field) {
|
|
throw new BadRequestException(
|
|
`Unknown technical field "${entry.fieldId}"`,
|
|
);
|
|
}
|
|
|
|
this.validateFieldEntry(field, entry);
|
|
}
|
|
|
|
for (const field of fields) {
|
|
if (!field.isRequired) {
|
|
continue;
|
|
}
|
|
const entry = values.find((item) => item.fieldId === field.id);
|
|
if (!entry || !this.hasFieldValue(field, entry)) {
|
|
throw new BadRequestException(
|
|
`Required field "${field.label}" is missing`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateFieldEntry(
|
|
field: FormField,
|
|
entry: UserProductTechnicalValueDto,
|
|
) {
|
|
if (field.type === 'text' || field.type === 'textarea') {
|
|
if (entry.optionId || entry.optionIds?.length) {
|
|
throw new BadRequestException(
|
|
`Field "${field.label}" expects a text value`,
|
|
);
|
|
}
|
|
const text = entry.textValue?.trim() ?? '';
|
|
if (field.isRequired && !text) {
|
|
throw new BadRequestException(
|
|
`Required field "${field.label}" cannot be empty`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (field.type === 'select') {
|
|
if (entry.textValue || entry.optionIds?.length) {
|
|
throw new BadRequestException(
|
|
`Field "${field.label}" expects a single optionId`,
|
|
);
|
|
}
|
|
if (!entry.optionId) {
|
|
if (field.isRequired) {
|
|
throw new BadRequestException(
|
|
`Required field "${field.label}" cannot be empty`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
this.findOptionById(field, entry.optionId);
|
|
return;
|
|
}
|
|
|
|
if (entry.textValue || entry.optionId) {
|
|
throw new BadRequestException(
|
|
`Field "${field.label}" expects optionIds`,
|
|
);
|
|
}
|
|
const optionIds = entry.optionIds ?? [];
|
|
if (!optionIds.length) {
|
|
if (field.isRequired) {
|
|
throw new BadRequestException(
|
|
`Required field "${field.label}" cannot be empty`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
for (const optionId of optionIds) {
|
|
this.findOptionById(field, optionId);
|
|
}
|
|
}
|
|
|
|
private hasFieldValue(
|
|
field: FormField,
|
|
entry: UserProductTechnicalValueDto,
|
|
): boolean {
|
|
if (field.type === 'text' || field.type === 'textarea') {
|
|
return Boolean(entry.textValue?.trim());
|
|
}
|
|
if (field.type === 'select') {
|
|
return Boolean(entry.optionId);
|
|
}
|
|
return Boolean(entry.optionIds?.length);
|
|
}
|
|
|
|
private findOptionById(field: FormField, optionId: string) {
|
|
const option = field.options.find((item) => item.id === optionId);
|
|
if (!option) {
|
|
throw new BadRequestException(
|
|
`Invalid option "${optionId}" for field "${field.label}"`,
|
|
);
|
|
}
|
|
return option;
|
|
}
|
|
|
|
private async saveTechnicalValues(
|
|
tx: Prisma.TransactionClient,
|
|
businessId: bigint,
|
|
userProductId: bigint,
|
|
fields: FormField[],
|
|
values: UserProductTechnicalValueDto[],
|
|
) {
|
|
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
|
|
|
for (const entry of values) {
|
|
const field = fieldMap.get(entry.fieldId);
|
|
if (!field) {
|
|
continue;
|
|
}
|
|
|
|
if (!this.hasFieldValue(field, entry)) {
|
|
continue;
|
|
}
|
|
|
|
const fieldId = BigInt(field.id);
|
|
|
|
if (field.type === 'text' || field.type === 'textarea') {
|
|
await tx.userProductTechnicalFieldValue.create({
|
|
data: {
|
|
businessId,
|
|
userProductId,
|
|
fieldId,
|
|
textValue: entry.textValue!.trim(),
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (field.type === 'select') {
|
|
await tx.userProductTechnicalFieldValue.create({
|
|
data: {
|
|
businessId,
|
|
userProductId,
|
|
fieldId,
|
|
optionId: BigInt(entry.optionId!),
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const fieldValue = await tx.userProductTechnicalFieldValue.create({
|
|
data: {
|
|
businessId,
|
|
userProductId,
|
|
fieldId,
|
|
},
|
|
});
|
|
|
|
for (const optionId of entry.optionIds ?? []) {
|
|
await tx.userProductTechnicalFieldValueOption.create({
|
|
data: {
|
|
fieldValueId: fieldValue.id,
|
|
optionId: BigInt(optionId),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async loadCategoriesForProducts(
|
|
businessId: bigint,
|
|
productIds: bigint[],
|
|
) {
|
|
const map = new Map<
|
|
string,
|
|
{ id: bigint; name: string; nameFa: string | null }
|
|
>();
|
|
|
|
if (!productIds.length) {
|
|
return map;
|
|
}
|
|
|
|
const assignments = await this.prisma.categoryAssignment.findMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: { in: productIds },
|
|
},
|
|
include: {
|
|
category: {
|
|
select: { id: true, name: true, nameFa: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const assignment of assignments) {
|
|
map.set(assignment.entityId.toString(), assignment.category);
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
private serializeListItem(
|
|
product: UserProductListRow,
|
|
category?: { id: bigint; name: string; nameFa: string | null } | null,
|
|
) {
|
|
const content = this.asRecord(product.content);
|
|
const metadata = this.asRecord(product.metadata);
|
|
const titleEn =
|
|
typeof content.titleEn === 'string' ? content.titleEn : null;
|
|
const priceByExpert = metadata.priceByExpert === true;
|
|
const promoted = metadata.promoted === true;
|
|
|
|
return {
|
|
id: product.id.toString(),
|
|
slug: product.slug,
|
|
pathSlug: buildUserProductSlug(product.title, titleEn),
|
|
title: product.title,
|
|
titleFa: product.title,
|
|
titleEn,
|
|
description: product.description,
|
|
price: product.price === null ? null : Number(product.price),
|
|
priceCurrency: product.priceCurrency,
|
|
priceByExpert,
|
|
promoted,
|
|
...readSeoMetaFromMetadata(metadata),
|
|
status: product.status,
|
|
condition: product.condition,
|
|
deliveryNote: product.deliveryNote,
|
|
technicalNotes: product.technicalNotes,
|
|
cityId: product.cityId.toString(),
|
|
countryId: product.countryId.toString(),
|
|
cityName: product.city.nameEn,
|
|
cityNameFa: product.city.nameFa,
|
|
countryName: product.country.nameEn,
|
|
countryNameFa: product.country.nameFa,
|
|
imageUrl: product.featuredMedia?.publicUrl ?? null,
|
|
categoryId: category?.id.toString() ?? null,
|
|
categoryName: category?.name ?? null,
|
|
categoryNameFa: category?.nameFa ?? null,
|
|
createdAt: product.createdAt,
|
|
publishedAt: product.publishedAt,
|
|
};
|
|
}
|
|
|
|
private serializeDetail(
|
|
product: UserProductDetailRow,
|
|
category?: { id: bigint; name: string; nameFa: string | null } | null,
|
|
gallery: { mediaId: bigint; publicUrl: string }[] = [],
|
|
) {
|
|
const base = this.serializeListItem(
|
|
product as UserProductListRow,
|
|
category,
|
|
);
|
|
const technicalValues: UserProductTechnicalValueDto[] = [];
|
|
|
|
for (const value of product.technicalFieldValues) {
|
|
const fieldId = value.fieldId.toString();
|
|
if (value.textValue != null) {
|
|
technicalValues.push({ fieldId, textValue: value.textValue });
|
|
continue;
|
|
}
|
|
if (value.optionId != null) {
|
|
technicalValues.push({
|
|
fieldId,
|
|
optionId: value.optionId.toString(),
|
|
});
|
|
continue;
|
|
}
|
|
if (value.selectedOptions.length) {
|
|
technicalValues.push({
|
|
fieldId,
|
|
optionIds: value.selectedOptions.map((item) =>
|
|
item.optionId.toString(),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
countryId: product.countryId.toString(),
|
|
cityId: product.cityId.toString(),
|
|
countrySlug: product.country.slug,
|
|
featuredMediaId: product.featuredMediaId?.toString() ?? null,
|
|
galleryMediaIds: gallery.map((item) => item.mediaId.toString()),
|
|
images: gallery.map((item) => ({
|
|
mediaId: item.mediaId.toString(),
|
|
url: item.publicUrl,
|
|
})),
|
|
technicalValues,
|
|
};
|
|
}
|
|
|
|
private serializeAdminListItem(
|
|
product: UserProductAdminListRow,
|
|
category?: { id: bigint; name: string; nameFa: string | null } | null,
|
|
) {
|
|
const base = this.serializeListItem(
|
|
product as UserProductListRow,
|
|
category,
|
|
);
|
|
const owner = this.formatOwnerNames(product.user);
|
|
return {
|
|
...base,
|
|
ownerId: product.user.id.toString(),
|
|
ownerName: owner.nameEn,
|
|
ownerNameFa: owner.nameFa,
|
|
ownerCell: product.user.cellNumber,
|
|
};
|
|
}
|
|
|
|
private serializeAdminDetail(
|
|
product: UserProductAdminDetailRow,
|
|
category?: { id: bigint; name: string; nameFa: string | null } | null,
|
|
gallery: { mediaId: bigint; publicUrl: string }[] = [],
|
|
) {
|
|
const detail = this.serializeDetail(
|
|
product as UserProductDetailRow,
|
|
category,
|
|
gallery,
|
|
);
|
|
const owner = this.formatOwnerNames(product.user);
|
|
return {
|
|
...detail,
|
|
ownerId: product.user.id.toString(),
|
|
ownerName: owner.nameEn,
|
|
ownerNameFa: owner.nameFa,
|
|
ownerCell: product.user.cellNumber,
|
|
};
|
|
}
|
|
|
|
private formatOwnerNames(user: {
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
firstNameEn: string | null;
|
|
lastNameEn: string | null;
|
|
cellNumber: string;
|
|
}) {
|
|
const nameFa = [user.firstName, user.lastName]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.trim();
|
|
const nameEn = [user.firstNameEn, user.lastNameEn]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.trim();
|
|
const fallback = nameFa || nameEn || user.cellNumber;
|
|
return {
|
|
nameFa: nameFa || fallback,
|
|
nameEn: nameEn || fallback,
|
|
};
|
|
}
|
|
|
|
private async listProductCategories(businessId: bigint) {
|
|
const items = await this.prisma.category.findMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.product,
|
|
isActive: true,
|
|
},
|
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
nameFa: true,
|
|
parentId: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
items: items.map((item) => ({
|
|
id: item.id.toString(),
|
|
name: item.name,
|
|
nameFa: item.nameFa,
|
|
parentId: item.parentId?.toString() ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
private asRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
private async ensureUniqueSlug(
|
|
businessId: bigint,
|
|
baseSlug: string,
|
|
excludeId?: bigint,
|
|
) {
|
|
let slug = baseSlug;
|
|
let suffix = 1;
|
|
|
|
while (true) {
|
|
const existing = await this.prisma.userProduct.findFirst({
|
|
where: {
|
|
businessId,
|
|
slug,
|
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
return slug;
|
|
}
|
|
|
|
suffix += 1;
|
|
slug = `${baseSlug}-${suffix}`;
|
|
}
|
|
}
|
|
|
|
private async assertProductCategory(businessId: bigint, categoryId: bigint) {
|
|
const category = await this.prisma.category.findFirst({
|
|
where: {
|
|
id: categoryId,
|
|
businessId,
|
|
entityType: MediaEntityType.product,
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
if (!category) {
|
|
throw new NotFoundException('Product category not found');
|
|
}
|
|
|
|
return category;
|
|
}
|
|
|
|
private async assertCountryAndCity(countryId: bigint, cityId: bigint) {
|
|
const [country, city] = await Promise.all([
|
|
this.prisma.city.findFirst({
|
|
where: { id: countryId, level: CityLevel.country, isActive: true },
|
|
}),
|
|
this.prisma.city.findFirst({
|
|
where: { id: cityId, level: CityLevel.city, isActive: true },
|
|
}),
|
|
]);
|
|
|
|
if (!country) {
|
|
throw new BadRequestException('Country not found');
|
|
}
|
|
if (!city) {
|
|
throw new BadRequestException('City not found');
|
|
}
|
|
|
|
let cursor: { id: bigint; parentId: bigint | null; level: CityLevel } | null =
|
|
city;
|
|
let belongsToCountry = false;
|
|
|
|
while (cursor) {
|
|
if (cursor.id === countryId) {
|
|
belongsToCountry = true;
|
|
break;
|
|
}
|
|
if (!cursor.parentId) {
|
|
break;
|
|
}
|
|
cursor = await this.prisma.city.findFirst({
|
|
where: { id: cursor.parentId },
|
|
select: { id: true, parentId: true, level: true },
|
|
});
|
|
}
|
|
|
|
if (!belongsToCountry) {
|
|
throw new BadRequestException('City does not belong to the selected country');
|
|
}
|
|
}
|
|
|
|
private async buildPublicWhere(
|
|
businessId: bigint,
|
|
query: ListPublicUserProductsDto,
|
|
): Promise<Prisma.UserProductWhereInput> {
|
|
let entityIds: bigint[] | undefined;
|
|
|
|
if (query.categoryId) {
|
|
const assignments = await this.prisma.categoryAssignment.findMany({
|
|
where: {
|
|
businessId,
|
|
categoryId: BigInt(query.categoryId),
|
|
entityType: MediaEntityType.user_product,
|
|
},
|
|
select: { entityId: true },
|
|
});
|
|
|
|
entityIds = assignments.map((item) => item.entityId);
|
|
if (entityIds.length === 0) {
|
|
return { id: { in: [] } };
|
|
}
|
|
}
|
|
|
|
const search = (query.q ?? query.name)?.trim();
|
|
const and: Prisma.UserProductWhereInput[] = [];
|
|
|
|
if (search) {
|
|
and.push({
|
|
OR: [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ description: { contains: search, mode: 'insensitive' } },
|
|
],
|
|
});
|
|
}
|
|
|
|
if (query.promoted === true) {
|
|
and.push({
|
|
metadata: {
|
|
path: ['promoted'],
|
|
equals: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
businessId,
|
|
status: ContentStatus.published,
|
|
...(entityIds ? { id: { in: entityIds } } : {}),
|
|
...(query.cityId ? { cityId: BigInt(query.cityId) } : {}),
|
|
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
|
...(query.condition ? { condition: query.condition } : {}),
|
|
...(and.length ? { AND: and } : {}),
|
|
};
|
|
}
|
|
|
|
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 resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) {
|
|
const ids = rawIds.map((id) => BigInt(id));
|
|
for (const mediaId of ids) {
|
|
await this.assertMediaBelongsToBusiness(businessId, mediaId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
private async syncGalleryAttachments(
|
|
tx: Prisma.TransactionClient,
|
|
businessId: bigint,
|
|
productId: bigint,
|
|
mediaIds: bigint[],
|
|
) {
|
|
await tx.mediaAttachment.deleteMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
isFeatured: false,
|
|
},
|
|
});
|
|
|
|
for (const [index, mediaId] of mediaIds.entries()) {
|
|
await tx.mediaAttachment.create({
|
|
data: {
|
|
businessId,
|
|
mediaId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
sortOrder: index,
|
|
isFeatured: false,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async loadGalleryAttachments(businessId: bigint, productId: bigint) {
|
|
const attachments = await this.prisma.mediaAttachment.findMany({
|
|
where: {
|
|
businessId,
|
|
entityType: MediaEntityType.user_product,
|
|
entityId: productId,
|
|
isFeatured: false,
|
|
},
|
|
include: { media: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
|
|
return attachments.map((item) => ({
|
|
mediaId: item.mediaId,
|
|
publicUrl: item.media.publicUrl,
|
|
}));
|
|
}
|
|
|
|
private async assertAdminAccess(
|
|
businessId: bigint,
|
|
actor: AuthUser,
|
|
permission: string,
|
|
) {
|
|
if (await this.permissions.isSuperAdmin(actor.id)) {
|
|
return;
|
|
}
|
|
|
|
const allowed = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
permission,
|
|
);
|
|
if (!allowed) {
|
|
throw new ForbiddenException('Insufficient permissions');
|
|
}
|
|
}
|
|
|
|
private async assertCustomerAccess(businessId: bigint, actor: AuthUser) {
|
|
if (await this.permissions.isSuperAdmin(actor.id)) {
|
|
return;
|
|
}
|
|
|
|
const membership = await this.prisma.businessCustomer.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId: actor.id },
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new ForbiddenException('You are not a customer of this business');
|
|
}
|
|
}
|
|
}
|