Add shipping cost groups and reject spam contact-form SMS.
Wire cart/checkout shipping fees with category groups and static rates, reject gibberish contact submissions, and SMS only valid Iranian mobiles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1a49576752
commit
88ef4e82a1
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { CartController } from './cart.controller';
|
||||
import { CartService } from './cart.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
BusinessSettingsModule,
|
||||
StoreModule,
|
||||
],
|
||||
controllers: [CartController],
|
||||
providers: [CartService],
|
||||
})
|
||||
|
||||
+114
-10
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
|
||||
|
||||
const cartVariantInclude = {
|
||||
storeItem: {
|
||||
@@ -59,6 +60,7 @@ export class CartService {
|
||||
private readonly orders: OrdersService,
|
||||
private readonly payments: PaymentsService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
private readonly shippingCostGroups: ShippingCostGroupsService,
|
||||
) {}
|
||||
|
||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||
@@ -66,7 +68,7 @@ export class CartService {
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
return { cart: this.serializeCart(cart) };
|
||||
return { cart: await this.serializeCart(cart) };
|
||||
}
|
||||
|
||||
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
||||
@@ -97,7 +99,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item quantity updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Item added to cart',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +168,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item removed',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +182,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart cleared',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +206,71 @@ export class CartService {
|
||||
dto.shippingAddress,
|
||||
);
|
||||
|
||||
const deliveryMode =
|
||||
dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery'
|
||||
? dto.deliveryMode
|
||||
: dto.addressId
|
||||
? 'delivery'
|
||||
: 'pickup';
|
||||
|
||||
const selectedMethodsByGroupId: Record<string, string> = {};
|
||||
for (const group of dto.shippingGroups ?? []) {
|
||||
if (group.methodId?.trim()) {
|
||||
selectedMethodsByGroupId[group.groupId] = group.methodId.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = [
|
||||
...new Set(
|
||||
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||
),
|
||||
];
|
||||
const assignments =
|
||||
productIds.length === 0
|
||||
? []
|
||||
: await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: 'product',
|
||||
entityId: { in: productIds },
|
||||
},
|
||||
select: { entityId: true, categoryId: true },
|
||||
});
|
||||
const categoryIdsByProduct = new Map<string, string[]>();
|
||||
for (const row of assignments) {
|
||||
const key = row.entityId.toString();
|
||||
const list = categoryIdsByProduct.get(key) ?? [];
|
||||
list.push(row.categoryId.toString());
|
||||
categoryIdsByProduct.set(key, list);
|
||||
}
|
||||
|
||||
const shippingQuote = await this.shippingCostGroups.quoteForCheckout({
|
||||
businessId,
|
||||
deliveryMode,
|
||||
provinceName:
|
||||
deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null,
|
||||
cartItems: cart.items.map((item) => {
|
||||
const product = item.storeItemVariant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
const nameFa = (content.nameFa as string | null | undefined)?.trim();
|
||||
return {
|
||||
productName: nameFa || product.title,
|
||||
categoryIds:
|
||||
categoryIdsByProduct.get(product.id.toString()) ?? [],
|
||||
};
|
||||
}),
|
||||
selectedMethodsByGroupId,
|
||||
});
|
||||
|
||||
const enrichedShippingAddress = {
|
||||
...shippingAddress,
|
||||
deliveryMode,
|
||||
shipping: {
|
||||
total: shippingQuote.total,
|
||||
groups: shippingQuote.groups,
|
||||
},
|
||||
};
|
||||
|
||||
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
||||
let gatewayType: PaymentGatewayId | undefined;
|
||||
|
||||
@@ -234,7 +301,8 @@ export class CartService {
|
||||
createdBy: actor.id,
|
||||
source: 'website',
|
||||
cartItems: cart.items,
|
||||
shippingAddress,
|
||||
shippingAddress: enrichedShippingAddress,
|
||||
shippingTotal: shippingQuote.total,
|
||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||
customerNotes: dto.customerNotes?.trim() || null,
|
||||
adminNotes: null,
|
||||
@@ -393,8 +461,40 @@ export class CartService {
|
||||
}
|
||||
}
|
||||
|
||||
private serializeCart(cart: CartWithItems) {
|
||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
||||
private async serializeCart(cart: CartWithItems) {
|
||||
const productIds = [
|
||||
...new Set(
|
||||
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||
),
|
||||
];
|
||||
|
||||
const assignments =
|
||||
productIds.length === 0
|
||||
? []
|
||||
: await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId: cart.businessId,
|
||||
entityType: 'product',
|
||||
entityId: { in: productIds },
|
||||
},
|
||||
select: { entityId: true, categoryId: true },
|
||||
});
|
||||
|
||||
const categoryIdsByProduct = new Map<string, string[]>();
|
||||
for (const row of assignments) {
|
||||
const key = row.entityId.toString();
|
||||
const list = categoryIdsByProduct.get(key) ?? [];
|
||||
list.push(row.categoryId.toString());
|
||||
categoryIdsByProduct.set(key, list);
|
||||
}
|
||||
|
||||
const items = cart.items.map((item) => {
|
||||
const productId = item.storeItemVariant.storeItem.product.id.toString();
|
||||
return this.serializeCartItem(
|
||||
item,
|
||||
categoryIdsByProduct.get(productId) ?? [],
|
||||
);
|
||||
});
|
||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
|
||||
return {
|
||||
@@ -407,7 +507,10 @@ export class CartService {
|
||||
};
|
||||
}
|
||||
|
||||
private serializeCartItem(item: CartWithItems['items'][number]) {
|
||||
private serializeCartItem(
|
||||
item: CartWithItems['items'][number],
|
||||
categoryIds: string[],
|
||||
) {
|
||||
const variant = item.storeItemVariant;
|
||||
const product = variant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
@@ -431,6 +534,7 @@ export class CartService {
|
||||
productTitle: product.title,
|
||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||
categoryIds,
|
||||
sku: variant.sku,
|
||||
selections,
|
||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
|
||||
landline?: string;
|
||||
}
|
||||
|
||||
export class CheckoutShippingGroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
groupId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
methodId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
methodLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
itemNames?: string[];
|
||||
}
|
||||
|
||||
export class CheckoutCartDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
|
||||
@Type(() => ShippingAddressDto)
|
||||
shippingAddress?: ShippingAddressDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['delivery', 'pickup'])
|
||||
deliveryMode?: 'delivery' | 'pickup';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CheckoutShippingGroupDto)
|
||||
shippingGroups?: CheckoutShippingGroupDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerNotes?: string;
|
||||
|
||||
Reference in New Issue
Block a user