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,169 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { OrderStatus, TransactionType } from '@prisma/client';
|
||||
import { ShippingAddressDto } from '../../cart/dto/cart.dto';
|
||||
|
||||
export class AdminOrderPaymentDto {
|
||||
@IsEnum(TransactionType)
|
||||
type!: TransactionType;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
@Type(() => Number)
|
||||
amount!: number;
|
||||
|
||||
@ValidateIf((dto) => dto.type === TransactionType.pos)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
posType?: string;
|
||||
|
||||
@ValidateIf((dto) => dto.type === TransactionType.transfer)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
transferAccount?: string;
|
||||
|
||||
@ValidateIf((dto) => dto.type === TransactionType.transfer)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
transferRefNumber?: string;
|
||||
|
||||
@ValidateIf((dto) => dto.type === TransactionType.e_payment_gate)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gatewayType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class OrderItemInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
storeItemVariantId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateAdminOrderDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
customerUserId!: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderItemInputDto)
|
||||
@ArrayMinSize(1)
|
||||
items!: OrderItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
addressId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ShippingAddressDto)
|
||||
shippingAddress?: ShippingAddressDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(OrderStatus)
|
||||
status?: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminOrderPaymentDto)
|
||||
@ArrayMinSize(1)
|
||||
payments?: AdminOrderPaymentDto[];
|
||||
}
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@IsEnum(OrderStatus)
|
||||
status?: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
processStepId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminNotes?: string;
|
||||
}
|
||||
|
||||
export class ListOrdersDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(OrderStatus)
|
||||
status?: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
customerUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
orderNumber?: string;
|
||||
|
||||
/** Matches customer first name, last name, or cell 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,80 @@
|
||||
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 {
|
||||
CreateAdminOrderDto,
|
||||
ListOrdersDto,
|
||||
UpdateOrderDto,
|
||||
} from './dto/order.dto';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Controller('businesses/:businessId/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OrdersController {
|
||||
constructor(private readonly service: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListOrdersDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':orderId')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('orderId') orderId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, orderId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(BusinessPermissionGuard)
|
||||
@RequireBusinessPermission('orders.create')
|
||||
createAdmin(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateAdminOrderDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.createAdmin(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':orderId')
|
||||
@UseGuards(BusinessPermissionGuard)
|
||||
@RequireBusinessPermission('orders.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('orderId') orderId: string,
|
||||
@Body() dto: UpdateOrderDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, orderId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':orderId')
|
||||
@UseGuards(BusinessPermissionGuard)
|
||||
@RequireBusinessPermission('orders.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('orderId') orderId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, orderId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,922 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContentStatus,
|
||||
MediaEntityType,
|
||||
OrderSource,
|
||||
OrderStatus,
|
||||
Prisma,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateAdminOrderDto,
|
||||
ListOrdersDto,
|
||||
UpdateOrderDto,
|
||||
} from './dto/order.dto';
|
||||
import { ShippingAddressDto } from '../cart/dto/cart.dto';
|
||||
|
||||
type OrderWithItems = Prisma.OrderGetPayload<{
|
||||
include: typeof ORDER_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
|
||||
const ORDER_RELATIONS_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
transactions: true,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TransactionPaymentInput = {
|
||||
type: TransactionType;
|
||||
amount: number;
|
||||
posType?: string;
|
||||
gatewayType?: string;
|
||||
transferAccount?: string;
|
||||
transferRefNumber?: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
type CartItemForOrder = {
|
||||
quantity: number;
|
||||
storeItemVariant: {
|
||||
id: bigint;
|
||||
sku: string | null;
|
||||
price: Prisma.Decimal | null;
|
||||
compareAtPrice: Prisma.Decimal | null;
|
||||
stockQuantity: number | null;
|
||||
storeItem: {
|
||||
product: {
|
||||
id: bigint;
|
||||
title: string;
|
||||
status: ContentStatus;
|
||||
};
|
||||
};
|
||||
selections: {
|
||||
variation: { id: bigint; name: string };
|
||||
option: { id: bigint; label: string };
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
type OrderItemInput = {
|
||||
storeItemVariantId: bigint;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListOrdersDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const scope = await this.resolveReadScope(businessId, actor);
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
businessId,
|
||||
...(scope === 'customer' ? { userId: actor.id } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(scope === 'admin' && query.customerUserId
|
||||
? { userId: BigInt(query.customerUserId) }
|
||||
: {}),
|
||||
...(query.orderNumber
|
||||
? {
|
||||
orderNumber: {
|
||||
contains: query.orderNumber.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(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 } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(scope === 'admin' && query.customerQuery?.trim()
|
||||
? {
|
||||
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: ORDER_RELATIONS_INCLUDE,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: await this.serializeManyWithGallery(items),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const orderId = BigInt(orderIdRaw);
|
||||
const scope = await this.resolveReadScope(businessId, actor);
|
||||
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
businessId,
|
||||
...(scope === 'customer' ? { userId: actor.id } : {}),
|
||||
},
|
||||
include: ORDER_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
return { order: await this.serializeWithGallery(order) };
|
||||
}
|
||||
|
||||
async createAdmin(
|
||||
businessIdRaw: string,
|
||||
dto: CreateAdminOrderDto,
|
||||
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 shippingAddress = await this.resolveShippingAddress(
|
||||
customerUserId,
|
||||
dto.addressId,
|
||||
dto.shippingAddress,
|
||||
false,
|
||||
);
|
||||
|
||||
const preparedItems = await this.prepareItems(
|
||||
businessId,
|
||||
dto.items.map((item) => ({
|
||||
storeItemVariantId: BigInt(item.storeItemVariantId),
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
);
|
||||
|
||||
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
|
||||
if (dto.payments?.length) {
|
||||
const paymentTotal = dto.payments.reduce(
|
||||
(sum, payment) => sum + Number(payment.amount),
|
||||
0,
|
||||
);
|
||||
if (Math.abs(paymentTotal - orderTotal) > 0.009) {
|
||||
throw new BadRequestException(
|
||||
'Payment amounts must equal order total',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const order = await this.createOrder({
|
||||
businessId,
|
||||
userId: customerUserId,
|
||||
createdBy: actor.id,
|
||||
source: 'admin',
|
||||
items: preparedItems,
|
||||
shippingAddress,
|
||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||
customerNotes: dto.customerNotes?.trim() || null,
|
||||
adminNotes: dto.adminNotes?.trim() || null,
|
||||
status: dto.status ?? 'pending',
|
||||
payments: dto.payments?.map((payment) => ({
|
||||
type: payment.type,
|
||||
amount: Number(payment.amount),
|
||||
posType: payment.posType,
|
||||
gatewayType: payment.gatewayType,
|
||||
transferAccount: payment.transferAccount,
|
||||
transferRefNumber: payment.transferRefNumber,
|
||||
notes: payment.notes?.trim() || null,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Order created successfully',
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
orderIdRaw: string,
|
||||
dto: UpdateOrderDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const orderId = BigInt(orderIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||
|
||||
const existing = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
if (dto.processStepId !== undefined) {
|
||||
await this.assertValidProcessStepId(businessId, dto.processStepId);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(dto.processStepId !== undefined
|
||||
? { processStepId: dto.processStepId }
|
||||
: {}),
|
||||
...(dto.adminNotes !== undefined
|
||||
? { adminNotes: dto.adminNotes.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
include: ORDER_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Order updated successfully',
|
||||
order: await this.serializeWithGallery(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const orderId = BigInt(orderIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||
|
||||
const existing = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
await this.prisma.order.delete({
|
||||
where: { id: orderId },
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Order removed successfully',
|
||||
};
|
||||
}
|
||||
|
||||
async createFromCart(input: {
|
||||
businessId: bigint;
|
||||
userId: bigint;
|
||||
createdBy: bigint;
|
||||
source: OrderSource;
|
||||
cartItems: CartItemForOrder[];
|
||||
shippingAddress: Record<string, unknown>;
|
||||
addressId: bigint | null;
|
||||
customerNotes: string | null;
|
||||
adminNotes: string | null;
|
||||
status: OrderStatus;
|
||||
payment: Omit<TransactionPaymentInput, 'amount'>;
|
||||
}) {
|
||||
const preparedItems = await this.prepareItems(
|
||||
input.businessId,
|
||||
input.cartItems.map((item) => ({
|
||||
storeItemVariantId: item.storeItemVariant.id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
);
|
||||
|
||||
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
|
||||
return this.createOrder({
|
||||
businessId: input.businessId,
|
||||
userId: input.userId,
|
||||
createdBy: input.createdBy,
|
||||
source: input.source,
|
||||
items: preparedItems,
|
||||
shippingAddress: input.shippingAddress,
|
||||
addressId: input.addressId,
|
||||
customerNotes: input.customerNotes,
|
||||
adminNotes: input.adminNotes,
|
||||
status: input.status,
|
||||
payments: [
|
||||
{
|
||||
...input.payment,
|
||||
amount: orderTotal,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async createOrder(input: {
|
||||
businessId: bigint;
|
||||
userId: bigint;
|
||||
createdBy: bigint;
|
||||
source: OrderSource;
|
||||
items: PreparedOrderItem[];
|
||||
shippingAddress: Record<string, unknown>;
|
||||
addressId: bigint | null;
|
||||
customerNotes: string | null;
|
||||
adminNotes: string | null;
|
||||
status: OrderStatus;
|
||||
payments?: TransactionPaymentInput[];
|
||||
}) {
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const shippingTotal = 0;
|
||||
const discountTotal = 0;
|
||||
const total = subtotal + shippingTotal - discountTotal;
|
||||
const processStepId = await this.defaultProcessStepId(input.businessId);
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const orderNumber = await this.generateOrderNumber(tx, input.businessId);
|
||||
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
businessId: input.businessId,
|
||||
userId: input.userId,
|
||||
orderNumber,
|
||||
status: input.status,
|
||||
processStepId,
|
||||
source: input.source,
|
||||
subtotal,
|
||||
shippingTotal,
|
||||
discountTotal,
|
||||
total,
|
||||
shippingAddress: input.shippingAddress as Prisma.InputJsonValue,
|
||||
addressId: input.addressId,
|
||||
customerNotes: input.customerNotes,
|
||||
adminNotes: input.adminNotes,
|
||||
createdBy: input.createdBy,
|
||||
items: {
|
||||
create: input.items.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: ORDER_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (input.payments?.length) {
|
||||
for (const payment of input.payments) {
|
||||
const normalized = this.normalizePaymentFields(payment);
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
businessId: input.businessId,
|
||||
orderId: created.id,
|
||||
userId: input.userId,
|
||||
type: normalized.type,
|
||||
amount: normalized.amount,
|
||||
status: input.source === 'admin' ? 'completed' : 'pending',
|
||||
posType: normalized.posType ?? null,
|
||||
gatewayType: normalized.gatewayType ?? null,
|
||||
transferAccount: normalized.transferAccount ?? null,
|
||||
transferRefNumber: normalized.transferRefNumber ?? null,
|
||||
notes: normalized.notes ?? null,
|
||||
createdBy: input.createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of input.items) {
|
||||
if (item.stockQuantity !== null) {
|
||||
const updated = await tx.storeItemVariant.updateMany({
|
||||
where: {
|
||||
id: item.storeItemVariantId,
|
||||
businessId: input.businessId,
|
||||
stockQuantity: { gte: item.quantity },
|
||||
},
|
||||
data: {
|
||||
stockQuantity: { decrement: item.quantity },
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new BadRequestException(
|
||||
`Insufficient stock for variant ${item.storeItemVariantId.toString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created as OrderWithItems;
|
||||
});
|
||||
|
||||
const orderWithTransactions = input.payments?.length
|
||||
? await this.prisma.order.findUniqueOrThrow({
|
||||
where: { id: order.id },
|
||||
include: ORDER_RELATIONS_INCLUDE,
|
||||
})
|
||||
: await this.prisma.order.findUniqueOrThrow({
|
||||
where: { id: order.id },
|
||||
include: ORDER_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
return this.serializeWithGallery(orderWithTransactions);
|
||||
}
|
||||
|
||||
private async prepareItems(businessId: bigint, items: OrderItemInput[]) {
|
||||
if (!items.length) {
|
||||
throw new BadRequestException('At least one order item is required');
|
||||
}
|
||||
|
||||
const prepared: PreparedOrderItem[] = [];
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.stockQuantity !== null && item.quantity > variant.stockQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Insufficient stock for variant ${item.storeItemVariantId.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
selectionsSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private async generateOrderNumber(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
) {
|
||||
const today = new Date();
|
||||
const datePart = [
|
||||
today.getFullYear(),
|
||||
String(today.getMonth() + 1).padStart(2, '0'),
|
||||
String(today.getDate()).padStart(2, '0'),
|
||||
].join('');
|
||||
|
||||
const prefix = `ORD-${datePart}-`;
|
||||
const latest = await tx.order.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
orderNumber: { startsWith: prefix },
|
||||
},
|
||||
orderBy: { orderNumber: 'desc' },
|
||||
select: { orderNumber: true },
|
||||
});
|
||||
|
||||
const nextSeq = latest
|
||||
? Number(latest.orderNumber.slice(prefix.length)) + 1
|
||||
: 1;
|
||||
|
||||
return `${prefix}${String(nextSeq).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
private async resolveShippingAddress(
|
||||
userId: bigint,
|
||||
addressIdRaw: string | undefined,
|
||||
inline: ShippingAddressDto | undefined,
|
||||
required: boolean,
|
||||
) {
|
||||
if (addressIdRaw) {
|
||||
const address = await this.prisma.address.findFirst({
|
||||
where: { id: BigInt(addressIdRaw), userId },
|
||||
});
|
||||
|
||||
if (!address) {
|
||||
throw new NotFoundException('Shipping address not found');
|
||||
}
|
||||
|
||||
return {
|
||||
label: address.label,
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
};
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
return {
|
||||
province: inline.province.trim(),
|
||||
city: inline.city.trim(),
|
||||
address: inline.address.trim(),
|
||||
postalCode: inline.postalCode?.trim() || null,
|
||||
landline: inline.landline?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (required) {
|
||||
throw new BadRequestException(
|
||||
'Provide addressId or shippingAddress for the order',
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private normalizePaymentFields(
|
||||
payment: TransactionPaymentInput,
|
||||
): TransactionPaymentInput {
|
||||
switch (payment.type) {
|
||||
case TransactionType.pos:
|
||||
return {
|
||||
...payment,
|
||||
posType: payment.posType?.trim() || 'operator',
|
||||
gatewayType: undefined,
|
||||
transferAccount: undefined,
|
||||
transferRefNumber: undefined,
|
||||
};
|
||||
case TransactionType.cash:
|
||||
return {
|
||||
...payment,
|
||||
posType: undefined,
|
||||
gatewayType: undefined,
|
||||
transferAccount: undefined,
|
||||
transferRefNumber: undefined,
|
||||
};
|
||||
case TransactionType.transfer:
|
||||
return {
|
||||
...payment,
|
||||
posType: undefined,
|
||||
gatewayType: undefined,
|
||||
transferAccount: payment.transferAccount?.trim(),
|
||||
transferRefNumber: payment.transferRefNumber?.trim(),
|
||||
};
|
||||
case TransactionType.e_payment_gate:
|
||||
return {
|
||||
...payment,
|
||||
posType: undefined,
|
||||
transferAccount: undefined,
|
||||
transferRefNumber: undefined,
|
||||
gatewayType: payment.gatewayType?.trim() || 'default',
|
||||
};
|
||||
default:
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
|
||||
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 defaultProcessStepId(businessId: bigint) {
|
||||
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
||||
return steps[0]?.id ?? 'processing';
|
||||
}
|
||||
|
||||
private async assertValidProcessStepId(businessId: bigint, processStepId: string) {
|
||||
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
||||
const valid = steps.some((step) => step.id === processStepId);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Invalid order process step');
|
||||
}
|
||||
}
|
||||
|
||||
private async serializeWithGallery(order: OrderWithItems) {
|
||||
const [serialized] = await this.serializeManyWithGallery([order]);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
private async serializeManyWithGallery(orders: OrderWithItems[]) {
|
||||
if (orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const businessId = orders[0].businessId;
|
||||
const productIds = this.collectProductIdsNeedingGallery(orders);
|
||||
const [galleryByProductId, processSteps] = await Promise.all([
|
||||
this.loadProductGalleryUrls(businessId, productIds),
|
||||
this.businessSettings.getOrderProcessSteps(businessId),
|
||||
]);
|
||||
|
||||
return orders.map((order) =>
|
||||
this.serialize(order, galleryByProductId, processSteps),
|
||||
);
|
||||
}
|
||||
|
||||
private collectProductIdsNeedingGallery(orders: OrderWithItems[]) {
|
||||
const ids = new Set<bigint>();
|
||||
|
||||
for (const order of orders) {
|
||||
for (const item of order.items) {
|
||||
if (!item.product.featuredMedia?.publicUrl) {
|
||||
ids.add(item.productId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
private async loadProductGalleryUrls(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 resolveProductImage(
|
||||
product: OrderWithItems['items'][number]['product'],
|
||||
galleryByProductId: Map<string, string>,
|
||||
) {
|
||||
const thumbnailUrl = product.featuredMedia?.publicUrl ?? null;
|
||||
if (thumbnailUrl) {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
return galleryByProductId.get(product.id.toString()) ?? null;
|
||||
}
|
||||
|
||||
private serialize(
|
||||
order: OrderWithItems,
|
||||
galleryByProductId: Map<string, string> = new Map(),
|
||||
processSteps: { id: string; label: string; color: string }[] = [],
|
||||
) {
|
||||
const processStep =
|
||||
processSteps.find((step) => step.id === order.processStepId) ?? null;
|
||||
|
||||
return {
|
||||
id: order.id.toString(),
|
||||
businessId: order.businessId.toString(),
|
||||
orderNumber: order.orderNumber,
|
||||
status: order.status,
|
||||
processStepId: order.processStepId,
|
||||
processStepLabel: processStep?.label ?? null,
|
||||
processStepColor: processStep?.color ?? null,
|
||||
source: order.source,
|
||||
subtotal: Number(order.subtotal),
|
||||
shippingTotal: Number(order.shippingTotal),
|
||||
discountTotal: Number(order.discountTotal),
|
||||
total: Number(order.total),
|
||||
shippingAddress: order.shippingAddress,
|
||||
addressId: order.addressId?.toString() ?? null,
|
||||
customerNotes: order.customerNotes,
|
||||
adminNotes: order.adminNotes,
|
||||
createdBy: order.createdBy?.toString() ?? null,
|
||||
createdAt: order.createdAt,
|
||||
updatedAt: order.updatedAt,
|
||||
customer: {
|
||||
id: order.customer.id.toString(),
|
||||
firstName: order.customer.firstName,
|
||||
lastName: order.customer.lastName,
|
||||
cellNumber: order.customer.cellNumber,
|
||||
email: order.customer.email,
|
||||
},
|
||||
items: order.items.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
|
||||
productId: item.productId.toString(),
|
||||
productTitle: item.productTitle,
|
||||
productImage: this.resolveProductImage(item.product, galleryByProductId),
|
||||
variantSku: item.variantSku,
|
||||
unitPrice: Number(item.unitPrice),
|
||||
compareAtPrice:
|
||||
item.compareAtPrice === null ? null : Number(item.compareAtPrice),
|
||||
quantity: item.quantity,
|
||||
lineTotal: Number(item.lineTotal),
|
||||
selections: item.selectionsSnapshot,
|
||||
})),
|
||||
transactions: (order.transactions ?? []).map((transaction) =>
|
||||
this.serializeTransaction(transaction),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeTransaction(transaction: Transaction) {
|
||||
return {
|
||||
id: transaction.id.toString(),
|
||||
type: transaction.type,
|
||||
status: transaction.status,
|
||||
amount: Number(transaction.amount),
|
||||
posType: transaction.posType,
|
||||
gatewayType: transaction.gatewayType,
|
||||
transferAccount: transaction.transferAccount,
|
||||
transferRefNumber: transaction.transferRefNumber,
|
||||
notes: transaction.notes,
|
||||
createdBy: transaction.createdBy?.toString() ?? null,
|
||||
createdAt: transaction.createdAt,
|
||||
updatedAt: transaction.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveReadScope(businessId: bigint, actor: AuthUser) {
|
||||
if (
|
||||
await this.permissions.hasBusinessPermission(
|
||||
actor.id,
|
||||
businessId,
|
||||
'orders.read',
|
||||
)
|
||||
) {
|
||||
return 'admin' as const;
|
||||
}
|
||||
|
||||
await this.assertBusinessCustomer(businessId, actor.id);
|
||||
return 'customer' as const;
|
||||
}
|
||||
|
||||
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('User is not a customer of 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PreparedOrderItem = {
|
||||
storeItemVariantId: bigint;
|
||||
productId: bigint;
|
||||
productTitle: string;
|
||||
variantSku: string | null;
|
||||
unitPrice: number;
|
||||
compareAtPrice: number;
|
||||
quantity: number;
|
||||
lineTotal: number;
|
||||
stockQuantity: number | null;
|
||||
selectionsSnapshot: {
|
||||
variationId: string;
|
||||
variationName: string;
|
||||
optionId: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
Reference in New Issue
Block a user