diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index cea1a3a..988c63d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -349,9 +349,9 @@ Base: `/tenants/:host/user-products` | Method | Path | Description | |--------|------|-------------| | GET | `/payments/methods` | Enabled gateways for this business (no secrets). Also on `GET /tenants/:host` as `ePayment` | -| POST/GET | `/payments/:gateway/callback` | Bank return URL (Mellat first). Verifies/settles, then 303 → `returnUrl?status=…` | +| POST/GET | `/payments/:gateway/callback` | Bank/gateway return URL (Mellat, ZarinPal). Verifies, then 303 → `returnUrl?status=…` | -Gateway credentials live in `businesses.settings.store.ePayment` (dashboard: Website → E-Payment). Adapter registry supports Mellat now; stubs reserved for `sep`, `snappay`, `digipay`, `zarinpal`. +Gateway credentials live in `businesses.settings.store.ePayment` (dashboard: **Settings**). Adapter registry: **Mellat** + **ZarinPal**; stubs reserved for `sep`, `snappay`, `digipay`. #### Orders @@ -619,7 +619,7 @@ See `.env.example` for the full list. Key groups: - Product variation values (which options a product offers) - Store items / product variants (price, stock, SKU) - Shopping cart + checkout + orders (customer + admin) -- Online e-payment (Mellat first; multi-gateway registry for SEP / Snapp Pay / DigiPay / Zarinpal) +- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay) - Product technical info - Category variations & technical forms - Tenant resolution by domain diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index d264c22..09d791a 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -31,10 +31,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` 2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials -3. Catalog: categories, products, store-items, **user-products** (customer stock listings) +3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl` + - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` - Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank - Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…` - Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods` diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index b039dd9..0e449a6 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1716,6 +1716,29 @@ "url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback", "description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl." } + }, + { + "name": "ZarinPal callback (gateway → API)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/payments/zarinpal/callback?Authority={{authority}}&Status=OK", + "host": ["{{baseUrl}}"], + "path": [ + "businesses", + "{{businessId}}", + "payments", + "zarinpal", + "callback" + ], + "query": [ + { "key": "Authority", "value": "{{authority}}" }, + { "key": "Status", "value": "OK" } + ] + }, + "description": "Called by ZarinPal (not by the website). Verifies payment, then 303 redirect to returnUrl." + } } ] }, diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 7942c1e..cdbe4bf 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -645,6 +645,7 @@ { "name": "name", "in": "query", + "description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.", "schema": { "type": "string" } diff --git a/scripts/websites-agent/deploy.sh b/scripts/websites-agent/deploy.sh index 513d80b..95ff58f 100755 --- a/scripts/websites-agent/deploy.sh +++ b/scripts/websites-agent/deploy.sh @@ -89,7 +89,10 @@ if [[ -f .next/standalone/server.js ]]; then cp -a .next/static .next/standalone/.next/static if [[ -d public ]]; then rm -rf .next/standalone/public - cp -a public .next/standalone/public + # Follow symlinks (e.g. public/images -> ../src/images) so standalone + # serves real files. cp -a alone would copy a broken relative link. + mkdir -p .next/standalone/public + cp -aL public/. .next/standalone/public/ fi SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE' @@ -121,6 +124,7 @@ app.cwd = root + '/.next/standalone'; app.script = 'server.js'; app.args = ''; app.env = { + ...(app.env || {}), NODE_ENV: 'production', PORT: String(port), HOSTNAME: '127.0.0.1', @@ -136,15 +140,16 @@ cfg.apps.forEach((entry, idx) => { lines.push(` args: ${JSON.stringify(entry.args)},`); } lines.push(' env: {'); - lines.push(` NODE_ENV: ${JSON.stringify(entry.env.NODE_ENV)},`); - lines.push(` PORT: ${JSON.stringify(entry.env.PORT)},`); - if (entry.env.HOSTNAME) { - lines.push(` HOSTNAME: ${JSON.stringify(entry.env.HOSTNAME)},`); + for (const [key, value] of Object.entries(entry.env || {})) { + lines.push(` ${key}: ${JSON.stringify(String(value))},`); } lines.push(' },'); lines.push(` error_file: ${JSON.stringify(entry.error_file)},`); lines.push(` out_file: ${JSON.stringify(entry.out_file)},`); lines.push(' time: true,'); + if (entry.max_memory_restart) { + lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`); + } lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },'); }); lines.push(' ],'); diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 04ec2df..863433f 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -148,6 +148,12 @@ export class BusinessSettingsService { enabled: dto.store.ePayment.gateways?.zarinpal?.enabled ?? currentEPayment.gateways.zarinpal.enabled, + merchantId: + dto.store.ePayment.gateways?.zarinpal?.merchantId ?? + currentEPayment.gateways.zarinpal.merchantId, + sandbox: + dto.store.ePayment.gateways?.zarinpal?.sandbox ?? + currentEPayment.gateways.zarinpal.sandbox, }, }, }), diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index ca0116e..87c055c 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -58,8 +58,17 @@ export type MellatGatewaySettings = { password: string; }; +/** ZarinPal merchant credentials. */ +export type ZarinpalGatewaySettings = { + enabled: boolean; + /** Merchant code from the ZarinPal panel. */ + merchantId: string; + /** When true, use sandbox.zarinpal.com endpoints. */ + sandbox: boolean; +}; + /** - * Placeholder for gateways not yet wired (SEP, Snapp Pay, DigiPay, Zarinpal). + * Placeholder for gateways not yet wired (SEP, Snapp Pay, DigiPay). * Credential fields are added when each adapter ships. */ export type StubGatewaySettings = { @@ -71,7 +80,7 @@ export type PaymentGatewaysSettings = { sep: StubGatewaySettings; snappay: StubGatewaySettings; digipay: StubGatewaySettings; - zarinpal: StubGatewaySettings; + zarinpal: ZarinpalGatewaySettings; }; /** Online e-payment (website checkout). */ @@ -192,6 +201,12 @@ export const DEFAULT_STUB_GATEWAY_SETTINGS: StubGatewaySettings = { enabled: false, }; +export const DEFAULT_ZARINPAL_GATEWAY_SETTINGS: ZarinpalGatewaySettings = { + enabled: false, + merchantId: '', + sandbox: false, +}; + export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = { enabled: false, defaultGateway: null, @@ -200,7 +215,7 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = { sep: { ...DEFAULT_STUB_GATEWAY_SETTINGS }, snappay: { ...DEFAULT_STUB_GATEWAY_SETTINGS }, digipay: { ...DEFAULT_STUB_GATEWAY_SETTINGS }, - zarinpal: { ...DEFAULT_STUB_GATEWAY_SETTINGS }, + zarinpal: { ...DEFAULT_ZARINPAL_GATEWAY_SETTINGS }, }, }; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 8e884b9..210f60f 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -15,6 +15,7 @@ import { DEFAULT_MELLAT_GATEWAY_SETTINGS, DEFAULT_ORDER_PROCESS_STEPS, DEFAULT_STUB_GATEWAY_SETTINGS, + DEFAULT_ZARINPAL_GATEWAY_SETTINGS, EPaymentSettings, HOME_CHART_IDS, MellatGatewaySettings, @@ -25,6 +26,7 @@ import { type PaymentGatewayId, OrderProcessStep, StubGatewaySettings, + ZarinpalGatewaySettings, } from './business-settings.types'; import { defaultOrderStepColor, @@ -131,6 +133,24 @@ function readStubGateway(value: unknown): StubGatewaySettings { }; } +function readZarinpalGateway(value: unknown): ZarinpalGatewaySettings { + const source = isRecord(value) ? value : {}; + return { + enabled: readBoolean( + source.enabled, + DEFAULT_ZARINPAL_GATEWAY_SETTINGS.enabled, + ), + merchantId: readString( + source.merchantId, + DEFAULT_ZARINPAL_GATEWAY_SETTINGS.merchantId, + ), + sandbox: readBoolean( + source.sandbox, + DEFAULT_ZARINPAL_GATEWAY_SETTINGS.sandbox, + ), + }; +} + export function normalizeEPaymentSettings(value: unknown): EPaymentSettings { const source = isRecord(value) ? value : {}; const gateways = isRecord(source.gateways) ? source.gateways : {}; @@ -146,7 +166,7 @@ export function normalizeEPaymentSettings(value: unknown): EPaymentSettings { sep: readStubGateway(gateways.sep), snappay: readStubGateway(gateways.snappay), digipay: readStubGateway(gateways.digipay), - zarinpal: readStubGateway(gateways.zarinpal), + zarinpal: readZarinpalGateway(gateways.zarinpal), }, }; } @@ -177,7 +197,11 @@ export function mergeEPaymentSettings( sep: { enabled: patch.gateways.sep.enabled }, snappay: { enabled: patch.gateways.snappay.enabled }, digipay: { enabled: patch.gateways.digipay.enabled }, - zarinpal: { enabled: patch.gateways.zarinpal.enabled }, + zarinpal: { + enabled: patch.gateways.zarinpal.enabled, + merchantId: patch.gateways.zarinpal.merchantId.trim(), + sandbox: patch.gateways.zarinpal.sandbox, + }, }, }; } @@ -228,10 +252,13 @@ export function isGatewayReady( g.password.length > 0 ); } + case 'zarinpal': { + const g = settings.gateways.zarinpal; + return g.enabled && g.merchantId.length > 0; + } case 'sep': case 'snappay': case 'digipay': - case 'zarinpal': return false; default: return false; diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index cfcb77b..840a9ce 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -104,6 +104,20 @@ class StubGatewaySettingsDto { enabled?: boolean; } +class ZarinpalGatewaySettingsDto { + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsString() + merchantId?: string; + + @IsOptional() + @IsBoolean() + sandbox?: boolean; +} + class PaymentGatewaysSettingsDto { @IsOptional() @ValidateNested() @@ -127,8 +141,8 @@ class PaymentGatewaysSettingsDto { @IsOptional() @ValidateNested() - @Type(() => StubGatewaySettingsDto) - zarinpal?: StubGatewaySettingsDto; + @Type(() => ZarinpalGatewaySettingsDto) + zarinpal?: ZarinpalGatewaySettingsDto; } class EPaymentSettingsDto { diff --git a/src/payments/gateways/payment-gateway.types.ts b/src/payments/gateways/payment-gateway.types.ts index 8e12bc9..0d5a783 100644 --- a/src/payments/gateways/payment-gateway.types.ts +++ b/src/payments/gateways/payment-gateway.types.ts @@ -2,6 +2,7 @@ import type { EPaymentSettings, MellatGatewaySettings, PaymentGatewayId, + ZarinpalGatewaySettings, } from '../../business-settings/business-settings.types'; export type PaymentRedirect = { @@ -46,6 +47,7 @@ export type VerifyPaymentResult = { export type GatewayCredentials = { mellat: MellatGatewaySettings; + zarinpal: ZarinpalGatewaySettings; }; export interface PaymentGatewayAdapter { diff --git a/src/payments/gateways/zarinpal.gateway.ts b/src/payments/gateways/zarinpal.gateway.ts new file mode 100644 index 0000000..fa7e015 --- /dev/null +++ b/src/payments/gateways/zarinpal.gateway.ts @@ -0,0 +1,233 @@ +import { + BadGatewayException, + Injectable, + Logger, +} from '@nestjs/common'; +import type { EPaymentSettings } from '../../business-settings/business-settings.types'; +import { isGatewayReady } from '../../business-settings/business-settings.util'; +import type { + GatewayCredentials, + InitiatePaymentInput, + InitiatePaymentResult, + PaymentGatewayAdapter, + VerifyPaymentInput, + VerifyPaymentResult, +} from './payment-gateway.types'; + +const ZARINPAL_API_LIVE = 'https://payment.zarinpal.com/pg/v4/payment'; +const ZARINPAL_API_SANDBOX = 'https://sandbox.zarinpal.com/pg/v4/payment'; +const ZARINPAL_START_LIVE = 'https://www.zarinpal.com/pg/StartPay'; +const ZARINPAL_START_SANDBOX = 'https://sandbox.zarinpal.com/pg/StartPay'; + +/** First successful verify. */ +const ZARINPAL_OK = 100; +/** Already verified (idempotent retry). */ +const ZARINPAL_ALREADY_VERIFIED = 101; + +type ZarinpalEnvelope = { + data?: { + code?: number; + message?: string; + authority?: string; + ref_id?: number | string; + card_pan?: string; + card_hash?: string; + fee_type?: string; + fee?: number; + }; + errors?: + | Array<{ code?: number; message?: string }> + | Record + | []; +}; + +function readPayloadString( + payload: Record, + ...keys: string[] +): string { + for (const key of keys) { + const value = payload[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + } + return ''; +} + +@Injectable() +export class ZarinpalGateway implements PaymentGatewayAdapter { + readonly id = 'zarinpal' as const; + private readonly logger = new Logger(ZarinpalGateway.name); + + isConfigured(settings: EPaymentSettings): boolean { + return isGatewayReady(settings, 'zarinpal'); + } + + async initiate( + credentials: GatewayCredentials, + input: InitiatePaymentInput, + ): Promise { + const zarinpal = credentials.zarinpal; + // ZarinPal amount is Rials; Meshkee stores IRT (Toman). + const amountRials = Math.round(input.amountIrt * 10); + + const body = await this.apiCall(zarinpal.sandbox, 'request.json', { + merchant_id: zarinpal.merchantId, + amount: amountRials, + callback_url: input.callbackUrl, + description: (input.description ?? `Order ${input.orderId}`).slice(0, 255), + metadata: { + order_id: input.orderId.toString(), + transaction_id: input.transactionId.toString(), + }, + }); + + const code = body.data?.code; + const authority = body.data?.authority?.trim() ?? ''; + if (code !== ZARINPAL_OK || !authority) { + this.logger.warn( + `ZarinPal request failed business=${input.businessId} order=${input.orderId} code=${code ?? 'unknown'}`, + ); + throw new BadGatewayException( + `ZarinPal payment init failed (code ${code ?? 'unknown'})`, + ); + } + + const startBase = zarinpal.sandbox + ? ZARINPAL_START_SANDBOX + : ZARINPAL_START_LIVE; + + return { + gatewayRef: authority, + redirect: { + method: 'GET', + url: `${startBase}/${authority}`, + }, + meta: { code, amountRials, sandbox: zarinpal.sandbox }, + }; + } + + async verify( + credentials: GatewayCredentials, + input: VerifyPaymentInput, + ): Promise { + const zarinpal = credentials.zarinpal; + const status = readPayloadString( + input.callbackPayload, + 'Status', + 'status', + ).toUpperCase(); + const authority = + readPayloadString( + input.callbackPayload, + 'Authority', + 'authority', + ) || (input.gatewayRef?.trim() ?? ''); + + if (status && status !== 'OK') { + return { + success: false, + resCode: status, + message: `ZarinPal payment declined (Status ${status})`, + meta: { status, authority }, + }; + } + + if (!authority) { + return { + success: false, + resCode: 'missing_authority', + message: 'ZarinPal callback missing Authority', + meta: { status, authority }, + }; + } + + const amountRials = Math.round(input.amountIrt * 10); + const body = await this.apiCall(zarinpal.sandbox, 'verify.json', { + merchant_id: zarinpal.merchantId, + amount: amountRials, + authority, + }); + + const code = body.data?.code; + const refId = body.data?.ref_id; + const trackId = + refId !== undefined && refId !== null ? String(refId) : undefined; + + if (code !== ZARINPAL_OK && code !== ZARINPAL_ALREADY_VERIFIED) { + return { + success: false, + resCode: String(code ?? 'unknown'), + trackId, + message: `ZarinPal verify failed (code ${code ?? 'unknown'})`, + meta: { + status, + authority, + code, + errors: body.errors, + amountRials, + }, + }; + } + + return { + success: true, + resCode: String(code), + trackId, + message: 'Payment verified', + meta: { + status, + authority, + code, + refId, + cardPan: body.data?.card_pan, + amountRials, + }, + }; + } + + private async apiCall( + sandbox: boolean, + path: string, + payload: Record, + ): Promise { + const base = sandbox ? ZARINPAL_API_SANDBOX : ZARINPAL_API_LIVE; + const url = `${base}/${path}`; + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + } catch (err) { + this.logger.error(`ZarinPal network error (${path})`, err); + throw new BadGatewayException('Unable to reach ZarinPal payment gateway'); + } + + const text = await response.text(); + let body: ZarinpalEnvelope; + try { + body = JSON.parse(text) as ZarinpalEnvelope; + } catch { + this.logger.error( + `ZarinPal non-JSON (${path}) HTTP ${response.status}: ${text.slice(0, 400)}`, + ); + throw new BadGatewayException('ZarinPal payment gateway returned an error'); + } + + if (!response.ok && body.data?.code === undefined) { + this.logger.error( + `ZarinPal HTTP ${response.status} (${path}): ${text.slice(0, 400)}`, + ); + throw new BadGatewayException('ZarinPal payment gateway returned an error'); + } + + return body; + } +} diff --git a/src/payments/payment-gateway.registry.ts b/src/payments/payment-gateway.registry.ts index 485c2cf..cfdb539 100644 --- a/src/payments/payment-gateway.registry.ts +++ b/src/payments/payment-gateway.registry.ts @@ -1,14 +1,17 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import type { PaymentGatewayId } from '../business-settings/business-settings.types'; import { MellatGateway } from './gateways/mellat.gateway'; +import { ZarinpalGateway } from './gateways/zarinpal.gateway'; import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types'; @Injectable() export class PaymentGatewayRegistry { private readonly adapters: Map; - constructor(mellat: MellatGateway) { - this.adapters = new Map([[mellat.id, mellat]]); + constructor(mellat: MellatGateway, zarinpal: ZarinpalGateway) { + this.adapters = new Map(); + this.adapters.set(mellat.id, mellat); + this.adapters.set(zarinpal.id, zarinpal); } get(gatewayId: string): PaymentGatewayAdapter { diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index d0cf3f1..046fbae 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { BusinessSettingsModule } from '../business-settings/business-settings.module'; import { MellatGateway } from './gateways/mellat.gateway'; +import { ZarinpalGateway } from './gateways/zarinpal.gateway'; import { PaymentGatewayRegistry } from './payment-gateway.registry'; import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; @@ -8,7 +9,12 @@ import { PaymentsService } from './payments.service'; @Module({ imports: [BusinessSettingsModule], controllers: [PaymentsController], - providers: [MellatGateway, PaymentGatewayRegistry, PaymentsService], + providers: [ + MellatGateway, + ZarinpalGateway, + PaymentGatewayRegistry, + PaymentsService, + ], exports: [PaymentsService], }) export class PaymentsModule {} diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index a97c186..0a1f323 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -149,7 +149,10 @@ export class PaymentsService { ); const result = await adapter.initiate( - { mellat: ePayment.gateways.mellat }, + { + mellat: ePayment.gateways.mellat, + zarinpal: ePayment.gateways.zarinpal, + }, { businessId: input.businessId, orderId: BigInt(input.order.id), @@ -191,7 +194,13 @@ export class PaymentsService { const adapter = this.gateways.get(gatewayType); const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId'); - const refId = this.readString(payload, 'RefId', 'refId'); + const refId = this.readString( + payload, + 'RefId', + 'refId', + 'Authority', + 'authority', + ); const transaction = await this.findCallbackTransaction( businessId, @@ -254,7 +263,10 @@ export class PaymentsService { } const verify = await adapter.verify( - { mellat: settings.store.ePayment.gateways.mellat }, + { + mellat: settings.store.ePayment.gateways.mellat, + zarinpal: settings.store.ePayment.gateways.zarinpal, + }, { businessId, orderId: transaction.orderId, diff --git a/src/store/store-items.service.ts b/src/store/store-items.service.ts index de3d004..64efb9d 100644 --- a/src/store/store-items.service.ts +++ b/src/store/store-items.service.ts @@ -105,17 +105,20 @@ export class StoreItemsService { const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const where = await this.buildPublicVariantWhere(businessId, query); + const isSearch = Boolean(query.name?.trim()); - const [items, total] = await Promise.all([ - this.prisma.storeItemVariant.findMany({ - where, - orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], - skip, - take: pageSize, - include: variantInclude, - }), - this.prisma.storeItemVariant.count({ where }), - ]); + const [items, total] = isSearch + ? await this.listPublicSearchPage(where, skip, pageSize) + : await Promise.all([ + this.prisma.storeItemVariant.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + skip, + take: pageSize, + include: variantInclude, + }), + this.prisma.storeItemVariant.count({ where }), + ]); const productIds = [...new Set(items.map((item) => item.storeItem.productId))]; const [galleryByProduct, stockByProduct] = await Promise.all([ @@ -990,6 +993,81 @@ export class StoreItemsService { return assignment?.categoryId ?? null; } + private inStockWhere( + where: Prisma.StoreItemVariantWhereInput, + ): Prisma.StoreItemVariantWhereInput { + return { + AND: [ + where, + { OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }] }, + ], + }; + } + + private outOfStockWhere( + where: Prisma.StoreItemVariantWhereInput, + ): Prisma.StoreItemVariantWhereInput { + return { AND: [where, { stockQuantity: 0 }] }; + } + + /** Instant search: in-stock first (unlimited or qty > 0), then newest updatedAt. */ + private async listPublicSearchPage( + where: Prisma.StoreItemVariantWhereInput, + skip: number, + pageSize: number, + ): Promise<[VariantWithRelations[], number]> { + const inStockWhere = this.inStockWhere(where); + const outOfStockWhere = this.outOfStockWhere(where); + const orderBy: Prisma.StoreItemVariantOrderByWithRelationInput[] = [ + { updatedAt: 'desc' }, + { id: 'desc' }, + ]; + + const [inStockTotal, outOfStockTotal] = await Promise.all([ + this.prisma.storeItemVariant.count({ where: inStockWhere }), + this.prisma.storeItemVariant.count({ where: outOfStockWhere }), + ]); + + const total = inStockTotal + outOfStockTotal; + if (total === 0 || pageSize <= 0) { + return [[], total]; + } + + if (skip >= inStockTotal) { + const items = await this.prisma.storeItemVariant.findMany({ + where: outOfStockWhere, + orderBy, + skip: skip - inStockTotal, + take: pageSize, + include: variantInclude, + }); + return [items, total]; + } + + const inStockTake = Math.min(pageSize, inStockTotal - skip); + const inStockItems = await this.prisma.storeItemVariant.findMany({ + where: inStockWhere, + orderBy, + skip, + take: inStockTake, + include: variantInclude, + }); + + if (inStockItems.length >= pageSize) { + return [inStockItems, total]; + } + + const outOfStockItems = await this.prisma.storeItemVariant.findMany({ + where: outOfStockWhere, + orderBy, + skip: 0, + take: pageSize - inStockItems.length, + include: variantInclude, + }); + + return [[...inStockItems, ...outOfStockItems], total]; + } + private async buildPublicVariantWhere( businessId: bigint, query: ListPublicStoreItemsDto, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 91094f8..b3713cf 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -35,6 +35,56 @@ type UserListRow = { isActive: boolean; }; +/** + * ILIKE patterns so local Iranian input (09…) matches stored E.164 (+98…). + * `09024300` must match `+989024300340`; short fragments like `4300` still match. + */ +function cellNumberLikePatterns(input: string): string[] { + const trimmed = input.trim(); + if (!trimmed) return []; + + const patterns = new Set(); + patterns.add(`%${trimmed}%`); + + const digits = trimmed.replace(/\D/g, ''); + if (!digits) return [...patterns]; + + patterns.add(`%${digits}%`); + + let rest = digits; + if (rest.startsWith('00')) rest = rest.slice(2); + + if (rest.startsWith('98')) { + patterns.add(`%+${rest}%`); + const national = rest.slice(2); + if (national) { + patterns.add(`%0${national}%`); + patterns.add(`%${national}%`); + } + } else if (rest.startsWith('0')) { + const national = rest.slice(1); + if (national) { + patterns.add(`%+98${national}%`); + patterns.add(`%98${national}%`); + } + } else if (rest.startsWith('9')) { + patterns.add(`%+98${rest}%`); + patterns.add(`%98${rest}%`); + patterns.add(`%0${rest}%`); + } + + return [...patterns]; +} + +function cellNumberIlikeFilter(input: string | undefined): Prisma.Sql { + const patterns = cellNumberLikePatterns(input ?? ''); + if (!patterns.length) return Prisma.empty; + return Prisma.sql`AND (${Prisma.join( + patterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`), + ' OR ', + )})`; +} + @Injectable() export class UsersService { constructor( @@ -111,7 +161,6 @@ export class UsersService { const skip = (page - 1) * pageSize; const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null; - const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null; const roleSlug = query.role?.trim() || null; const businessId = query.businessId ?? null; const membership = query.membership ?? null; @@ -125,7 +174,7 @@ export class UsersService { OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} ) ` : Prisma.empty} - ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} + ${cellNumberIlikeFilter(query.cellNumber)} ${roleSlug ? Prisma.sql` AND EXISTS ( SELECT 1 @@ -346,6 +395,11 @@ export class UsersService { const q = query.q.trim(); const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); const like = `%${q}%`; + const cellPatterns = cellNumberLikePatterns(q); + const cellMatch = Prisma.join( + cellPatterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`), + ' OR ', + ); const items = await this.prisma.$queryRaw< { @@ -365,7 +419,7 @@ export class UsersService { FROM users u WHERE u.is_active = TRUE AND ( - u.cell_number ILIKE ${like} + ${cellMatch} OR u.first_name ILIKE ${like} OR u.last_name ILIKE ${like} OR u.email ILIKE ${like} diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index d264c22..09d791a 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -31,10 +31,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` 2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials -3. Catalog: categories, products, store-items, **user-products** (customer stock listings) +3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl` + - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` - Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank - Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…` - Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods` diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index b039dd9..0e449a6 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -1716,6 +1716,29 @@ "url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback", "description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl." } + }, + { + "name": "ZarinPal callback (gateway → API)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/payments/zarinpal/callback?Authority={{authority}}&Status=OK", + "host": ["{{baseUrl}}"], + "path": [ + "businesses", + "{{businessId}}", + "payments", + "zarinpal", + "callback" + ], + "query": [ + { "key": "Authority", "value": "{{authority}}" }, + { "key": "Status", "value": "OK" } + ] + }, + "description": "Called by ZarinPal (not by the website). Verifies payment, then 303 redirect to returnUrl." + } } ] }, diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 7942c1e..cdbe4bf 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -645,6 +645,7 @@ { "name": "name", "in": "query", + "description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.", "schema": { "type": "string" }