Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class StoreItemVariantSelectionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
variationId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
optionId!: string;
|
||||
}
|
||||
|
||||
export class CreateStoreItemVariantDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StoreItemVariantSelectionDto)
|
||||
selections!: StoreItemVariantSelectionDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stockQuantity?: number;
|
||||
}
|
||||
|
||||
export class BatchCreateStoreItemsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateStoreItemVariantDto)
|
||||
items!: CreateStoreItemVariantDto[];
|
||||
}
|
||||
|
||||
export class ListStoreItemsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class ListPublicStoreItemsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
inStock?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isFestival?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPrice?: number;
|
||||
}
|
||||
|
||||
export class UpdateStoreItemVariantDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stockQuantity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountedPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
isFestival?: boolean;
|
||||
}
|
||||
|
||||
export class BatchUpdateStoreItemDiscountDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountedPrice?: number | null;
|
||||
}
|
||||
|
||||
export class BatchUpdateStoreItemDiscountsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchUpdateStoreItemDiscountDto)
|
||||
items!: BatchUpdateStoreItemDiscountDto[];
|
||||
}
|
||||
|
||||
export class SyncStoreItemVariantRowDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StoreItemVariantSelectionDto)
|
||||
selections!: StoreItemVariantSelectionDto[];
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stockQuantity!: number;
|
||||
}
|
||||
|
||||
export class SyncProductStoreItemVariantsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SyncStoreItemVariantRowDto)
|
||||
items!: SyncStoreItemVariantRowDto[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
removedIds!: string[];
|
||||
}
|
||||
|
||||
export class BatchUpdateFestivalRewardDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
rewardPoints?: number | null;
|
||||
}
|
||||
|
||||
export class BatchUpdateFestivalRewardsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchUpdateFestivalRewardDto)
|
||||
items!: BatchUpdateFestivalRewardDto[];
|
||||
}
|
||||
|
||||
// Backward-compatible aliases for existing imports
|
||||
export {
|
||||
StoreItemVariantSelectionDto as StoreItemSelectionDto,
|
||||
CreateStoreItemVariantDto as CreateStoreItemDto,
|
||||
UpdateStoreItemVariantDto as UpdateStoreItemDto,
|
||||
SyncStoreItemVariantRowDto as SyncStoreItemRowDto,
|
||||
SyncProductStoreItemVariantsDto as SyncProductStoreItemsDto,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateStoreSpecialDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeItemIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStoreSpecialDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeItemIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListStoreSpecialsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
BatchCreateStoreItemsDto,
|
||||
BatchUpdateStoreItemDiscountsDto,
|
||||
BatchUpdateFestivalRewardsDto,
|
||||
ListPublicStoreItemsDto,
|
||||
ListStoreItemsDto,
|
||||
SyncProductStoreItemVariantsDto,
|
||||
UpdateStoreItemVariantDto,
|
||||
} from './dto/store-items.dto';
|
||||
import { StoreItemsService } from './store-items.service';
|
||||
|
||||
@Controller('businesses/:businessId/store-items')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class StoreItemsController {
|
||||
constructor(private readonly service: StoreItemsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('products.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListStoreItemsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get('by-product/:productId')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getByProduct(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getByProduct(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('products.update')
|
||||
batchCreateVariants(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: BatchCreateStoreItemsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.batchCreateVariants(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Put('discounts')
|
||||
@RequireBusinessPermission('products.update')
|
||||
updateDiscounts(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: BatchUpdateStoreItemDiscountsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateDiscounts(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Put('sync')
|
||||
@RequireBusinessPermission('products.update')
|
||||
syncProductVariants(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: SyncProductStoreItemVariantsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.syncProductVariants(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Put('festival-rewards')
|
||||
@RequireBusinessPermission('products.update')
|
||||
updateFestivalRewards(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: BatchUpdateFestivalRewardsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateFestivalRewards(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch('variants/:variantId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
updateVariant(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('variantId') variantId: string,
|
||||
@Body() dto: UpdateStoreItemVariantDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateVariant(businessId, variantId, dto, user);
|
||||
}
|
||||
|
||||
@Delete('variants/:variantId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
removeVariant(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('variantId') variantId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.removeVariant(businessId, variantId, user);
|
||||
}
|
||||
|
||||
@Delete('by-product/:productId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
removeByProduct(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.removeByProduct(businessId, productId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/store-items')
|
||||
export class PublicStoreItemsController {
|
||||
constructor(private readonly service: StoreItemsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-product/:productId')
|
||||
getByProduct(@Param('host') host: string, @Param('productId') productId: string) {
|
||||
return this.service.getPublicByProduct(host, productId);
|
||||
}
|
||||
|
||||
@Get(':variantId')
|
||||
getVariant(@Param('host') host: string, @Param('variantId') variantId: string) {
|
||||
return this.service.getPublicVariant(host, variantId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
CreateStoreSpecialDto,
|
||||
ListStoreSpecialsDto,
|
||||
UpdateStoreSpecialDto,
|
||||
} from './dto/store-specials.dto';
|
||||
import { StoreSpecialsService } from './store-specials.service';
|
||||
|
||||
@Controller('tenants/:host/store-specials')
|
||||
export class PublicStoreSpecialsController {
|
||||
constructor(private readonly service: StoreSpecialsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string) {
|
||||
return this.service.listPublic(host);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/store-specials')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class StoreSpecialsController {
|
||||
constructor(private readonly service: StoreSpecialsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('products.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListStoreSpecialsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':specialId')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('specialId') specialId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, specialId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('products.update')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateStoreSpecialDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':specialId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('specialId') specialId: string,
|
||||
@Body() dto: UpdateStoreSpecialDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, specialId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':specialId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('specialId') specialId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, specialId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { 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 {
|
||||
CreateStoreSpecialDto,
|
||||
ListStoreSpecialsDto,
|
||||
UpdateStoreSpecialDto,
|
||||
} from './dto/store-specials.dto';
|
||||
|
||||
const storeItemInclude = {
|
||||
product: {
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
orderBy: [{ sortOrder: 'asc' as const }, { createdAt: 'asc' as const }],
|
||||
include: {
|
||||
selections: {
|
||||
include: {
|
||||
variation: true,
|
||||
option: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.StoreItemInclude;
|
||||
|
||||
const specialInclude = {
|
||||
items: {
|
||||
orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }],
|
||||
include: {
|
||||
storeItem: {
|
||||
include: storeItemInclude,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.StoreSpecialInclude;
|
||||
|
||||
type SpecialWithItems = Prisma.StoreSpecialGetPayload<{
|
||||
include: typeof specialInclude;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class StoreSpecialsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async listPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const specials = await this.prisma.storeSpecial.findMany({
|
||||
where: { businessId: business.id, isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: specialInclude,
|
||||
});
|
||||
|
||||
const galleryByProduct = await this.loadFirstGalleryUrls(
|
||||
business.id,
|
||||
this.collectProductIds(specials),
|
||||
);
|
||||
|
||||
return {
|
||||
items: specials.map((special) =>
|
||||
this.serializeSpecial(special, true, galleryByProduct),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListStoreSpecialsDto,
|
||||
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: Prisma.StoreSpecialWhereInput = {
|
||||
businessId,
|
||||
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeSpecial.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: specialInclude,
|
||||
}),
|
||||
this.prisma.storeSpecial.count({ where }),
|
||||
]);
|
||||
|
||||
const galleryByProduct = await this.loadFirstGalleryUrls(
|
||||
businessId,
|
||||
this.collectProductIds(items),
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((special) =>
|
||||
this.serializeSpecial(special, false, galleryByProduct),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(
|
||||
businessIdRaw: string,
|
||||
specialIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const specialId = BigInt(specialIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
const special = await this.findSpecialOrThrow(businessId, specialId);
|
||||
const galleryByProduct = await this.loadFirstGalleryUrls(
|
||||
businessId,
|
||||
this.collectProductIds([special]),
|
||||
);
|
||||
return { special: this.serializeSpecial(special, false, galleryByProduct) };
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateStoreSpecialDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
const storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds ?? []);
|
||||
if (storeItemIds.length > 0) {
|
||||
await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const special = await tx.storeSpecial.create({
|
||||
data: {
|
||||
businessId,
|
||||
title: dto.title.trim(),
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.replaceItems(tx, special.id, storeItemIds);
|
||||
return tx.storeSpecial.findUniqueOrThrow({
|
||||
where: { id: special.id },
|
||||
include: specialInclude,
|
||||
});
|
||||
});
|
||||
|
||||
const galleryByProduct = await this.loadFirstGalleryUrls(
|
||||
businessId,
|
||||
this.collectProductIds([created]),
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Store special created successfully',
|
||||
special: this.serializeSpecial(created, false, galleryByProduct),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
specialIdRaw: string,
|
||||
dto: UpdateStoreSpecialDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const specialId = BigInt(specialIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
await this.findSpecialOrThrow(businessId, specialId);
|
||||
|
||||
let storeItemIds: bigint[] | undefined;
|
||||
if (dto.storeItemIds !== undefined) {
|
||||
storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds);
|
||||
await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.storeSpecial.update({
|
||||
where: { id: specialId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (storeItemIds !== undefined) {
|
||||
await this.replaceItems(tx, specialId, storeItemIds);
|
||||
}
|
||||
|
||||
return tx.storeSpecial.findUniqueOrThrow({
|
||||
where: { id: specialId },
|
||||
include: specialInclude,
|
||||
});
|
||||
});
|
||||
|
||||
const galleryByProduct = await this.loadFirstGalleryUrls(
|
||||
businessId,
|
||||
this.collectProductIds([updated]),
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Store special updated successfully',
|
||||
special: this.serializeSpecial(updated, false, galleryByProduct),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(
|
||||
businessIdRaw: string,
|
||||
specialIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const specialId = BigInt(specialIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
await this.findSpecialOrThrow(businessId, specialId);
|
||||
await this.prisma.storeSpecial.delete({ where: { id: specialId } });
|
||||
|
||||
return { message: 'Store special deleted successfully' };
|
||||
}
|
||||
|
||||
private async findSpecialOrThrow(businessId: bigint, specialId: bigint) {
|
||||
const special = await this.prisma.storeSpecial.findFirst({
|
||||
where: { id: specialId, businessId },
|
||||
include: specialInclude,
|
||||
});
|
||||
|
||||
if (!special) {
|
||||
throw new NotFoundException('Store special not found');
|
||||
}
|
||||
|
||||
return special;
|
||||
}
|
||||
|
||||
private parseUniqueStoreItemIds(ids: string[]) {
|
||||
const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||
return unique.map((id) => BigInt(id));
|
||||
}
|
||||
|
||||
private async assertStoreItemsBelongToBusiness(
|
||||
businessId: bigint,
|
||||
storeItemIds: bigint[],
|
||||
) {
|
||||
const found = await this.prisma.storeItem.findMany({
|
||||
where: { businessId, id: { in: storeItemIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (found.length !== storeItemIds.length) {
|
||||
throw new BadRequestException('One or more store items were not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceItems(
|
||||
tx: Prisma.TransactionClient,
|
||||
specialId: bigint,
|
||||
storeItemIds: bigint[],
|
||||
) {
|
||||
await tx.storeSpecialItem.deleteMany({ where: { specialId } });
|
||||
|
||||
await tx.storeSpecialItem.createMany({
|
||||
data: storeItemIds.map((storeItemId, index) => ({
|
||||
specialId,
|
||||
storeItemId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private collectProductIds(specials: SpecialWithItems[]) {
|
||||
const productIds = new Set<bigint>();
|
||||
for (const special of specials) {
|
||||
for (const entry of special.items) {
|
||||
productIds.add(entry.storeItem.productId);
|
||||
}
|
||||
}
|
||||
return [...productIds];
|
||||
}
|
||||
|
||||
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 serializeSpecial(
|
||||
special: SpecialWithItems,
|
||||
publicView: boolean,
|
||||
galleryByProduct: Map<string, string>,
|
||||
) {
|
||||
const items = special.items
|
||||
.filter((entry) => !publicView || entry.storeItem.isActive)
|
||||
.map((entry) => ({
|
||||
...this.serializeStoreItem(entry.storeItem, galleryByProduct),
|
||||
sortOrder: entry.sortOrder,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: special.id.toString(),
|
||||
title: special.title,
|
||||
sortOrder: special.sortOrder,
|
||||
isActive: special.isActive,
|
||||
createdAt: special.createdAt,
|
||||
updatedAt: special.updatedAt,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeStoreItem(
|
||||
storeItem: Prisma.StoreItemGetPayload<{ include: typeof storeItemInclude }>,
|
||||
galleryByProduct: Map<string, string>,
|
||||
) {
|
||||
const product = storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
const thumbnailUrl = product.featuredMedia?.publicUrl ?? null;
|
||||
const productImage =
|
||||
thumbnailUrl ?? galleryByProduct.get(product.id.toString()) ?? null;
|
||||
|
||||
return {
|
||||
id: storeItem.id.toString(),
|
||||
productId: product.id.toString(),
|
||||
productTitle: product.title,
|
||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
productImage,
|
||||
isActive: storeItem.isActive,
|
||||
sortOrder: storeItem.sortOrder,
|
||||
variants: storeItem.variants.map((variant) => {
|
||||
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);
|
||||
|
||||
return {
|
||||
id: variant.id.toString(),
|
||||
label: selections.map((item) => item.value).join(' · ') || product.title,
|
||||
selections,
|
||||
price,
|
||||
discountedPrice:
|
||||
discountedPrice !== null && price !== null && discountedPrice < price
|
||||
? discountedPrice
|
||||
: null,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
rewardPoints: variant.rewardPoints,
|
||||
isFestival: variant.isFestival,
|
||||
sortOrder: variant.sortOrder,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import {
|
||||
PublicStoreSpecialsController,
|
||||
StoreSpecialsController,
|
||||
} from './store-specials.controller';
|
||||
import { StoreSpecialsService } from './store-specials.service';
|
||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||
import { StoreItemsService } from './store-items.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [
|
||||
StoreItemsController,
|
||||
PublicStoreItemsController,
|
||||
PublicStoreSpecialsController,
|
||||
StoreSpecialsController,
|
||||
],
|
||||
providers: [StoreItemsService, StoreSpecialsService],
|
||||
exports: [StoreItemsService, StoreSpecialsService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
Reference in New Issue
Block a user