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
+169
View File
@@ -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;
}
+80
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+922
View File
@@ -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;
}[];
};