Files
backend/src/store/store-items.service.ts
T

1089 lines
33 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
BatchCreateStoreItemsDto,
BatchUpdateStoreItemDiscountsDto,
BatchUpdateFestivalRewardsDto,
CreateStoreItemVariantDto,
ListPublicStoreItemsDto,
ListStoreItemsDto,
SyncProductStoreItemVariantsDto,
UpdateStoreItemVariantDto,
} from './dto/store-items.dto';
const variantInclude = {
storeItem: {
include: {
product: {
include: {
featuredMedia: true,
},
},
},
},
selections: {
include: {
variation: true,
option: true,
},
},
} satisfies Prisma.StoreItemVariantInclude;
type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{
include: typeof variantInclude;
}>;
@Injectable()
export class StoreItemsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
) {}
async list(
businessIdRaw: string,
query: ListStoreItemsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = { businessId };
const [items, total] = await Promise.all([
this.prisma.storeItemVariant.findMany({
where,
orderBy: [{ createdAt: 'desc' }],
skip,
take: pageSize,
include: variantInclude,
}),
this.prisma.storeItemVariant.count({ where }),
]);
const productIds = [
...new Set(items.map((item) => item.storeItem.productId)),
];
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, productIds),
this.loadStockTotals(businessId, productIds),
]);
return {
items: items.map((item) =>
this.serializeVariant(item, {
galleryUrl:
galleryByProduct.get(item.storeItem.productId.toString()) ?? null,
productTotalStock:
stockByProduct.get(item.storeItem.productId.toString()) ?? 0,
}),
),
total,
page,
pageSize,
};
}
async listPublic(host: string, query: ListPublicStoreItemsDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = await this.buildPublicVariantWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.storeItemVariant.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: variantInclude,
}),
this.prisma.storeItemVariant.count({ where }),
]);
const productIds = [...new Set(items.map((item) => item.storeItem.productId))];
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, productIds),
this.loadStockTotals(businessId, productIds),
]);
return {
items: items.map((item) =>
this.serializeVariant(item, {
galleryUrl:
galleryByProduct.get(item.storeItem.productId.toString()) ?? null,
productTotalStock:
stockByProduct.get(item.storeItem.productId.toString()) ?? 0,
}),
),
total,
page,
pageSize,
};
}
async getPublicByProduct(host: string, productIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const productId = BigInt(productIdRaw);
await this.assertPublishedProductExists(businessId, productId);
const storeItem = await this.prisma.storeItem.findFirst({
where: { businessId, productId, isActive: true },
include: {
variants: {
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: variantInclude,
},
},
});
if (!storeItem) {
return { storeItem: null };
}
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
storeItem: {
id: storeItem.id.toString(),
productId: storeItem.productId.toString(),
isActive: storeItem.isActive,
sortOrder: storeItem.sortOrder,
createdAt: storeItem.createdAt,
updatedAt: storeItem.updatedAt,
variants: storeItem.variants.map((variant) =>
this.serializeVariant(variant, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
},
};
}
async getPublicVariant(host: string, variantIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const variantId = BigInt(variantIdRaw);
const variant = await this.prisma.storeItemVariant.findFirst({
where: {
id: variantId,
businessId,
isActive: true,
storeItem: {
isActive: true,
product: { status: ContentStatus.published },
},
},
include: variantInclude,
});
if (!variant) {
throw new NotFoundException('Store item variant not found');
}
const productId = variant.storeItem.productId;
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
variant: this.serializeVariant(variant, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
};
}
async getByProduct(
businessIdRaw: string,
productIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(productIdRaw);
await this.assertPermission(businessId, actor.id, 'products.read');
const storeItem = await this.prisma.storeItem.findFirst({
where: { businessId, productId },
include: {
variants: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: variantInclude,
},
},
});
if (!storeItem) {
return { storeItem: null };
}
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
storeItem: {
id: storeItem.id.toString(),
productId: storeItem.productId.toString(),
isActive: storeItem.isActive,
sortOrder: storeItem.sortOrder,
createdAt: storeItem.createdAt,
updatedAt: storeItem.updatedAt,
variants: storeItem.variants.map((variant) =>
this.serializeVariant(variant, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
},
};
}
async batchCreateVariants(
businessIdRaw: string,
dto: BatchCreateStoreItemsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(dto.productId);
await this.assertPermission(businessId, actor.id, 'products.update');
if (!dto.items.length) {
throw new BadRequestException('At least one variant is required');
}
await this.assertProductWithCategory(businessId, productId);
const [categoryVariations, productValues, existingVariants] =
await this.loadVariantContext(businessId, productId);
const variationMap = new Map(
categoryVariations.map((variation) => [variation.id.toString(), variation]),
);
const allowedOptionIds = new Set(
productValues.map((item) => item.optionId.toString()),
);
const existingKeys = new Set(
existingVariants.map((variant) =>
this.selectionKey(variant.selections.map((s) => s.optionId.toString())),
),
);
const batchKeys = new Set<string>();
for (const item of dto.items) {
this.validateVariantItem(
item,
variationMap,
allowedOptionIds,
existingKeys,
batchKeys,
);
}
const created = await this.prisma.$transaction(async (tx) => {
const storeItem = await this.ensureStoreItem(businessId, productId, tx);
const baseSortOrder = await tx.storeItemVariant.count({
where: { storeItemId: storeItem.id },
});
const results: VariantWithRelations[] = [];
for (const [index, item] of dto.items.entries()) {
const variant = await tx.storeItemVariant.create({
data: {
businessId,
storeItemId: storeItem.id,
price: item.price ?? null,
stockQuantity: item.stockQuantity ?? null,
sortOrder: baseSortOrder + index,
},
});
for (const selection of item.selections.filter((entry) => entry.optionId?.trim())) {
await tx.storeItemVariantSelection.create({
data: {
variantId: variant.id,
variationId: BigInt(selection.variationId),
optionId: BigInt(selection.optionId),
},
});
}
results.push(
await tx.storeItemVariant.findUniqueOrThrow({
where: { id: variant.id },
include: variantInclude,
}),
);
}
return results;
});
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
message: 'Store item variants created successfully',
items: created.map((item) =>
this.serializeVariant(item, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
};
}
async updateVariant(
businessIdRaw: string,
variantIdRaw: string,
dto: UpdateStoreItemVariantDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const variantId = BigInt(variantIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
const existing = await this.prisma.storeItemVariant.findFirst({
where: { id: variantId, businessId },
});
if (!existing) {
throw new NotFoundException('Store item variant not found');
}
const price = dto.price ?? (existing.price === null ? null : Number(existing.price));
const discountedPrice =
dto.discountedPrice === undefined
? existing.compareAtPrice === null
? null
: Number(existing.compareAtPrice)
: dto.discountedPrice;
if (discountedPrice !== null && price !== null && discountedPrice >= price) {
throw new BadRequestException('Discounted price must be lower than the regular price');
}
const updated = await this.prisma.storeItemVariant.update({
where: { id: variantId },
data: {
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.stockQuantity !== undefined ? { stockQuantity: dto.stockQuantity } : {}),
...(dto.discountedPrice !== undefined
? { compareAtPrice: dto.discountedPrice }
: {}),
...(dto.isFestival !== undefined ? { isFestival: dto.isFestival } : {}),
},
include: variantInclude,
});
const productId = updated.storeItem.productId;
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
message: 'Store item variant updated successfully',
item: this.serializeVariant(updated, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
};
}
async updateDiscounts(
businessIdRaw: string,
dto: BatchUpdateStoreItemDiscountsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(dto.productId);
await this.assertPermission(businessId, actor.id, 'products.update');
const variants = await this.prisma.storeItemVariant.findMany({
where: { businessId, storeItem: { productId } },
});
const variantMap = new Map(variants.map((item) => [item.id.toString(), item]));
for (const entry of dto.items) {
const variant = variantMap.get(entry.id);
if (!variant) {
throw new BadRequestException(`Variant "${entry.id}" not found for this product`);
}
const price = variant.price === null ? null : Number(variant.price);
if (
entry.discountedPrice !== null &&
entry.discountedPrice !== undefined &&
price !== null &&
entry.discountedPrice >= price
) {
throw new BadRequestException(
`Discounted price for "${entry.id}" must be lower than the regular price`,
);
}
}
await this.prisma.$transaction(
dto.items.map((entry) =>
this.prisma.storeItemVariant.update({
where: { id: BigInt(entry.id) },
data: {
compareAtPrice:
entry.discountedPrice === undefined ? undefined : entry.discountedPrice,
},
}),
),
);
const refreshed = await this.getVariantsForProduct(businessId, productId);
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
message: 'Variant discounts updated successfully',
items: refreshed.map((item) =>
this.serializeVariant(item, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
};
}
async syncProductVariants(
businessIdRaw: string,
dto: SyncProductStoreItemVariantsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(dto.productId);
await this.assertPermission(businessId, actor.id, 'products.update');
if (!dto.items.length && !dto.removedIds.length) {
throw new BadRequestException('No variant changes to save');
}
await this.assertProductWithCategory(businessId, productId);
const [categoryVariations, productValues, existingVariants] =
await this.loadVariantContext(businessId, productId);
const variationMap = new Map(
categoryVariations.map((variation) => [variation.id.toString(), variation]),
);
const allowedOptionIds = new Set(
productValues.map((item) => item.optionId.toString()),
);
const variantMap = new Map(
existingVariants.map((variant) => [variant.id.toString(), variant]),
);
for (const removedId of dto.removedIds) {
if (!variantMap.has(removedId)) {
throw new BadRequestException(`Variant "${removedId}" not found for this product`);
}
}
const survivingVariants = existingVariants.filter(
(variant) => !dto.removedIds.includes(variant.id.toString()),
);
const existingKeys = new Map(
survivingVariants.map((variant) => [
variant.id.toString(),
this.selectionKey(variant.selections.map((selection) => selection.optionId.toString())),
]),
);
const batchKeys = new Set<string>();
for (const item of dto.items) {
const createDto: CreateStoreItemVariantDto = {
selections: item.selections,
price: item.price,
stockQuantity: item.stockQuantity,
};
if (item.id && !variantMap.has(item.id)) {
throw new BadRequestException(`Variant "${item.id}" not found for this product`);
}
this.validateVariantItem(
createDto,
variationMap,
allowedOptionIds,
new Set(
[...existingKeys.entries()]
.filter(([id]) => id !== item.id)
.map(([, key]) => key),
),
batchKeys,
);
const key = this.selectionKey(
item.selections.filter((selection) => selection.optionId?.trim()).map((s) => s.optionId),
);
if (item.id) {
existingKeys.set(item.id, key);
}
}
await this.prisma.$transaction(async (tx) => {
const storeItem = await this.ensureStoreItem(businessId, productId, tx);
const baseSortOrder = await tx.storeItemVariant.count({
where: { storeItemId: storeItem.id },
});
for (const removedId of dto.removedIds) {
await tx.storeItemVariant.delete({ where: { id: BigInt(removedId) } });
}
let createIndex = 0;
for (const item of dto.items) {
const activeSelections = item.selections.filter((selection) => selection.optionId?.trim());
if (item.id) {
const variantId = BigInt(item.id);
await tx.storeItemVariant.update({
where: { id: variantId },
data: {
price: item.price,
stockQuantity: item.stockQuantity,
},
});
await tx.storeItemVariantSelection.deleteMany({ where: { variantId } });
for (const selection of activeSelections) {
await tx.storeItemVariantSelection.create({
data: {
variantId,
variationId: BigInt(selection.variationId),
optionId: BigInt(selection.optionId),
},
});
}
continue;
}
const variant = await tx.storeItemVariant.create({
data: {
businessId,
storeItemId: storeItem.id,
price: item.price,
stockQuantity: item.stockQuantity,
sortOrder: baseSortOrder + createIndex,
},
});
createIndex += 1;
for (const selection of activeSelections) {
await tx.storeItemVariantSelection.create({
data: {
variantId: variant.id,
variationId: BigInt(selection.variationId),
optionId: BigInt(selection.optionId),
},
});
}
}
});
const refreshed = await this.getVariantsForProduct(businessId, productId);
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
message: 'Store item variants updated successfully',
items: refreshed.map((item) =>
this.serializeVariant(item, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
};
}
async updateFestivalRewards(
businessIdRaw: string,
dto: BatchUpdateFestivalRewardsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(dto.productId);
await this.assertPermission(businessId, actor.id, 'products.update');
const variants = await this.prisma.storeItemVariant.findMany({
where: { businessId, storeItem: { productId } },
});
const variantMap = new Map(variants.map((item) => [item.id.toString(), item]));
for (const entry of dto.items) {
if (!variantMap.has(entry.id)) {
throw new BadRequestException(`Variant "${entry.id}" not found for this product`);
}
if (entry.rewardPoints !== null && entry.rewardPoints !== undefined && entry.rewardPoints < 0) {
throw new BadRequestException('Reward points cannot be negative');
}
}
await this.prisma.$transaction(
dto.items.map((entry) =>
this.prisma.storeItemVariant.update({
where: { id: BigInt(entry.id) },
data: {
rewardPoints: entry.rewardPoints ?? null,
isFestival: Boolean(entry.rewardPoints && entry.rewardPoints > 0),
},
}),
),
);
const refreshed = await this.getVariantsForProduct(businessId, productId);
const [galleryByProduct, stockByProduct] = await Promise.all([
this.loadFirstGalleryUrls(businessId, [productId]),
this.loadStockTotals(businessId, [productId]),
]);
return {
message: 'Festival reward points updated successfully',
items: refreshed.map((item) =>
this.serializeVariant(item, {
galleryUrl: galleryByProduct.get(productId.toString()) ?? null,
productTotalStock: stockByProduct.get(productId.toString()) ?? 0,
}),
),
};
}
async removeVariant(
businessIdRaw: string,
variantIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const variantId = BigInt(variantIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
const existing = await this.prisma.storeItemVariant.findFirst({
where: { id: variantId, businessId },
});
if (!existing) {
throw new NotFoundException('Store item variant not found');
}
await this.prisma.storeItemVariant.delete({ where: { id: variantId } });
return { message: 'Store item variant deleted successfully' };
}
async removeByProduct(
businessIdRaw: string,
productIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(productIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
const result = await this.prisma.storeItem.deleteMany({
where: { businessId, productId },
});
if (result.count === 0) {
throw new NotFoundException('No store item found for this product');
}
return { message: 'Store item removed successfully' };
}
private async getVariantsForProduct(businessId: bigint, productId: bigint) {
return this.prisma.storeItemVariant.findMany({
where: { businessId, storeItem: { productId } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: variantInclude,
});
}
private async loadVariantContext(businessId: bigint, productId: bigint) {
const categoryId = await this.getProductCategoryId(businessId, productId);
if (!categoryId) {
throw new BadRequestException(
'Assign a category to this product before managing store item variants',
);
}
return Promise.all([
this.prisma.categoryVariation.findMany({
where: { businessId, categoryId },
include: { options: true },
}),
this.prisma.productVariationValue.findMany({ where: { productId } }),
this.prisma.storeItemVariant.findMany({
where: { businessId, storeItem: { productId } },
include: { selections: true },
}),
]);
}
private async assertProductWithCategory(businessId: bigint, productId: bigint) {
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
const categoryId = await this.getProductCategoryId(businessId, productId);
if (!categoryId) {
throw new BadRequestException(
'Assign a category to this product before creating store item variants',
);
}
}
private async ensureStoreItem(
businessId: bigint,
productId: bigint,
tx: Prisma.TransactionClient = this.prisma,
) {
const existing = await tx.storeItem.findUnique({
where: { businessId_productId: { businessId, productId } },
});
if (existing) {
return existing;
}
return tx.storeItem.create({
data: { businessId, productId },
});
}
private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) {
if (productIds.length === 0) {
return new Map<string, string>();
}
const attachments = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: { in: productIds },
isFeatured: false,
},
orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }],
include: { media: true },
});
const map = new Map<string, string>();
for (const attachment of attachments) {
const key = attachment.entityId.toString();
if (!map.has(key)) {
map.set(key, attachment.media.publicUrl);
}
}
return map;
}
private async loadStockTotals(businessId: bigint, productIds: bigint[]) {
if (productIds.length === 0) {
return new Map<string, number>();
}
const storeItems = await this.prisma.storeItem.findMany({
where: { businessId, productId: { in: productIds } },
select: { id: true, productId: true },
});
if (storeItems.length === 0) {
return new Map<string, number>();
}
const rows = await this.prisma.storeItemVariant.groupBy({
by: ['storeItemId'],
where: {
businessId,
storeItemId: { in: storeItems.map((item) => item.id) },
},
_sum: { stockQuantity: true },
});
const productByStoreItem = new Map(
storeItems.map((item) => [item.id.toString(), item.productId.toString()]),
);
return new Map(
rows.map((row) => [
productByStoreItem.get(row.storeItemId.toString()) ?? row.storeItemId.toString(),
row._sum.stockQuantity ?? 0,
]),
);
}
private validateVariantItem(
item: CreateStoreItemVariantDto,
variationMap: Map<string, { id: bigint; name: string; options: { id: bigint }[] }>,
allowedOptionIds: Set<string>,
existingKeys: Set<string>,
batchKeys: Set<string>,
) {
const seenVariationIds = new Set<string>();
const activeSelections = item.selections.filter((selection) => selection.optionId?.trim());
for (const selection of activeSelections) {
if (seenVariationIds.has(selection.variationId)) {
throw new BadRequestException(
`Duplicate variation "${selection.variationId}" in one variant`,
);
}
seenVariationIds.add(selection.variationId);
const variation = variationMap.get(selection.variationId);
if (!variation) {
throw new BadRequestException(
`Variation "${selection.variationId}" is not defined for this product category`,
);
}
const validOptionIds = new Set(
variation.options.map((option) => option.id.toString()),
);
if (!validOptionIds.has(selection.optionId)) {
throw new BadRequestException(
`Option "${selection.optionId}" does not belong to variation "${variation.name}"`,
);
}
if (allowedOptionIds.size > 0 && !allowedOptionIds.has(selection.optionId)) {
throw new BadRequestException(
`Option "${selection.optionId}" is not enabled for this product`,
);
}
}
const key = this.selectionKey(activeSelections.map((selection) => selection.optionId));
if (existingKeys.has(key)) {
throw new BadRequestException('A variant with this variation combination already exists');
}
if (batchKeys.has(key)) {
throw new BadRequestException('Duplicate variation combination in request');
}
existingKeys.add(key);
batchKeys.add(key);
if (item.price != null && item.price < 0) {
throw new BadRequestException('Price cannot be negative');
}
if (item.stockQuantity != null && item.stockQuantity < 0) {
throw new BadRequestException('Stock cannot be negative');
}
}
private selectionKey(optionIds: string[]) {
return [...optionIds].sort().join(':');
}
private serializeVariant(
variant: VariantWithRelations,
extras: { galleryUrl: string | null; productTotalStock: number },
) {
const product = variant.storeItem.product;
const content = this.asRecord(product.content);
const selections = variant.selections.map((selection) => ({
variationId: selection.variation.id.toString(),
variationName: selection.variation.name,
optionId: selection.option.id.toString(),
value: selection.option.label,
}));
const price = variant.price === null ? null : Number(variant.price);
const discountedPrice =
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
const thumbnailUrl = product.featuredMedia?.publicUrl ?? null;
const productImage = thumbnailUrl ?? extras.galleryUrl ?? null;
return {
id: variant.id.toString(),
storeItemId: variant.storeItemId.toString(),
productId: product.id.toString(),
productTitle: product.title,
productNameFa: (content.nameFa as string | null | undefined) ?? '',
productImage,
productTotalStock: extras.productTotalStock,
selections,
label: selections.map((item) => item.value).join(' · ') || product.title,
price,
discountedPrice:
discountedPrice !== null && price !== null && discountedPrice < price
? discountedPrice
: null,
stockQuantity: variant.stockQuantity,
rewardPoints: variant.rewardPoints,
isFestival: variant.isFestival,
sortOrder: variant.sortOrder,
createdAt: variant.createdAt,
};
}
private asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async getProductCategoryId(businessId: bigint, productId: bigint) {
const assignment = await this.prisma.categoryAssignment.findFirst({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: productId,
},
});
return assignment?.categoryId ?? null;
}
private async buildPublicVariantWhere(
businessId: bigint,
query: ListPublicStoreItemsDto,
): Promise<Prisma.StoreItemVariantWhereInput> {
let productIds: bigint[] | undefined;
if (query.productId) {
productIds = [BigInt(query.productId)];
} else if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.product,
},
select: { entityId: true },
});
productIds = assignments.map((item) => item.entityId);
if (productIds.length === 0) {
return { id: { in: [] } };
}
}
const name = query.name?.trim();
return {
businessId,
isActive: true,
storeItem: {
isActive: true,
product: {
status: ContentStatus.published,
...(query.brandId ? { brandId: BigInt(query.brandId) } : {}),
...(productIds ? { id: { in: productIds } } : {}),
...(name
? {
OR: [
{ title: { contains: name, mode: 'insensitive' } },
{
content: {
path: ['nameFa'],
string_contains: name,
},
},
],
}
: {}),
},
},
...(query.inStock ? { stockQuantity: { gt: 0 } } : {}),
...(query.isFestival ? { isFestival: true } : {}),
...(query.minPrice !== undefined || query.maxPrice !== undefined
? {
price: {
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
},
}
: {}),
};
}
private async assertPublishedProductExists(businessId: bigint, productId: bigint) {
const product = await this.prisma.product.findFirst({
where: {
id: productId,
businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
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`,
);
}
}
}