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
+12
View File
@@ -501,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
return msisdn;
}
/**
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
* Rejects landlines, foreign numbers, and invalid formats.
*/
export function toIranianMobileMsisdn(raw: string): string | null {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
return null;
}
return msisdn;
}
export function maskMsisdn(msisdn: string): string {
if (msisdn.length < 6) return '***';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
@@ -170,6 +170,21 @@ export class BusinessSettingsService {
}).store.orderProcessSteps
: current.store.orderProcessSteps,
ePayment: ePaymentPatch ?? current.store.ePayment,
staticShippingEnabled:
dto.store.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
dto.store.staticShippingAmount ??
current.store.staticShippingAmount,
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions !== undefined
? normalizeBusinessSettings({
store: {
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions,
},
}).store.staticShippingProvinceExceptions
: current.store.staticShippingProvinceExceptions,
};
}
@@ -92,6 +92,12 @@ export type EPaymentSettings = {
gateways: PaymentGatewaysSettings;
};
/** Per-province override for the static flat shipping fee. */
export type StaticShippingProvinceException = {
provinceId: string;
amount: number;
};
/** Per-business store / sales settings. */
export type StoreSettings = {
onlineSellEnabled: boolean;
@@ -99,6 +105,14 @@ export type StoreSettings = {
torobEnabled: boolean;
orderProcessSteps: OrderProcessStep[];
ePayment: EPaymentSettings;
/**
* When true, every order uses `staticShippingAmount` and shipping cost groups are ignored.
*/
staticShippingEnabled: boolean;
/** Flat shipping fee in IRT applied to all orders when static shipping is enabled. */
staticShippingAmount: number;
/** Province-specific overrides of the static flat fee. */
staticShippingProvinceExceptions: StaticShippingProvinceException[];
};
/** Where website special-product carousels read from. */
@@ -356,6 +370,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
staticShippingEnabled: true,
staticShippingAmount: 0,
staticShippingProvinceExceptions: [],
},
modules: {
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
@@ -119,6 +119,44 @@ function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function readNonNegativeNumber(value: unknown, fallback: number) {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
return fallback;
}
function readStaticShippingProvinceExceptions(
value: unknown,
): BusinessSettings['store']['staticShippingProvinceExceptions'] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const items: BusinessSettings['store']['staticShippingProvinceExceptions'] =
[];
for (const entry of value) {
if (!isRecord(entry)) continue;
const provinceId =
typeof entry.provinceId === 'string'
? entry.provinceId.trim()
: typeof entry.provinceId === 'number'
? String(entry.provinceId)
: '';
if (!provinceId || seen.has(provinceId)) continue;
const amount = readNonNegativeNumber(entry.amount, Number.NaN);
if (!Number.isFinite(amount)) continue;
seen.add(provinceId);
items.push({ provinceId, amount });
}
return items;
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
@@ -431,6 +469,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
ePayment: normalizeEPaymentSettings(store.ePayment),
staticShippingEnabled: readBoolean(
store.staticShippingEnabled,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled,
),
staticShippingAmount: readNonNegativeNumber(
store.staticShippingAmount,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount,
),
staticShippingProvinceExceptions: readStaticShippingProvinceExceptions(
store.staticShippingProvinceExceptions,
),
},
modules: {
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
@@ -487,6 +536,14 @@ export function mergeBusinessSettings(
current.store.ePayment,
patch.store?.ePayment,
),
staticShippingEnabled:
patch.store?.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
patch.store?.staticShippingAmount ?? current.store.staticShippingAmount,
staticShippingProvinceExceptions:
patch.store?.staticShippingProvinceExceptions ??
current.store.staticShippingProvinceExceptions,
},
modules: {
enabled: patch.modules?.enabled ?? current.modules.enabled,
@@ -5,8 +5,10 @@ import {
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
ValidateIf,
ValidateNested,
@@ -163,6 +165,17 @@ class EPaymentSettingsDto {
gateways?: PaymentGatewaysSettingsDto;
}
class StaticShippingProvinceExceptionDto {
@IsString()
@MinLength(1)
provinceId!: string;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount!: number;
}
class StoreSettingsDto {
@IsOptional()
@IsBoolean()
@@ -182,6 +195,22 @@ class StoreSettingsDto {
@ValidateNested()
@Type(() => EPaymentSettingsDto)
ePayment?: EPaymentSettingsDto;
@IsOptional()
@IsBoolean()
staticShippingEnabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
staticShippingAmount?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => StaticShippingProvinceExceptionDto)
staticShippingProvinceExceptions?: StaticShippingProvinceExceptionDto[];
}
class ModulesSettingsDto {
+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;
@@ -0,0 +1,66 @@
/**
* Heuristics to reject obvious bot / gibberish contact submissions
* before they are stored or trigger SMS.
*/
/** Single Latin token with chaotic mixed case (e.g. nuKnGmIVvgMoplagJYbJHIf). */
export function looksLikeRandomToken(value: string): boolean {
const s = value.trim();
if (s.length < 12) return false;
if (!/^[A-Za-z]+$/.test(s)) return false;
// Normal Title Case word: "Something"
if (/^[A-Z][a-z]+$/.test(s)) return false;
// ALL CAPS short-ish brand-like tokens are less common at this length
if (/^[A-Z]+$/.test(s) && s.length < 16) return false;
// all lowercase long word without spaces is still suspicious when very long
if (/^[a-z]+$/.test(s)) return s.length >= 18;
let caseSwitches = 0;
for (let i = 1; i < s.length; i++) {
const prevUpper = s[i - 1]! >= 'A' && s[i - 1]! <= 'Z';
const curUpper = s[i]! >= 'A' && s[i]! <= 'Z';
if (prevUpper !== curUpper) caseSwitches += 1;
}
// Random camelNoise tends to flip case often
if (caseSwitches >= 5) return true;
if (caseSwitches >= 3 && caseSwitches / (s.length - 1) >= 0.28) return true;
return false;
}
/** True when the submission looks like spam and should be rejected. */
export function isLikelySpamContactSubmission(input: {
title: string;
name: string;
text: string;
}): boolean {
const title = input.title.trim();
const name = input.name.trim();
const text = input.text.trim();
if (looksLikeRandomToken(name)) return true;
const gibberishFields = [title, name, text].filter((v) =>
looksLikeRandomToken(v),
).length;
if (gibberishFields >= 2) return true;
// Entire payload is one unbroken Latin blob with no spaces / Persian
const combined = `${title} ${name} ${text}`;
const hasPersian = /[\u0600-\u06FF]/.test(combined);
const hasSpace = /\s/.test(name) || /\s/.test(text);
if (
!hasPersian &&
!hasSpace &&
/^[A-Za-z0-9]+$/.test(name) &&
name.length >= 14 &&
text.length >= 14 &&
/^[A-Za-z0-9]+$/.test(text)
) {
return true;
}
return false;
}
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
@@ -9,9 +10,10 @@ import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import type { SmsSendType } from '../auth/sms-billing';
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
import { SmsService, toIranianMobileMsisdn } from '../auth/sms.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import { isLikelySpamContactSubmission } from './contact-submission-spam';
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
@@ -40,14 +42,27 @@ export class ContactSubmissionsService {
async createPublic(host: string, dto: CreateContactSubmissionDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const title = dto.title.trim();
const name = dto.name.trim();
const text = dto.text.trim();
const email = dto.email?.trim() || null;
const cellNumber = dto.cellNumber?.trim() || null;
if (isLikelySpamContactSubmission({ title, name, text })) {
this.logger.warn(
`Contact form rejected as spam for business ${business.id}: name="${name}"`,
);
throw new BadRequestException('Invalid submission');
}
const created = await this.prisma.contactSubmission.create({
data: {
businessId: business.id,
title: dto.title.trim(),
name: dto.name.trim(),
email: dto.email?.trim() || null,
cellNumber: dto.cellNumber?.trim() || null,
text: dto.text.trim(),
title,
name,
email,
cellNumber,
text,
},
});
@@ -69,7 +84,7 @@ export class ContactSubmissionsService {
/**
* Best-effort SMS via Meshkee service (main) line:
* 1) business owner — new contact named in the message
* 2) contacter — confirmation, only when their cell number is valid
* 2) contacter — confirmation, only for a valid Iranian mobile
* Never fails the contact form submission.
*/
private async notifyContactSubmissionSms(input: {
@@ -92,7 +107,7 @@ export class ContactSubmissionsService {
label: 'owner',
});
if (!toGamaMsisdn(input.contacterCellNumber ?? '')) {
if (!toIranianMobileMsisdn(input.contacterCellNumber ?? '')) {
return;
}
@@ -129,9 +144,9 @@ export class ContactSubmissionsService {
label: string;
}): Promise<void> {
const cellNumber = input.cellNumber?.trim();
if (!cellNumber || !toGamaMsisdn(cellNumber)) {
if (!cellNumber || !toIranianMobileMsisdn(cellNumber)) {
this.logger.warn(
`Contact form ${input.label} SMS skipped for business ${input.businessId}: invalid or missing cellphone`,
`Contact form ${input.label} SMS skipped for business ${input.businessId}: not a valid Iranian mobile`,
);
return;
}
+11 -3
View File
@@ -376,6 +376,7 @@ export class OrdersService {
source: OrderSource;
cartItems: CartItemForOrder[];
shippingAddress: Record<string, unknown>;
shippingTotal?: number;
addressId: bigint | null;
customerNotes: string | null;
adminNotes: string | null;
@@ -390,7 +391,10 @@ export class OrdersService {
})),
);
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
const discountTotal = 0;
const orderTotal = subtotal + shippingTotal - discountTotal;
return this.createOrder({
businessId: input.businessId,
@@ -399,6 +403,8 @@ export class OrdersService {
source: input.source,
items: preparedItems,
shippingAddress: input.shippingAddress,
shippingTotal,
discountTotal,
addressId: input.addressId,
customerNotes: input.customerNotes,
adminNotes: input.adminNotes,
@@ -419,6 +425,8 @@ export class OrdersService {
source: OrderSource;
items: PreparedOrderItem[];
shippingAddress: Record<string, unknown>;
shippingTotal?: number;
discountTotal?: number;
addressId: bigint | null;
customerNotes: string | null;
adminNotes: string | null;
@@ -426,8 +434,8 @@ export class OrdersService {
payments?: TransactionPaymentInput[];
}) {
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
const shippingTotal = 0;
const discountTotal = 0;
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
const discountTotal = Math.max(0, Number(input.discountTotal ?? 0));
const total = subtotal + shippingTotal - discountTotal;
const processStepId = await this.defaultProcessStepId(input.businessId);
+23
View File
@@ -303,6 +303,8 @@ export class PaymentsService {
},
});
await this.advanceOrderAfterPayment(businessId, transaction.orderId);
// Keep order as pending for fulfillment; payment completion is on the transaction.
if (!returnUrl) {
throw new BadRequestException(
@@ -392,6 +394,27 @@ export class PaymentsService {
return null;
}
private async advanceOrderAfterPayment(
businessId: bigint,
orderId: bigint,
) {
const steps = await this.settings.getOrderProcessSteps(businessId);
const paymentSuccessful = steps.find((step) => step.id === 'payment-successful');
if (!paymentSuccessful) return;
await this.prisma.order.updateMany({
where: {
id: orderId,
businessId,
processStepId: { in: ['awaiting-payment', steps[0]?.id].filter(Boolean) as string[] },
},
data: {
processStepId: 'payment-successful',
status: 'confirmed',
},
});
}
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
await this.prisma.transaction.update({
where: { id: transactionId },
+163
View File
@@ -0,0 +1,163 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export const SHIPPING_METHOD_IDS = [
'post',
'tipax',
'mahex',
'freight',
'air',
] as const;
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number];
export class ListShippingCostGroupsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
}
export class ShippingProvinceExceptionDto {
@IsString()
@MinLength(1)
provinceId!: string;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount!: number;
}
export class CreateShippingCostGroupDto {
@IsString()
@MinLength(1)
@MaxLength(255)
nameFa!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
nameEn!: string;
@IsOptional()
@IsString()
description?: string;
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
methods!: ShippingMethodId[];
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
defaultAmount!: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
categoryIds?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ShippingProvinceExceptionDto)
provinceExceptions?: ShippingProvinceExceptionDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class UpdateShippingCostGroupDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
nameFa?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
nameEn?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
methods?: ShippingMethodId[];
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
defaultAmount?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
categoryIds?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ShippingProvinceExceptionDto)
provinceExceptions?: ShippingProvinceExceptionDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
@@ -0,0 +1,89 @@
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 {
CreateShippingCostGroupDto,
ListShippingCostGroupsDto,
UpdateShippingCostGroupDto,
} from './dto/shipping-cost-groups.dto';
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
@Controller('tenants/:host/shipping-cost-groups')
export class PublicShippingCostGroupsController {
constructor(private readonly service: ShippingCostGroupsService) {}
@Get()
list(@Param('host') host: string) {
return this.service.listPublic(host);
}
}
@Controller('businesses/:businessId/shipping-cost-groups')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class ShippingCostGroupsController {
constructor(private readonly service: ShippingCostGroupsService) {}
@Get()
@RequireBusinessPermission('products.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListShippingCostGroupsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':groupId')
@RequireBusinessPermission('products.read')
getOne(
@Param('businessId') businessId: string,
@Param('groupId') groupId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, groupId, user);
}
@Post()
@RequireBusinessPermission('products.update')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateShippingCostGroupDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':groupId')
@RequireBusinessPermission('products.update')
update(
@Param('businessId') businessId: string,
@Param('groupId') groupId: string,
@Body() dto: UpdateShippingCostGroupDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, groupId, dto, user);
}
@Delete(':groupId')
@RequireBusinessPermission('products.update')
remove(
@Param('businessId') businessId: string,
@Param('groupId') groupId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, groupId, user);
}
}
+698
View File
@@ -0,0 +1,698 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreateShippingCostGroupDto,
ListShippingCostGroupsDto,
SHIPPING_METHOD_IDS,
type ShippingMethodId,
UpdateShippingCostGroupDto,
} from './dto/shipping-cost-groups.dto';
type GroupWithRelations = Prisma.ShippingCostGroupGetPayload<{
include: {
categories: {
include: {
category: {
select: { id: true; name: true; nameFa: true; slug: true };
};
};
};
cityRates: true;
};
}>;
const METHOD_SET = new Set<string>(SHIPPING_METHOD_IDS);
const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه';
const METHOD_LABELS_FA: Record<ShippingMethodId, string> = {
post: 'پست',
tipax: 'تیپاکس',
mahex: 'ماهکس',
freight: 'باربری',
air: 'ارسال هوایی',
};
@Injectable()
export class ShippingCostGroupsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
) {}
async list(
businessIdRaw: string,
query: ListShippingCostGroupsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.read');
await this.assertStoreModuleEnabled(businessId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
const skip = (page - 1) * pageSize;
const where: Prisma.ShippingCostGroupWhereInput = {
businessId,
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
};
const [items, total] = await Promise.all([
this.prisma.shippingCostGroup.findMany({
where,
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: this.groupInclude(),
}),
this.prisma.shippingCostGroup.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async getOne(
businessIdRaw: string,
groupIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const groupId = BigInt(groupIdRaw);
await this.assertPermission(businessId, actor.id, 'products.read');
await this.assertStoreModuleEnabled(businessId);
const group = await this.findOrThrow(businessId, groupId);
return { group: this.serialize(group) };
}
async create(
businessIdRaw: string,
dto: CreateShippingCostGroupDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
await this.assertStoreModuleEnabled(businessId);
const methods = this.normalizeMethods(dto.methods);
const categoryIds = await this.resolveCategoryIds(
businessId,
dto.categoryIds ?? [],
);
const provinceIds = await this.resolveProvinceIds(
dto.provinceExceptions ?? [],
);
const isDefault = dto.isDefault === true;
const created = await this.prisma.$transaction(async (tx) => {
if (isDefault) {
await tx.shippingCostGroup.updateMany({
where: { businessId, isDefault: true },
data: { isDefault: false },
});
}
return tx.shippingCostGroup.create({
data: {
businessId,
nameFa: dto.nameFa.trim(),
nameEn: dto.nameEn.trim(),
description: dto.description?.trim() || null,
methods,
defaultAmount: dto.defaultAmount,
sortOrder: dto.sortOrder ?? 0,
isActive: dto.isActive ?? true,
isDefault,
...(categoryIds.length
? {
categories: {
create: categoryIds.map((categoryId) => ({ categoryId })),
},
}
: {}),
...(provinceIds.length
? {
cityRates: {
create: provinceIds.map(({ provinceId, amount }) => ({
cityId: provinceId,
amount,
})),
},
}
: {}),
},
include: this.groupInclude(),
});
});
return {
message: 'Shipping cost group created successfully',
group: this.serialize(created),
};
}
async update(
businessIdRaw: string,
groupIdRaw: string,
dto: UpdateShippingCostGroupDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const groupId = BigInt(groupIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
await this.assertStoreModuleEnabled(businessId);
await this.findOrThrow(businessId, groupId);
const data: Prisma.ShippingCostGroupUpdateInput = {};
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
if (dto.nameEn !== undefined) data.nameEn = dto.nameEn.trim();
if (dto.description !== undefined) {
data.description =
dto.description == null ? null : dto.description.trim() || null;
}
if (dto.methods !== undefined) {
data.methods = this.normalizeMethods(dto.methods);
}
if (dto.defaultAmount !== undefined) {
data.defaultAmount = dto.defaultAmount;
}
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.isDefault !== undefined) data.isDefault = dto.isDefault;
if (dto.categoryIds !== undefined) {
const categoryIds = await this.resolveCategoryIds(
businessId,
dto.categoryIds,
);
data.categories = {
deleteMany: {},
...(categoryIds.length
? {
create: categoryIds.map((categoryId) => ({ categoryId })),
}
: {}),
};
}
if (dto.provinceExceptions !== undefined) {
const provinceIds = await this.resolveProvinceIds(dto.provinceExceptions);
data.cityRates = {
deleteMany: {},
...(provinceIds.length
? {
create: provinceIds.map(({ provinceId, amount }) => ({
cityId: provinceId,
amount,
})),
}
: {}),
};
}
const updated = await this.prisma.$transaction(async (tx) => {
if (dto.isDefault === true) {
await tx.shippingCostGroup.updateMany({
where: {
businessId,
isDefault: true,
id: { not: groupId },
},
data: { isDefault: false },
});
}
return tx.shippingCostGroup.update({
where: { id: groupId },
data,
include: this.groupInclude(),
});
});
return {
message: 'Shipping cost group updated successfully',
group: this.serialize(updated),
};
}
async remove(
businessIdRaw: string,
groupIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const groupId = BigInt(groupIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
await this.assertStoreModuleEnabled(businessId);
await this.findOrThrow(businessId, groupId);
await this.prisma.shippingCostGroup.delete({ where: { id: groupId } });
return { message: 'Shipping cost group deleted successfully' };
}
async listPublic(host: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const settings = normalizeBusinessSettings(business.settings);
if (!settings.modules.enabled.includes('store')) {
return {
enabled: false,
staticShippingEnabled: false,
staticShippingAmount: 0,
staticShippingProvinceExceptions: [],
items: [],
};
}
const items = await this.prisma.shippingCostGroup.findMany({
where: { businessId: business.id, isActive: true },
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
include: this.groupInclude(),
});
return {
enabled: true,
staticShippingEnabled: settings.store.staticShippingEnabled,
staticShippingAmount: settings.store.staticShippingAmount,
staticShippingProvinceExceptions:
settings.store.staticShippingProvinceExceptions,
items: items.map((item) => this.serialize(item)),
};
}
/**
* Server-side shipping quote for checkout. Method ids from the client are only
* used when they belong to the matched group's methods (for labels).
*/
async quoteForCheckout(input: {
businessId: bigint;
deliveryMode: 'delivery' | 'pickup';
provinceName: string | null;
cartItems: Array<{
productName: string;
categoryIds: string[];
}>;
selectedMethodsByGroupId?: Record<string, string>;
}): Promise<{
total: number;
groups: Array<{
groupId: string;
title: string;
methodId: string | null;
methodLabel: string | null;
amount: number;
itemNames: string[];
}>;
}> {
if (input.deliveryMode === 'pickup' || input.cartItems.length === 0) {
return { total: 0, groups: [] };
}
const business = await this.prisma.business.findUnique({
where: { id: input.businessId },
select: { settings: true },
});
if (!business) {
return {
total: 0,
groups: [
{
groupId: 'ungrouped',
title: STORE_SHIPPING_TITLE,
methodId: null,
methodLabel: null,
amount: 0,
itemNames: input.cartItems.map((item) => item.productName),
},
],
};
}
const settings = normalizeBusinessSettings(business.settings);
if (!settings.modules.enabled.includes('store')) {
return {
total: 0,
groups: [
{
groupId: 'ungrouped',
title: STORE_SHIPPING_TITLE,
methodId: null,
methodLabel: null,
amount: 0,
itemNames: input.cartItems.map((item) => item.productName),
},
],
};
}
const provinceId = await this.resolveProvinceId(input.provinceName);
const selected = input.selectedMethodsByGroupId ?? {};
if (settings.store.staticShippingEnabled) {
const amount = this.resolveProvinceAmount(
settings.store.staticShippingAmount,
settings.store.staticShippingProvinceExceptions,
provinceId,
);
return {
total: amount,
groups: [
{
groupId: 'static',
title: STORE_SHIPPING_TITLE,
methodId: null,
methodLabel: null,
amount,
itemNames: input.cartItems.map((item) => item.productName),
},
],
};
}
const groups = await this.prisma.shippingCostGroup.findMany({
where: { businessId: input.businessId, isActive: true },
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
include: this.groupInclude(),
});
const serialized = groups.map((item) => this.serialize(item));
const defaultGroup = serialized.find((group) => group.isDefault) ?? null;
const buckets = new Map<
string,
{
groupId: string;
title: string;
methods: ShippingMethodId[];
amount: number;
itemNames: string[];
isDefault: boolean;
}
>();
const ungroupedNames: string[] = [];
for (const item of input.cartItems) {
const matched = this.findGroupForCategoryIds(serialized, item.categoryIds);
const group = matched ?? defaultGroup;
if (!group) {
ungroupedNames.push(item.productName);
continue;
}
const existing = buckets.get(group.id);
if (existing) {
existing.itemNames.push(item.productName);
} else {
buckets.set(group.id, {
groupId: group.id,
title: group.isDefault
? STORE_SHIPPING_TITLE
: group.nameFa || group.nameEn,
methods: group.methods,
amount: this.resolveProvinceAmount(
group.defaultAmount,
group.provinceExceptions,
provinceId,
),
itemNames: [item.productName],
isDefault: group.isDefault,
});
}
}
if (ungroupedNames.length > 0) {
buckets.set('ungrouped', {
groupId: 'ungrouped',
title: STORE_SHIPPING_TITLE,
methods: [],
amount: 0,
itemNames: ungroupedNames,
isDefault: true,
});
}
const resultGroups = [...buckets.values()].map((bucket) => {
const requested = selected[bucket.groupId];
const methodId =
requested && bucket.methods.includes(requested as ShippingMethodId)
? (requested as ShippingMethodId)
: bucket.methods[0] ?? null;
return {
groupId: bucket.groupId,
title: bucket.title,
methodId,
methodLabel: methodId ? METHOD_LABELS_FA[methodId] : null,
amount: bucket.amount,
itemNames: bucket.itemNames,
};
});
return {
total: resultGroups.reduce((sum, group) => sum + group.amount, 0),
groups: resultGroups,
};
}
private async resolveProvinceId(provinceName: string | null) {
const needle = provinceName?.trim();
if (!needle) return null;
const match = await this.prisma.city.findFirst({
where: {
level: 'province',
isActive: true,
OR: [
{ nameFa: needle },
{ nameEn: needle },
{ slug: needle },
],
},
select: { id: true },
});
return match?.id.toString() ?? null;
}
private resolveProvinceAmount(
defaultAmount: number,
exceptions: Array<{ provinceId: string; amount: number }> | undefined,
provinceId: string | null,
) {
if (!provinceId || !exceptions?.length) return defaultAmount;
const match = exceptions.find((item) => item.provinceId === provinceId);
return match ? match.amount : defaultAmount;
}
private findGroupForCategoryIds(
groups: Array<{
id: string;
isDefault: boolean;
categoryIds: string[];
nameFa: string;
nameEn: string;
methods: ShippingMethodId[];
defaultAmount: number;
provinceExceptions?: Array<{ provinceId: string; amount: number }>;
}>,
categoryIds: string[],
) {
const categorySet = new Set(categoryIds);
const byCategory = groups.filter((group) =>
group.categoryIds.some((id) => categorySet.has(id)),
);
if (byCategory.length === 0) return null;
return byCategory.find((group) => !group.isDefault) ?? byCategory[0] ?? null;
}
private groupInclude() {
return {
categories: {
include: {
category: {
select: { id: true, name: true, nameFa: true, slug: true },
},
},
},
cityRates: true,
} as const;
}
private serialize(group: GroupWithRelations) {
return {
id: group.id.toString(),
businessId: group.businessId.toString(),
nameFa: group.nameFa,
nameEn: group.nameEn,
description: group.description,
methods: group.methods.filter((method): method is ShippingMethodId =>
METHOD_SET.has(method),
),
defaultAmount: Number(group.defaultAmount),
sortOrder: group.sortOrder,
isActive: group.isActive,
isDefault: group.isDefault,
categoryIds: group.categories.map((row) => row.categoryId.toString()),
categories: group.categories.map((row) => ({
id: row.category.id.toString(),
name: row.category.name,
nameFa: row.category.nameFa,
slug: row.category.slug,
})),
cityRates: group.cityRates.map((rate) => ({
id: rate.id.toString(),
cityId: rate.cityId.toString(),
amount: Number(rate.amount),
})),
provinceExceptions: group.cityRates.map((rate) => ({
id: rate.id.toString(),
provinceId: rate.cityId.toString(),
amount: Number(rate.amount),
})),
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
};
}
private normalizeMethods(methods: ShippingMethodId[]) {
const unique = [...new Set(methods)];
if (!unique.length) {
throw new BadRequestException('Select at least one shipping method');
}
for (const method of unique) {
if (!METHOD_SET.has(method)) {
throw new BadRequestException(`Invalid shipping method: ${method}`);
}
}
return unique;
}
private async resolveProvinceIds(
exceptions: Array<{ provinceId: string; amount: number }>,
) {
if (!exceptions.length) return [] as Array<{ provinceId: bigint; amount: number }>;
const unique = new Map<string, number>();
for (const entry of exceptions) {
const provinceId = entry.provinceId.trim();
if (!provinceId) continue;
if (!Number.isFinite(entry.amount) || entry.amount < 0) {
throw new BadRequestException('Invalid province exception amount');
}
unique.set(provinceId, entry.amount);
}
const ids = [...unique.keys()].map((id) => {
try {
return BigInt(id);
} catch {
throw new BadRequestException(`Invalid province id: ${id}`);
}
});
const found = await this.prisma.city.findMany({
where: {
id: { in: ids },
level: 'province',
isActive: true,
},
select: { id: true },
});
if (found.length !== ids.length) {
throw new BadRequestException(
'One or more provinces were not found',
);
}
return [...unique.entries()].map(([provinceId, amount]) => ({
provinceId: BigInt(provinceId),
amount,
}));
}
private async resolveCategoryIds(businessId: bigint, rawIds: string[]) {
if (!rawIds.length) return [] as bigint[];
const unique = [...new Set(rawIds.map((id) => id.trim()).filter(Boolean))];
const ids = unique.map((id) => {
try {
return BigInt(id);
} catch {
throw new BadRequestException(`Invalid category id: ${id}`);
}
});
const found = await this.prisma.category.findMany({
where: {
businessId,
entityType: 'product',
id: { in: ids },
},
select: { id: true },
});
if (found.length !== ids.length) {
throw new BadRequestException(
'One or more categories were not found for this business',
);
}
return ids;
}
private async findOrThrow(businessId: bigint, groupId: bigint) {
const group = await this.prisma.shippingCostGroup.findFirst({
where: { id: groupId, businessId },
include: this.groupInclude(),
});
if (!group) {
throw new NotFoundException('Shipping cost group not found');
}
return group;
}
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`,
);
}
}
private async assertStoreModuleEnabled(businessId: bigint) {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { settings: true },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const settings = normalizeBusinessSettings(business.settings);
if (!settings.modules.enabled.includes('store')) {
throw new ForbiddenException('Store module is not enabled');
}
}
}
+14 -1
View File
@@ -15,6 +15,11 @@ import {
StoreLoanMethodsController,
} from './store-loan-methods.controller';
import { StoreLoanMethodsService } from './store-loan-methods.service';
import {
PublicShippingCostGroupsController,
ShippingCostGroupsController,
} from './shipping-cost-groups.controller';
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
@Module({
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
@@ -25,13 +30,21 @@ import { StoreLoanMethodsService } from './store-loan-methods.service';
StoreSpecialsController,
PublicStoreLoanMethodsController,
StoreLoanMethodsController,
PublicShippingCostGroupsController,
ShippingCostGroupsController,
],
providers: [
StoreItemsService,
StoreSpecialsService,
StoreSpecialsAiService,
StoreLoanMethodsService,
ShippingCostGroupsService,
],
exports: [
StoreItemsService,
StoreSpecialsService,
StoreLoanMethodsService,
ShippingCostGroupsService,
],
exports: [StoreItemsService, StoreSpecialsService, StoreLoanMethodsService],
})
export class StoreModule {}