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:
Alireza Hassani
2026-10-02 21:52:41 +03:30
co-authored by Cursor
parent 1a49576752
commit 88ef4e82a1
19 changed files with 1501 additions and 25 deletions
+8 -1
View File
@@ -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
View File
@@ -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,
+42
View File
@@ -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;