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,73 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ShoppingCardItemInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
storeItemVariantId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateShoppingCardDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
customerUserId!: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ShoppingCardItemInputDto)
|
||||
@ArrayMinSize(1)
|
||||
items!: ShoppingCardItemInputDto[];
|
||||
}
|
||||
|
||||
export class ListShoppingCardsDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
customerQuery?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
minTotal?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
maxTotal?: number;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
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 {
|
||||
CreateShoppingCardDto,
|
||||
ListShoppingCardsDto,
|
||||
} from './dto/shopping-card.dto';
|
||||
import { ShoppingCardsService } from './shopping-cards.service';
|
||||
|
||||
@Controller('businesses/:businessId/shopping-cards')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShoppingCardsController {
|
||||
constructor(private readonly service: ShoppingCardsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListShoppingCardsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':cardId')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('cardId') cardId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, cardId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(BusinessPermissionGuard)
|
||||
@RequireBusinessPermission('orders.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateShoppingCardDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':cardId')
|
||||
@UseGuards(BusinessPermissionGuard)
|
||||
@RequireBusinessPermission('orders.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('cardId') cardId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, cardId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ShoppingCardsController } from './shopping-cards.controller';
|
||||
import { ShoppingCardsService } from './shopping-cards.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ShoppingCardsController],
|
||||
providers: [ShoppingCardsService],
|
||||
exports: [ShoppingCardsService],
|
||||
})
|
||||
export class ShoppingCardsModule {}
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateShoppingCardDto,
|
||||
ListShoppingCardsDto,
|
||||
} from './dto/shopping-card.dto';
|
||||
|
||||
type ShoppingCardWithItems = Prisma.ShoppingCardGetPayload<{
|
||||
include: {
|
||||
items: true;
|
||||
customer: {
|
||||
select: {
|
||||
id: true;
|
||||
firstName: true;
|
||||
lastName: true;
|
||||
cellNumber: true;
|
||||
email: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
type PreparedShoppingCardItem = {
|
||||
storeItemVariantId: bigint;
|
||||
productId: bigint;
|
||||
productTitle: string;
|
||||
variantSku: string | null;
|
||||
unitPrice: number;
|
||||
compareAtPrice: number;
|
||||
quantity: number;
|
||||
lineTotal: number;
|
||||
selectionsSnapshot: {
|
||||
variationId: string;
|
||||
variationName: string;
|
||||
optionId: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ShoppingCardsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListShoppingCardsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.ShoppingCardWhereInput = {
|
||||
businessId,
|
||||
...(query.dateFrom || query.dateTo
|
||||
? {
|
||||
createdAt: {
|
||||
...(query.dateFrom ? { gte: new Date(query.dateFrom) } : {}),
|
||||
...(query.dateTo
|
||||
? {
|
||||
lte: (() => {
|
||||
const end = new Date(query.dateTo);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return end;
|
||||
})(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(query.minTotal !== undefined || query.maxTotal !== undefined
|
||||
? {
|
||||
total: {
|
||||
...(query.minTotal !== undefined ? { gte: query.minTotal } : {}),
|
||||
...(query.maxTotal !== undefined ? { lte: query.maxTotal } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(query.customerQuery?.trim()
|
||||
? {
|
||||
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.shoppingCard.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
items: true,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.shoppingCard.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const cardId = BigInt(cardIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.read');
|
||||
|
||||
const card = await this.prisma.shoppingCard.findFirst({
|
||||
where: { id: cardId, businessId },
|
||||
include: {
|
||||
items: true,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!card) {
|
||||
throw new NotFoundException('Shopping card not found');
|
||||
}
|
||||
|
||||
return { card: this.serialize(card) };
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateShoppingCardDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.create');
|
||||
|
||||
const customerUserId = BigInt(dto.customerUserId);
|
||||
await this.assertBusinessCustomer(businessId, customerUserId);
|
||||
|
||||
const preparedItems = await this.prepareItems(
|
||||
businessId,
|
||||
dto.items.map((item) => ({
|
||||
storeItemVariantId: BigInt(item.storeItemVariantId),
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
);
|
||||
|
||||
const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const total = subtotal;
|
||||
|
||||
const created = await this.prisma.shoppingCard.create({
|
||||
data: {
|
||||
businessId,
|
||||
userId: customerUserId,
|
||||
subtotal,
|
||||
total,
|
||||
createdBy: actor.id,
|
||||
items: {
|
||||
create: preparedItems.map((item) => ({
|
||||
storeItemVariantId: item.storeItemVariantId,
|
||||
productId: item.productId,
|
||||
productTitle: item.productTitle,
|
||||
variantSku: item.variantSku,
|
||||
unitPrice: item.unitPrice,
|
||||
compareAtPrice: item.compareAtPrice,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.lineTotal,
|
||||
selectionsSnapshot: item.selectionsSnapshot,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
items: true,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Shopping card saved',
|
||||
card: this.serialize(created),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const cardId = BigInt(cardIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||
|
||||
const existing = await this.prisma.shoppingCard.findFirst({
|
||||
where: { id: cardId, businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Shopping card not found');
|
||||
}
|
||||
|
||||
await this.prisma.shoppingCard.delete({ where: { id: cardId } });
|
||||
|
||||
return { message: 'Shopping card removed' };
|
||||
}
|
||||
|
||||
private async prepareItems(
|
||||
businessId: bigint,
|
||||
items: { storeItemVariantId: bigint; quantity: number }[],
|
||||
) {
|
||||
if (!items.length) {
|
||||
throw new BadRequestException('At least one item is required');
|
||||
}
|
||||
|
||||
const prepared: PreparedShoppingCardItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const variant = await this.prisma.storeItemVariant.findFirst({
|
||||
where: {
|
||||
id: item.storeItemVariantId,
|
||||
businessId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
storeItem: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
selections: {
|
||||
include: {
|
||||
variation: true,
|
||||
option: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
throw new NotFoundException(
|
||||
`Store item variant ${item.storeItemVariantId.toString()} not found or unavailable`,
|
||||
);
|
||||
}
|
||||
|
||||
const product = variant.storeItem.product;
|
||||
|
||||
if (product.status !== ContentStatus.published) {
|
||||
throw new BadRequestException(
|
||||
`Product "${product.title}" is not available for purchase`,
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.price === null) {
|
||||
throw new BadRequestException(
|
||||
`Store item variant ${item.storeItemVariantId.toString()} has no price configured`,
|
||||
);
|
||||
}
|
||||
|
||||
const price = Number(variant.price);
|
||||
const compareAtPrice =
|
||||
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
|
||||
const unitPrice =
|
||||
compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price;
|
||||
const selectionsSnapshot = variant.selections.map((selection) => ({
|
||||
variationId: selection.variation.id.toString(),
|
||||
variationName: selection.variation.name,
|
||||
optionId: selection.option.id.toString(),
|
||||
value: selection.option.label,
|
||||
}));
|
||||
|
||||
prepared.push({
|
||||
storeItemVariantId: variant.id,
|
||||
productId: product.id,
|
||||
productTitle: product.title,
|
||||
variantSku: variant.sku,
|
||||
unitPrice,
|
||||
compareAtPrice: price,
|
||||
quantity: item.quantity,
|
||||
lineTotal: unitPrice * item.quantity,
|
||||
selectionsSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private serialize(card: ShoppingCardWithItems) {
|
||||
return {
|
||||
id: card.id.toString(),
|
||||
businessId: card.businessId.toString(),
|
||||
subtotal: Number(card.subtotal),
|
||||
total: Number(card.total),
|
||||
createdBy: card.createdBy?.toString() ?? null,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt,
|
||||
customer: {
|
||||
id: card.customer.id.toString(),
|
||||
firstName: card.customer.firstName,
|
||||
lastName: card.customer.lastName,
|
||||
cellNumber: card.customer.cellNumber,
|
||||
email: card.customer.email,
|
||||
},
|
||||
items: card.items.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
|
||||
productId: item.productId.toString(),
|
||||
productTitle: item.productTitle,
|
||||
variantSku: item.variantSku,
|
||||
unitPrice: Number(item.unitPrice),
|
||||
compareAtPrice:
|
||||
item.compareAtPrice === null ? null : Number(item.compareAtPrice),
|
||||
quantity: item.quantity,
|
||||
lineTotal: Number(item.lineTotal),
|
||||
selections: this.readSelections(item.selectionsSnapshot),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private readSelections(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const variationId =
|
||||
typeof record.variationId === 'string' ? record.variationId : '';
|
||||
const variationName =
|
||||
typeof record.variationName === 'string' ? record.variationName : '';
|
||||
const optionId = typeof record.optionId === 'string' ? record.optionId : '';
|
||||
const label = typeof record.value === 'string' ? record.value : '';
|
||||
if (!variationId || !optionId) return null;
|
||||
return { variationId, variationName, optionId, value: label };
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
}
|
||||
|
||||
private buildCustomerSearchFilter(query: string): Prisma.UserWhereInput {
|
||||
const digits = query.replace(/\D/g, '');
|
||||
const or: Prisma.UserWhereInput[] = [
|
||||
{ firstName: { contains: query, mode: 'insensitive' } },
|
||||
{ lastName: { contains: query, mode: 'insensitive' } },
|
||||
];
|
||||
|
||||
if (digits.length >= 3) {
|
||||
or.push({ cellNumber: { contains: digits } });
|
||||
if (digits.startsWith('0')) {
|
||||
or.push({ cellNumber: { contains: `+98${digits.slice(1)}` } });
|
||||
} else if (digits.startsWith('98')) {
|
||||
or.push({ cellNumber: { contains: `+${digits}` } });
|
||||
} else if (digits.length === 10 && digits.startsWith('9')) {
|
||||
or.push({ cellNumber: { contains: `+98${digits}` } });
|
||||
}
|
||||
}
|
||||
|
||||
return { OR: or };
|
||||
}
|
||||
|
||||
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Customer is not registered for this business');
|
||||
}
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user