From 88ef4e82a1683e09ac9f4333d71f078343217995 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 2 Oct 2026 21:52:41 +0330 Subject: [PATCH] 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 --- .../migrations/100_shipping_cost_groups.sql | 58 ++ .../101_shipping_cost_group_is_default.sql | 8 + prisma/schema.prisma | 52 ++ src/auth/sms.service.ts | 12 + .../business-settings.service.ts | 15 + .../business-settings.types.ts | 17 + .../business-settings.util.ts | 57 ++ .../dto/update-business-settings.dto.ts | 29 + src/cart/cart.module.ts | 9 +- src/cart/cart.service.ts | 124 +++- src/cart/dto/cart.dto.ts | 42 ++ .../contact-submission-spam.ts | 66 ++ .../contact-submissions.service.ts | 35 +- src/orders/orders.service.ts | 14 +- src/payments/payments.service.ts | 23 + src/store/dto/shipping-cost-groups.dto.ts | 163 ++++ src/store/shipping-cost-groups.controller.ts | 89 +++ src/store/shipping-cost-groups.service.ts | 698 ++++++++++++++++++ src/store/store.module.ts | 15 +- 19 files changed, 1501 insertions(+), 25 deletions(-) create mode 100644 database/migrations/100_shipping_cost_groups.sql create mode 100644 database/migrations/101_shipping_cost_group_is_default.sql create mode 100644 src/contact-submissions/contact-submission-spam.ts create mode 100644 src/store/dto/shipping-cost-groups.dto.ts create mode 100644 src/store/shipping-cost-groups.controller.ts create mode 100644 src/store/shipping-cost-groups.service.ts diff --git a/database/migrations/100_shipping_cost_groups.sql b/database/migrations/100_shipping_cost_groups.sql new file mode 100644 index 0000000..663413c --- /dev/null +++ b/database/migrations/100_shipping_cost_groups.sql @@ -0,0 +1,58 @@ +-- Shipping cost groups (هزینه ارسال) + per-city overrides + category assignment + +CREATE TABLE IF NOT EXISTS shipping_cost_groups ( + id BIGSERIAL PRIMARY KEY, + business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION, + name VARCHAR(255) NOT NULL, + name_en VARCHAR(255) NOT NULL, + description TEXT NULL, + methods TEXT[] NOT NULL DEFAULT '{}', + default_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_id + ON shipping_cost_groups (business_id); + +CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_sort_order + ON shipping_cost_groups (business_id, sort_order); + +DROP TRIGGER IF EXISTS shipping_cost_groups_set_updated_at ON shipping_cost_groups; +CREATE TRIGGER shipping_cost_groups_set_updated_at + BEFORE UPDATE ON shipping_cost_groups + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS shipping_cost_group_city_rates ( + id BIGSERIAL PRIMARY KEY, + shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION, + city_id BIGINT NOT NULL REFERENCES cities (id) ON DELETE CASCADE ON UPDATE NO ACTION, + amount NUMERIC(12, 2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT shipping_cost_group_city_rates_unique UNIQUE (shipping_cost_group_id, city_id) +); + +CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_group_id + ON shipping_cost_group_city_rates (shipping_cost_group_id); + +CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_city_id + ON shipping_cost_group_city_rates (city_id); + +DROP TRIGGER IF EXISTS shipping_cost_group_city_rates_set_updated_at ON shipping_cost_group_city_rates; +CREATE TRIGGER shipping_cost_group_city_rates_set_updated_at + BEFORE UPDATE ON shipping_cost_group_city_rates + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS shipping_cost_group_categories ( + shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION, + category_id BIGINT NOT NULL REFERENCES categories (id) ON DELETE CASCADE ON UPDATE NO ACTION, + PRIMARY KEY (shipping_cost_group_id, category_id) +); + +CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_categories_category_id + ON shipping_cost_group_categories (category_id); diff --git a/database/migrations/101_shipping_cost_group_is_default.sql b/database/migrations/101_shipping_cost_group_is_default.sql new file mode 100644 index 0000000..e5a92fd --- /dev/null +++ b/database/migrations/101_shipping_cost_group_is_default.sql @@ -0,0 +1,8 @@ +-- Default shipping cost group per business (fallback for unassigned categories) + +ALTER TABLE shipping_cost_groups + ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_cost_groups_business_default + ON shipping_cost_groups (business_id) + WHERE is_default = TRUE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0aecff4..5df6f11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,6 +161,7 @@ model Business { storeItemVariants StoreItemVariant[] storeItems StoreItem[] storeLoanMethods StoreLoanMethod[] + shippingCostGroups ShippingCostGroup[] storeSpecials StoreSpecial[] transactions Transaction[] userProductTechnicalFieldValues UserProductTechnicalFieldValue[] @@ -443,6 +444,7 @@ model Category { assignments CategoryAssignment[] technicalForm CategoryTechnicalForm? variations CategoryVariation[] + shippingCostGroupCategories ShippingCostGroupCategory[] website_category_group_items website_category_group_items[] @@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique") @@ -943,6 +945,7 @@ model City { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction) children City[] @relation("CityTree") + shippingCostCityRates ShippingCostGroupCityRate[] userProductsAsCity UserProduct[] @relation("UserProductCity") userProductsAsCountry UserProduct[] @relation("UserProductCountry") userProductsAsDistrict UserProduct[] @relation("UserProductDistrict") @@ -1254,6 +1257,55 @@ model StoreLoanMethod { @@map("store_loan_methods") } +model ShippingCostGroup { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + nameFa String @map("name") @db.VarChar(255) + nameEn String @map("name_en") @db.VarChar(255) + description String? + methods String[] @default([]) + defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + cityRates ShippingCostGroupCityRate[] + categories ShippingCostGroupCategory[] + + @@index([businessId], map: "idx_shipping_cost_groups_business_id") + @@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order") + @@map("shipping_cost_groups") +} + +model ShippingCostGroupCityRate { + id BigInt @id @default(autoincrement()) + shippingCostGroupId BigInt @map("shipping_cost_group_id") + cityId BigInt @map("city_id") + amount Decimal @db.Decimal(12, 2) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction) + city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique") + @@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id") + @@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id") + @@map("shipping_cost_group_city_rates") +} + +model ShippingCostGroupCategory { + shippingCostGroupId BigInt @map("shipping_cost_group_id") + categoryId BigInt @map("category_id") + shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([shippingCostGroupId, categoryId]) + @@index([categoryId], map: "idx_shipping_cost_group_categories_category_id") + @@map("shipping_cost_group_categories") +} + model website_brand_group_items { id BigInt @id @default(autoincrement()) group_id BigInt diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts index 0c8b5ae..14dbd09 100644 --- a/src/auth/sms.service.ts +++ b/src/auth/sms.service.ts @@ -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)}`; diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 0d7cbc1..ddeba6e 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -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, }; } diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 71475b0..4ad8553 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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, diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 82cce51..f192585 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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(); + 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, diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 0f595d8..65fdcc2 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -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 { diff --git a/src/cart/cart.module.ts b/src/cart/cart.module.ts index 66b6dbb..2ca4ffa 100644 --- a/src/cart/cart.module.ts +++ b/src/cart/cart.module.ts @@ -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], }) diff --git a/src/cart/cart.service.ts b/src/cart/cart.service.ts index c3dbaec..beebd19 100644 --- a/src/cart/cart.service.ts +++ b/src/cart/cart.service.ts @@ -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 = {}; + 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(); + 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(); + 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, diff --git a/src/cart/dto/cart.dto.ts b/src/cart/dto/cart.dto.ts index 6c1a989..6d35a3f 100644 --- a/src/cart/dto/cart.dto.ts +++ b/src/cart/dto/cart.dto.ts @@ -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; diff --git a/src/contact-submissions/contact-submission-spam.ts b/src/contact-submissions/contact-submission-spam.ts new file mode 100644 index 0000000..cf1058e --- /dev/null +++ b/src/contact-submissions/contact-submission-spam.ts @@ -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; +} diff --git a/src/contact-submissions/contact-submissions.service.ts b/src/contact-submissions/contact-submissions.service.ts index 1f42452..80b08de 100644 --- a/src/contact-submissions/contact-submissions.service.ts +++ b/src/contact-submissions/contact-submissions.service.ts @@ -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 { 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; } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 15df72b..ed4370b 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -376,6 +376,7 @@ export class OrdersService { source: OrderSource; cartItems: CartItemForOrder[]; shippingAddress: Record; + 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; + 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); diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index 73beaa5..0a495ae 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -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 }, diff --git a/src/store/dto/shipping-cost-groups.dto.ts b/src/store/dto/shipping-cost-groups.dto.ts new file mode 100644 index 0000000..ef575c6 --- /dev/null +++ b/src/store/dto/shipping-cost-groups.dto.ts @@ -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; +} diff --git a/src/store/shipping-cost-groups.controller.ts b/src/store/shipping-cost-groups.controller.ts new file mode 100644 index 0000000..adb33ff --- /dev/null +++ b/src/store/shipping-cost-groups.controller.ts @@ -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); + } +} diff --git a/src/store/shipping-cost-groups.service.ts b/src/store/shipping-cost-groups.service.ts new file mode 100644 index 0000000..1f888ca --- /dev/null +++ b/src/store/shipping-cost-groups.service.ts @@ -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(SHIPPING_METHOD_IDS); + +const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه'; + +const METHOD_LABELS_FA: Record = { + 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; + }): 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(); + 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'); + } + } +} diff --git a/src/store/store.module.ts b/src/store/store.module.ts index 60f830f..c569f54 100644 --- a/src/store/store.module.ts +++ b/src/store/store.module.ts @@ -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 {}