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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+223
View File
@@ -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,
};
+71
View File
@@ -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;
}
+133
View File
@@ -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
+89
View File
@@ -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);
}
}
+425
View File
@@ -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`,
);
}
}
}
+23
View File
@@ -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 {}