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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user