mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
1089 lines
33 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|