diff --git a/.env.example b/.env.example index eb2bf94..a570666 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1 SMS_GAMA_USERNAME= SMS_GAMA_PASSWORD= SMS_GAMA_SOURCE_SERVICE=5000110005 -# Optional later: SMS_GAMA_SOURCE_ADVERTISE=5000990009 -SMS_GAMA_SOURCE_ADVERTISE=5000990009 +# Optional: advertising / unverified-template shortcode (default 9000590009) +SMS_GAMA_SOURCE_ADVERTISE=9000590009 # Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped) # Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret SMS_PARTNERS= diff --git a/database/migrations/081_sms_templates.sql b/database/migrations/081_sms_templates.sql new file mode 100644 index 0000000..e54b7ff --- /dev/null +++ b/database/migrations/081_sms_templates.sql @@ -0,0 +1,36 @@ +-- SMS templates per business (verified by super-admin → Meshkee service shortcode). + +CREATE TABLE IF NOT EXISTS sms_templates ( + id BIGSERIAL PRIMARY KEY, + business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + verified_at TIMESTAMPTZ NULL, + verified_by BIGINT NULL REFERENCES users (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_sms_templates_business_id + ON sms_templates (business_id); + +CREATE INDEX IF NOT EXISTS idx_sms_templates_business_verified + ON sms_templates (business_id, is_verified); + +CREATE INDEX IF NOT EXISTS idx_sms_templates_created_at + ON sms_templates (created_at DESC); + +CREATE OR REPLACE FUNCTION sms_templates_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS sms_templates_set_updated_at ON sms_templates; +CREATE TRIGGER sms_templates_set_updated_at + BEFORE UPDATE ON sms_templates + FOR EACH ROW + EXECUTE FUNCTION sms_templates_set_updated_at(); diff --git a/database/migrations/082_business_sms_balance.sql b/database/migrations/082_business_sms_balance.sql new file mode 100644 index 0000000..eeaf349 --- /dev/null +++ b/database/migrations/082_business_sms_balance.sql @@ -0,0 +1,7 @@ +-- Per-business prepaid SMS credit (messages). Deducted when customer SMS is sent. + +ALTER TABLE businesses + ADD COLUMN IF NOT EXISTS sms_balance INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN businesses.sms_balance IS + 'Prepaid SMS message credits for this business; decremented on customer SMS send.'; diff --git a/database/migrations/083_sms_template_verification_status.sql b/database/migrations/083_sms_template_verification_status.sql new file mode 100644 index 0000000..799989b --- /dev/null +++ b/database/migrations/083_sms_template_verification_status.sql @@ -0,0 +1,21 @@ +-- SMS template verification workflow: free | pending | verified + +ALTER TABLE sms_templates + ADD COLUMN IF NOT EXISTS verification_status VARCHAR(32) NOT NULL DEFAULT 'free'; + +UPDATE sms_templates +SET verification_status = 'verified' +WHERE is_verified = TRUE; + +ALTER TABLE sms_templates + DROP CONSTRAINT IF EXISTS sms_templates_verification_status_check; + +ALTER TABLE sms_templates + ADD CONSTRAINT sms_templates_verification_status_check + CHECK (verification_status IN ('free', 'pending', 'verified')); + +CREATE INDEX IF NOT EXISTS idx_sms_templates_verification_status + ON sms_templates (verification_status); + +CREATE INDEX IF NOT EXISTS idx_sms_templates_business_status + ON sms_templates (business_id, verification_status); diff --git a/database/migrations/084_sms_balance_irt_billing.sql b/database/migrations/084_sms_balance_irt_billing.sql new file mode 100644 index 0000000..93b8cbb --- /dev/null +++ b/database/migrations/084_sms_balance_irt_billing.sql @@ -0,0 +1,6 @@ +-- SMS balance is prepaid IRT (not message counts). +-- Billing: ceil(message_length / 64) * 400 IRT per recipient +-- (login OTP, customer SMS, partner/public SMS, etc.). + +COMMENT ON COLUMN businesses.sms_balance IS + 'Prepaid SMS balance in IRT. Each 64-character segment costs 400 IRT per recipient.'; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 82241aa..a972544 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -453,7 +453,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f - Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens. - JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type` - SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`) -- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs. +- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message, source? }`; default sender advertising `9000590009`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs. --- diff --git a/docs/website-api/SMS.md b/docs/website-api/SMS.md index f7405ae..37129ba 100644 --- a/docs/website-api/SMS.md +++ b/docs/website-api/SMS.md @@ -24,6 +24,7 @@ X-Api-Key: | `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) | | `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` | | `message` | yes | Free text, max 700 characters | +| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` | ### Example (Balout) @@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**. ## Sender line (v1) -Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet. +Default sender is the **advertising** shortcode (`9000590009` / `SMS_GAMA_SOURCE_ADVERTISE`). +Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint. --- diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 58f1915..b645c15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { transactions Transaction[] @relation("TransactionCustomer") userProducts UserProduct[] userRoles UserRole[] + smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier") @@index([cellNumber], map: "idx_users_cell_number") @@map("users") @@ -69,6 +70,28 @@ model UserTitle { @@map("user_titles") } +model SmsTemplate { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + name String @db.VarChar(255) + body String @db.Text + verificationStatus String @default("free") @map("verification_status") @db.VarChar(32) + isVerified Boolean @default(false) @map("is_verified") + verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) + verifiedBy BigInt? @map("verified_by") + 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) + verifier User? @relation("SmsTemplateVerifier", fields: [verifiedBy], references: [id], onDelete: SetNull, onUpdate: NoAction) + + @@index([businessId], map: "idx_sms_templates_business_id") + @@index([businessId, isVerified], map: "idx_sms_templates_business_verified") + @@index([verificationStatus], map: "idx_sms_templates_verification_status") + @@index([businessId, verificationStatus], map: "idx_sms_templates_business_status") + @@index([createdAt(sort: Desc)], map: "idx_sms_templates_created_at") + @@map("sms_templates") +} + model Business { id BigInt @id @default(autoincrement()) name String @db.VarChar(255) @@ -89,12 +112,14 @@ model Business { faviconMediaId BigInt? @map("favicon_media_id") oldBusinessId BigInt? @map("old_business_id") annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6) + smsBalance Int @default(0) @map("sms_balance") addresses Address[] blogs blogs[] brands Brand[] categoryAssignments BusinessCategoryAssignment[] businessCustomers BusinessCustomer[] businessUsers BusinessUser[] + smsTemplates SmsTemplate[] faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction) logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction) logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction) diff --git a/src/app.module.ts b/src/app.module.ts index 7bf273a..c6120ba 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -44,6 +44,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module'; import { PublicSmsModule } from './public-sms/public-sms.module'; import { PaymentsModule } from './payments/payments.module'; import { SitemapModule } from './sitemap/sitemap.module'; +import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { TorobModule } from './torob/torob.module'; @Module({ @@ -93,6 +94,7 @@ import { TorobModule } from './torob/torob.module'; AiPromptsModule, PublicSmsModule, SitemapModule, + SmsTemplatesModule, TorobModule, ], }) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 615a217..b60ad5f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -9,6 +9,7 @@ import { AuthService } from './auth.service'; import { BusinessPermissionGuard } from './guards/business-permission.guard'; import { PermissionsService } from './permissions.service'; import { SmsService } from './sms.service'; +import { SmsBillingService } from './sms-billing.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { UserAddressesService } from './user-addresses.service'; @@ -33,10 +34,17 @@ import { UserAddressesService } from './user-addresses.service'; AuthService, UserAddressesService, SmsService, + SmsBillingService, PermissionsService, BusinessPermissionGuard, JwtStrategy, ], - exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService], + exports: [ + AuthService, + PermissionsService, + BusinessPermissionGuard, + SmsService, + SmsBillingService, + ], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index cadeb32..b73dee1 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -28,6 +28,7 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; import { PermissionsService } from './permissions.service'; import { parseUserProfile } from './profile.util'; import { SmsService } from './sms.service'; +import { SmsBillingService } from './sms-billing.service'; const OTP_TTL_SECONDS = 300; /** Pending cross-site password change (longer than OTP so resend still works). */ @@ -44,6 +45,7 @@ export class AuthService { private readonly config: ConfigService, private readonly redis: RedisService, private readonly sms: SmsService, + private readonly smsBilling: SmsBillingService, private readonly tenant: TenantService, private readonly permissions: PermissionsService, ) {} @@ -403,7 +405,7 @@ export class AuthService { return { message: 'Password changed successfully' }; } - async sendOtp(cellNumber: string, domain?: string) { + async sendOtp(cellNumber: string, domain: string) { if (!this.sms.isEnabled()) { return { enabled: false, @@ -420,24 +422,29 @@ export class AuthService { throw new UnauthorizedException('Cell number is not registered'); } - let businessNameFa: string | undefined; const host = domain?.trim(); - if (host) { - try { - const business = await this.tenant.resolveBusinessByDomain(host); - businessNameFa = - business.nameFa?.trim() || business.name?.trim() || undefined; - } catch { - // Domain may be unknown — still send OTP without branding suffix - } + if (!host) { + throw new BadRequestException( + 'Domain is required to send SMS (prepaid balance is billed per business).', + ); } + const business = await this.tenant.resolveBusinessByDomain(host); + const businessNameFa = + business.nameFa?.trim() || business.name?.trim() || undefined; const code = this.generateOtpCode(); + const brand = businessNameFa?.trim(); + const message = brand + ? `کد تایید شما: ${code}\n${brand}` + : `کد تایید شما: ${code}`; + + const charged = await this.smsBilling.charge(business.id, message); await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS); try { - await this.sms.sendVerificationCode(cellNumber, code, businessNameFa); + await this.sms.sendMessage(cellNumber, message); } catch (err) { + await this.smsBilling.refund(business.id, charged); if (err instanceof HttpException) { throw err; } diff --git a/src/auth/dto/send-otp.dto.ts b/src/auth/dto/send-otp.dto.ts index 04b2e11..b4ec01d 100644 --- a/src/auth/dto/send-otp.dto.ts +++ b/src/auth/dto/send-otp.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsString, Matches, MaxLength } from 'class-validator'; export class SendOtpDto { @IsString() @@ -7,9 +7,8 @@ export class SendOtpDto { }) cellNumber!: string; - /** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */ - @IsOptional() + /** Tenant host/apex — required so OTP SMS is billed to the business. */ @IsString() @MaxLength(253) - domain?: string; + domain!: string; } diff --git a/src/auth/sms-billing.service.ts b/src/auth/sms-billing.service.ts new file mode 100644 index 0000000..85e3726 --- /dev/null +++ b/src/auth/sms-billing.service.ts @@ -0,0 +1,81 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { calculateSmsCostIrt } from './sms-billing'; + +@Injectable() +export class SmsBillingService { + constructor( + private readonly prisma: PrismaService, + private readonly tenant: TenantService, + ) {} + + costForMessage(message: string, recipientCount = 1): number { + return calculateSmsCostIrt(message.trim().length, recipientCount); + } + + /** + * Resolve tenant business from any Meshkee / external domain host. + * Returns null when the host is not linked to an active business. + */ + async findBusinessIdByDomain(host: string | undefined | null): Promise { + const trimmed = host?.trim(); + if (!trimmed) return null; + try { + const business = await this.tenant.resolveBusinessByDomain(trimmed); + return business.id; + } catch { + return null; + } + } + + async requireBusinessIdByDomain(host: string): Promise { + const id = await this.findBusinessIdByDomain(host); + if (!id) { + throw new NotFoundException( + `No business found for domain: ${host}. Link the domain to a business first.`, + ); + } + return id; + } + + /** Atomically deduct IRT for a message. Returns charged amount. */ + async charge( + businessId: bigint, + message: string, + recipientCount = 1, + ): Promise { + return this.chargeAmount( + businessId, + this.costForMessage(message, recipientCount), + ); + } + + /** Atomically deduct an explicit IRT amount. Returns charged amount. */ + async chargeAmount(businessId: bigint, amount: number): Promise { + const cost = Math.floor(amount); + if (cost <= 0) return 0; + + const updated = await this.prisma.$executeRaw` + UPDATE businesses + SET sms_balance = sms_balance - ${cost} + WHERE id = ${businessId} AND sms_balance >= ${cost} + `; + if (updated === 0) { + throw new BadRequestException( + 'Insufficient SMS balance. Contact Meshkee to top up.', + ); + } + return cost; + } + + async refund(businessId: bigint, amount: number): Promise { + const credit = Math.floor(amount); + if (credit <= 0) return; + await this.prisma.$executeRaw` + UPDATE businesses + SET sms_balance = sms_balance + ${credit} + WHERE id = ${businessId} + `; + } +} diff --git a/src/auth/sms-billing.ts b/src/auth/sms-billing.ts new file mode 100644 index 0000000..00ba074 --- /dev/null +++ b/src/auth/sms-billing.ts @@ -0,0 +1,20 @@ +/** Prepaid SMS billing: each 64-character segment costs 400 IRT per recipient. */ + +export const SMS_SEGMENT_CHARS = 64; +export const SMS_COST_PER_SEGMENT_IRT = 400; +export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = 500_000; + +/** + * Cost in IRT for one or more recipients. + * 1–64 chars → 400, 65–128 → 800, etc. + */ +export function calculateSmsCostIrt( + messageLength: number, + recipientCount = 1, +): number { + const length = Math.max(0, Math.floor(messageLength)); + const recipients = Math.max(0, Math.floor(recipientCount)); + if (recipients === 0 || length === 0) return 0; + const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS)); + return segments * SMS_COST_PER_SEGMENT_IRT * recipients; +} diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts index 0729ef8..55d00e3 100644 --- a/src/auth/sms.service.ts +++ b/src/auth/sms.service.ts @@ -21,7 +21,7 @@ export type SmsSendOptions = { }; /** Default Meshkee advertising shortcode when a business has no own SMS number. */ -export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009'; +export const DEFAULT_ADVERTISING_SMS_SOURCE = '9000590009'; /** Gama batch size limit is undocumented; keep chunks conservative. */ const GAMA_BULK_CHUNK_SIZE = 100; @@ -58,6 +58,64 @@ export class SmsService { ); } + /** Meshkee service shortcode (OTP + verified SMS templates). */ + getServiceSource(): string { + return ( + this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim() || + '5000110005' + ); + } + + /** + * Best-effort account credit from Gama. Returns null when the provider + * does not expose credit over REST (check the Gama panel instead). + */ + async getAccountCredit(): Promise<{ balance: number; currency?: string } | null> { + if (!this.isEnabled() || this.isProxyConfigured()) { + return null; + } + + const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); + const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); + const baseUrl = ( + this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' + ).replace(/\/$/, ''); + + if (!username || !password) return null; + + // Gama REST docs do not document a credit endpoint; try common paths quietly. + for (const path of ['/account/credit', '/credit', '/user/credit']) { + try { + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) continue; + const payload = (await response.json()) as { + success?: boolean; + body?: number | { credit?: number; balance?: number }; + }; + if (!payload.success) continue; + const raw = + typeof payload.body === 'number' + ? payload.body + : payload.body?.credit ?? payload.body?.balance; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return { balance: raw }; + } + } catch { + // ignore and try next path + } + } + + return null; + } + async sendVerificationCode( cellNumber: string, code: string, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index ded3bd5..1e0810e 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -9,6 +9,7 @@ import { import { Prisma } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { PermissionsService } from '../auth/permissions.service'; +import { DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT } from '../auth/sms-billing'; import { PrismaService } from '../prisma/prisma.service'; import { AuthUser } from '../auth/auth.types'; import { AddDomainDto } from './dto/add-domain.dto'; @@ -81,6 +82,7 @@ type BusinessRow = { enabledModules: unknown; homeCharts: unknown; smsSenderNumber: string | null; + smsBalance: number; }; function slugify(value: string) { @@ -166,7 +168,8 @@ export class BusinessAdminService { b.settings->'branding'->>'themeMode' AS "themeMode", b.settings->'modules'->'enabled' AS "enabledModules", b.settings->'modules'->'charts' AS "homeCharts", - b.settings->'sms'->>'senderNumber' AS "smsSenderNumber" + b.settings->'sms'->>'senderNumber' AS "smsSenderNumber", + b.sms_balance AS "smsBalance" FROM businesses b LEFT JOIN LATERAL ( SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug @@ -228,6 +231,7 @@ export class BusinessAdminService { moduleCount: enabledModules.length, homeCharts, smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber), + smsBalance: Number(item.smsBalance ?? 0), }; }), total: totalRow[0]?.total ?? 0, @@ -387,6 +391,7 @@ export class BusinessAdminService { : new Date(), oldBusinessId: dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined, + smsBalance: DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT, settings: toPrismaJson({ ...DEFAULT_BUSINESS_SETTINGS, modules: { @@ -466,6 +471,10 @@ export class BusinessAdminService { } } + if (dto.smsBalance !== undefined && dto.smsBalance < 0) { + throw new BadRequestException('smsBalance cannot be negative'); + } + await this.prisma.$transaction(async (tx) => { const nextSettings = dto.smsSenderNumber !== undefined @@ -503,6 +512,7 @@ export class BusinessAdminService { } : {}), ...(nextSettings !== undefined ? { settings: nextSettings } : {}), + ...(dto.smsBalance !== undefined ? { smsBalance: dto.smsBalance } : {}), }, }); @@ -1272,6 +1282,7 @@ export class BusinessAdminService { createdAt: Date; updatedAt: Date; settings: unknown; + smsBalance?: number; categoryAssignments: { category: { id: bigint; @@ -1316,6 +1327,7 @@ export class BusinessAdminService { createdAt: business.createdAt, updatedAt: business.updatedAt, smsSenderNumber: settings.sms.senderNumber, + smsBalance: business.smsBalance ?? 0, categories: business.categoryAssignments.map((a) => ({ id: a.category.id, name: a.category.name, diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts index 44ee4ad..315313b 100644 --- a/src/business-admin/dto/update-business.dto.ts +++ b/src/business-admin/dto/update-business.dto.ts @@ -7,6 +7,7 @@ import { IsPositive, IsString, Matches, + Min, MinLength, ValidateIf, } from 'class-validator'; @@ -71,4 +72,11 @@ export class UpdateBusinessDto { message: 'smsSenderNumber must be an 8–16 digit shortcode', }) smsSenderNumber?: string | null; + + /** Prepaid SMS message credits for this business (absolute set). */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + smsBalance?: number; } diff --git a/src/customers/customers.module.ts b/src/customers/customers.module.ts index 0be9cb6..b16f802 100644 --- a/src/customers/customers.module.ts +++ b/src/customers/customers.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { CustomersController } from './customers.controller'; import { CustomersService } from './customers.service'; +import { SmsTemplatesModule } from '../sms-templates/sms-templates.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, SmsTemplatesModule], controllers: [CustomersController], providers: [CustomersService], }) diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 1d4d724..212555c 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt'; import { randomBytes } from 'crypto'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { SmsBillingService } from '../auth/sms-billing.service'; import { SmsService } from '../auth/sms.service'; import { buildDualDailyActivitySeries, @@ -21,6 +22,11 @@ import { SearchCustomersDto } from './dto/search-customers.dto'; import { SendCustomerSmsDto } from './dto/send-customer-sms.dto'; import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto'; +import { SmsTemplatesService } from '../sms-templates/sms-templates.service'; +import { + renderSmsTemplate, + smsTemplateUsesVariables, +} from '../sms-templates/sms-template-render'; type CustomerListRow = { id: bigint; @@ -43,6 +49,8 @@ export class CustomersService { private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly sms: SmsService, + private readonly smsBilling: SmsBillingService, + private readonly smsTemplates: SmsTemplatesService, ) {} async list( @@ -692,7 +700,7 @@ export class CustomersService { businessId_userId: { businessId, userId }, }, include: { - user: true, + user: { include: { title: true } }, business: { select: { settings: true, isActive: true } }, }, }); @@ -708,23 +716,36 @@ export class CustomersService { }; } - const settings = normalizeBusinessSettings(membership.business.settings); - const source = - settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); + const resolved = await this.resolveOutboundSms( + businessId, + membership.business.settings, + dto.message, + dto.templateId, + { + firstName: membership.user.firstName, + lastName: membership.user.lastName, + titleName: membership.user.title?.nameFa || membership.user.title?.nameEn, + }, + ); + + const charged = await this.smsBilling.charge(businessId, resolved.message); try { const result = await this.sms.sendMessage( membership.user.cellNumber, - dto.message.trim(), - { source }, + resolved.message, + { source: resolved.source }, ); return { enabled: true, message: 'Message sent successfully', serverId: result.serverId, - source, + source: resolved.source, + templateId: resolved.templateId, + templateVerified: resolved.templateVerified, }; } catch (err) { + await this.smsBilling.refund(businessId, charged); if ( err instanceof BadRequestException || err instanceof ServiceUnavailableException @@ -736,8 +757,8 @@ export class CustomersService { } /** - * Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers. - * Selected userIds may include team members shown on the customers list. + * Bulk SMS. Empty/omitted userIds → all enabled business customers. + * With a personalized template, messages are sent per-recipient (not one bulk payload). */ async sendBulkSms( businessIdRaw: string, @@ -759,7 +780,14 @@ export class CustomersService { .map((id) => id.trim()) .filter(Boolean); - let cellNumbers: string[]; + type Recipient = { + cellNumber: string; + firstName: string | null; + lastName: string | null; + titleName: string | null; + }; + + let recipients: Recipient[]; if (selectedIds.length > 0) { const userIds = selectedIds.map((id) => { @@ -770,9 +798,21 @@ export class CustomersService { } }); - const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql` - SELECT DISTINCT u.cell_number AS "cellNumber" + const rows = await this.prisma.$queryRaw< + { + cellNumber: string; + firstName: string | null; + lastName: string | null; + titleName: string | null; + }[] + >(Prisma.sql` + SELECT DISTINCT ON (u.id) + u.cell_number AS "cellNumber", + u.first_name AS "firstName", + u.last_name AS "lastName", + COALESCE(ut.name_fa, ut.name_en) AS "titleName" FROM users u + LEFT JOIN user_titles ut ON ut.id = u.title_id LEFT JOIN business_customers bc ON bc.user_id = u.id AND bc.business_id = ${businessId} LEFT JOIN business_users bu @@ -780,16 +820,30 @@ export class CustomersService { WHERE u.id IN (${Prisma.join(userIds)}) AND (bc.id IS NOT NULL OR bu.id IS NOT NULL) `); - cellNumbers = rows.map((r) => r.cellNumber); + recipients = rows; } else { const rows = await this.prisma.businessCustomer.findMany({ where: { businessId, isEnabled: true }, - select: { user: { select: { cellNumber: true } } }, + select: { + user: { + select: { + cellNumber: true, + firstName: true, + lastName: true, + title: { select: { nameFa: true, nameEn: true } }, + }, + }, + }, }); - cellNumbers = rows.map((r) => r.user.cellNumber); + recipients = rows.map((r) => ({ + cellNumber: r.user.cellNumber, + firstName: r.user.firstName, + lastName: r.user.lastName, + titleName: r.user.title?.nameFa || r.user.title?.nameEn || null, + })); } - if (cellNumbers.length === 0) { + if (recipients.length === 0) { throw new BadRequestException('No recipients found to send SMS'); } @@ -797,26 +851,117 @@ export class CustomersService { return { enabled: false, message: 'SMS is disabled. Message was not sent.', - recipientCount: cellNumbers.length, + recipientCount: recipients.length, }; } - const settings = normalizeBusinessSettings(business.settings); - const source = - settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); + const sample = await this.resolveOutboundSms( + businessId, + business.settings, + dto.message, + dto.templateId, + { + firstName: recipients[0].firstName, + lastName: recipients[0].lastName, + titleName: recipients[0].titleName, + }, + ); + + const personalized = + Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message); + + let charged = 0; + let personalizedPayloads: Array<{ + cellNumber: string; + message: string; + source: string; + cost: number; + }> = []; + + if (!personalized) { + charged = await this.smsBilling.charge( + businessId, + sample.message, + recipients.length, + ); + } else { + personalizedPayloads = []; + let totalCost = 0; + for (const recipient of recipients) { + const resolved = await this.resolveOutboundSms( + businessId, + business.settings, + dto.message, + dto.templateId, + { + firstName: recipient.firstName, + lastName: recipient.lastName, + titleName: recipient.titleName, + }, + ); + const cost = this.smsBilling.costForMessage(resolved.message); + personalizedPayloads.push({ + cellNumber: recipient.cellNumber, + message: resolved.message, + source: resolved.source, + cost, + }); + totalCost += cost; + } + charged = await this.smsBilling.chargeAmount(businessId, totalCost); + } try { - const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), { - source, - }); + if (!personalized) { + const result = await this.sms.sendBulkMessage( + recipients.map((r) => r.cellNumber), + sample.message, + { source: sample.source }, + ); + return { + enabled: true, + message: 'Message sent successfully', + recipientCount: result.recipientCount, + serverIds: result.serverIds, + source: sample.source, + templateId: sample.templateId, + templateVerified: sample.templateVerified, + }; + } + + const serverIds: string[] = []; + for (let i = 0; i < personalizedPayloads.length; i++) { + const payload = personalizedPayloads[i]; + try { + const result = await this.sms.sendMessage( + payload.cellNumber, + payload.message, + { source: payload.source }, + ); + serverIds.push(result.serverId); + } catch (err) { + const refundAmount = personalizedPayloads + .slice(i) + .reduce((sum, item) => sum + item.cost, 0); + await this.smsBilling.refund(businessId, refundAmount); + charged = 0; + throw err; + } + } + return { enabled: true, message: 'Message sent successfully', - recipientCount: result.recipientCount, - serverIds: result.serverIds, - source, + recipientCount: serverIds.length, + serverIds, + source: sample.source, + templateId: sample.templateId, + templateVerified: sample.templateVerified, }; } catch (err) { + if (charged > 0) { + await this.smsBilling.refund(businessId, charged); + } if ( err instanceof BadRequestException || err instanceof ServiceUnavailableException @@ -827,6 +972,60 @@ export class CustomersService { } } + /** + * Resolve message text + Gama shortcode for customer SMS. + * Verified template → service line (fixed). Unverified template → advertising line. + * Free text → business senderNumber or advertising (unchanged). + */ + private async resolveOutboundSms( + businessId: bigint, + businessSettings: unknown, + messageRaw: string, + templateIdRaw: string | undefined, + vars: { + firstName?: string | null; + lastName?: string | null; + titleName?: string | null; + }, + ) { + const settings = normalizeBusinessSettings(businessSettings); + let message = messageRaw.trim(); + let templateId: string | null = null; + let templateVerified: boolean | null = null; + let source = + settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); + + if (templateIdRaw?.trim()) { + const template = await this.smsTemplates.getTemplateForSend( + businessId, + BigInt(templateIdRaw.trim()), + ); + templateId = template.id.toString(); + templateVerified = template.isVerified; + // Verified templates always use the approved body (sender is also fixed). + const draft = template.isVerified + ? template.body + : message || template.body; + message = renderSmsTemplate(draft, vars); + source = template.isVerified + ? this.sms.getServiceSource() + : this.sms.getAdvertisingSource(); + } else if (smsTemplateUsesVariables(message)) { + message = renderSmsTemplate(message, vars); + } + + if (!message.trim()) { + throw new BadRequestException('Message is empty'); + } + + return { + message: message.trim(), + source, + templateId, + templateVerified, + }; + } + private formatLabel(user: { firstName: string | null; lastName: string | null; diff --git a/src/customers/dto/send-customer-sms.dto.ts b/src/customers/dto/send-customer-sms.dto.ts index 2f9f417..a456bb9 100644 --- a/src/customers/dto/send-customer-sms.dto.ts +++ b/src/customers/dto/send-customer-sms.dto.ts @@ -1,8 +1,13 @@ -import { IsString, MaxLength, MinLength } from 'class-validator'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; export class SendCustomerSmsDto { @IsString() @MinLength(1) @MaxLength(700) message!: string; + + /** Optional SMS template id — body may still be sent as the rendered text. */ + @IsOptional() + @IsString() + templateId?: string; } diff --git a/src/customers/dto/send-customers-bulk-sms.dto.ts b/src/customers/dto/send-customers-bulk-sms.dto.ts index eafd63b..6d84b2f 100644 --- a/src/customers/dto/send-customers-bulk-sms.dto.ts +++ b/src/customers/dto/send-customers-bulk-sms.dto.ts @@ -24,4 +24,8 @@ export class SendCustomersBulkSmsDto { @IsString({ each: true }) @Type(() => String) userIds?: string[]; + + @IsOptional() + @IsString() + templateId?: string; } diff --git a/src/public-sms/public-sms.service.ts b/src/public-sms/public-sms.service.ts index 3d291ab..168b726 100644 --- a/src/public-sms/public-sms.service.ts +++ b/src/public-sms/public-sms.service.ts @@ -6,6 +6,7 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { RedisService } from '../redis/redis.service'; +import { SmsBillingService } from '../auth/sms-billing.service'; import { SmsService, toGamaMsisdn } from '../auth/sms.service'; import { SendPublicSmsDto } from './dto/send-public-sms.dto'; @@ -19,6 +20,7 @@ export class PublicSmsService { constructor( private readonly sms: SmsService, + private readonly smsBilling: SmsBillingService, private readonly redis: RedisService, ) {} @@ -34,17 +36,31 @@ export class PublicSmsService { await this.assertRateLimits(partnerDomain, msisdn); - const result = await this.sms.sendMessage(msisdn, dto.message, { - source: dto.source?.trim() || undefined, - }); - this.logger.log( - `Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`, - ); + const businessId = + await this.smsBilling.requireBusinessIdByDomain(partnerDomain); + const charged = await this.smsBilling.charge(businessId, dto.message); - return { - success: true, - serverId: result.serverId, - }; + // Partners / free-text: advertising shortcode unless an explicit source is provided. + // Verified Meshkee templates use the service line via the business customers SMS API. + const source = + dto.source?.trim() || this.sms.getAdvertisingSource(); + + try { + const result = await this.sms.sendMessage(msisdn, dto.message, { + source, + }); + this.logger.log( + `Partner SMS accepted domain=${partnerDomain} source=${source} serverId=${result.serverId} charged=${charged}`, + ); + + return { + success: true, + serverId: result.serverId, + }; + } catch (err) { + await this.smsBilling.refund(businessId, charged); + throw err; + } } private async assertRateLimits(domain: string, msisdn: string): Promise { diff --git a/src/sms-templates/dto/create-sms-template.dto.ts b/src/sms-templates/dto/create-sms-template.dto.ts new file mode 100644 index 0000000..ed98bfc --- /dev/null +++ b/src/sms-templates/dto/create-sms-template.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateSmsTemplateDto { + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsString() + @MinLength(1) + @MaxLength(700) + body!: string; +} diff --git a/src/sms-templates/dto/update-sms-template.dto.ts b/src/sms-templates/dto/update-sms-template.dto.ts new file mode 100644 index 0000000..86000d4 --- /dev/null +++ b/src/sms-templates/dto/update-sms-template.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateSmsTemplateDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(700) + body?: string; +} diff --git a/src/sms-templates/sms-template-render.ts b/src/sms-templates/sms-template-render.ts new file mode 100644 index 0000000..1a95add --- /dev/null +++ b/src/sms-templates/sms-template-render.ts @@ -0,0 +1,36 @@ +/** Variables available in SMS templates (double or single braces). */ +export const SMS_TEMPLATE_VARIABLES = [ + 'user.name', + 'user.lastname', + 'user.title', +] as const; + +export type SmsTemplateVarContext = { + firstName?: string | null; + lastName?: string | null; + titleName?: string | null; +}; + +const VAR_REGEX = + /\{\{\s*(user\.(?:name|lastname|title|firstName|lastName))\s*\}\}|\{(user\.(?:name|lastname|title|firstName|lastName))\}/gi; + +export function smsTemplateUsesVariables(body: string): boolean { + return new RegExp(VAR_REGEX.source, 'i').test(body); +} + +export function renderSmsTemplate( + body: string, + ctx: SmsTemplateVarContext, +): string { + const first = (ctx.firstName ?? '').trim(); + const last = (ctx.lastName ?? '').trim(); + const title = (ctx.titleName ?? '').trim(); + + return body.replace(new RegExp(VAR_REGEX.source, 'gi'), (_full, a?: string, b?: string) => { + const key = (a || b || '').toLowerCase(); + if (key === 'user.name' || key === 'user.firstname') return first; + if (key === 'user.lastname') return last; + if (key === 'user.title') return title; + return ''; + }); +} diff --git a/src/sms-templates/sms-templates.controller.ts b/src/sms-templates/sms-templates.controller.ts new file mode 100644 index 0000000..b31cf9d --- /dev/null +++ b/src/sms-templates/sms-templates.controller.ts @@ -0,0 +1,137 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + 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 { CreateSmsTemplateDto } from './dto/create-sms-template.dto'; +import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto'; +import { SmsTemplatesService } from './sms-templates.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class SmsTemplatesController { + constructor(private readonly service: SmsTemplatesService) {} + + @Get('businesses/:businessId/sms/credit') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.read') + credit( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.creditForBusiness(businessId, user); + } + + @Get('businesses/:businessId/sms/templates') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.read') + listForBusiness( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.listForBusiness(businessId, user); + } + + @Post('businesses/:businessId/sms/templates') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + createForBusiness( + @Param('businessId') businessId: string, + @Body() dto: CreateSmsTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.createForBusiness(businessId, dto, user); + } + + @Patch('businesses/:businessId/sms/templates/:templateId') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + updateForBusiness( + @Param('businessId') businessId: string, + @Param('templateId') templateId: string, + @Body() dto: UpdateSmsTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateForBusiness(businessId, templateId, dto, user); + } + + @Delete('businesses/:businessId/sms/templates/:templateId') + @HttpCode(200) + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + removeForBusiness( + @Param('businessId') businessId: string, + @Param('templateId') templateId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.removeForBusiness(businessId, templateId, user); + } + + @Post('businesses/:businessId/sms/templates/:templateId/request-verification') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + requestVerification( + @Param('businessId') businessId: string, + @Param('templateId') templateId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.requestVerification(businessId, templateId, user); + } + + @Get('sms-templates') + listAll( + @CurrentUser() user: AuthUser, + @Query('status') status?: string, + @Query('verified') verified?: string, + ) { + // Prefer status=pending|verified|all; keep verified= for older clients. + let filter: 'free' | 'pending' | 'verified' | 'all' | undefined; + if (status === 'pending' || status === 'verified' || status === 'all') { + filter = status; + } else if (verified === 'true') { + filter = 'verified'; + } else if (verified === 'false') { + filter = 'pending'; + } else { + filter = 'all'; + } + return this.service.listAll(user, filter); + } + + @Patch('sms-templates/:templateId') + updateAsAdmin( + @Param('templateId') templateId: string, + @Body() dto: UpdateSmsTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateAsAdmin(templateId, dto, user); + } + + @Post('sms-templates/:templateId/verify') + verify( + @Param('templateId') templateId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.verify(templateId, user, true); + } + + @Post('sms-templates/:templateId/unverify') + unverify( + @Param('templateId') templateId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.verify(templateId, user, false); + } +} diff --git a/src/sms-templates/sms-templates.module.ts b/src/sms-templates/sms-templates.module.ts new file mode 100644 index 0000000..148de26 --- /dev/null +++ b/src/sms-templates/sms-templates.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { SmsTemplatesController } from './sms-templates.controller'; +import { SmsTemplatesService } from './sms-templates.service'; + +@Module({ + imports: [AuthModule], + controllers: [SmsTemplatesController], + providers: [SmsTemplatesService], + exports: [SmsTemplatesService], +}) +export class SmsTemplatesModule {} diff --git a/src/sms-templates/sms-templates.service.ts b/src/sms-templates/sms-templates.service.ts new file mode 100644 index 0000000..624fc0b --- /dev/null +++ b/src/sms-templates/sms-templates.service.ts @@ -0,0 +1,377 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateSmsTemplateDto } from './dto/create-sms-template.dto'; +import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto'; +import { SMS_TEMPLATE_VARIABLES } from './sms-template-render'; + +export type SmsTemplateVerificationStatus = 'free' | 'pending' | 'verified'; + +const CLEAR_VERIFICATION = { + verificationStatus: 'free' as const, + isVerified: false, + verifiedAt: null, + verifiedBy: null, +}; + +@Injectable() +export class SmsTemplatesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async listForBusiness(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.read'); + + const items = await this.prisma.smsTemplate.findMany({ + where: { businessId }, + orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], + }); + + return { + items: items.map((row) => this.serialize(row)), + variables: [...SMS_TEMPLATE_VARIABLES], + }; + } + + async createForBusiness( + businessIdRaw: string, + dto: CreateSmsTemplateDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.update'); + + const created = await this.prisma.smsTemplate.create({ + data: { + businessId, + name: dto.name.trim(), + body: dto.body.trim(), + verificationStatus: 'free', + isVerified: false, + }, + }); + + return { template: this.serialize(created) }; + } + + async updateForBusiness( + businessIdRaw: string, + templateIdRaw: string, + dto: UpdateSmsTemplateDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const templateId = BigInt(templateIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.smsTemplate.findFirst({ + where: { id: templateId, businessId }, + }); + if (!existing) { + throw new NotFoundException('SMS template not found'); + } + + const name = dto.name?.trim(); + const body = dto.body?.trim(); + const nameChanged = name !== undefined && name !== existing.name; + const bodyChanged = body !== undefined && body !== existing.body; + const contentChanged = nameChanged || bodyChanged; + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { + ...(name !== undefined ? { name } : {}), + ...(body !== undefined ? { body } : {}), + ...(contentChanged ? CLEAR_VERIFICATION : {}), + }, + }); + + return { template: this.serialize(updated) }; + } + + async requestVerification( + businessIdRaw: string, + templateIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const templateId = BigInt(templateIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.smsTemplate.findFirst({ + where: { id: templateId, businessId }, + }); + if (!existing) { + throw new NotFoundException('SMS template not found'); + } + if (existing.isVerified || existing.verificationStatus === 'verified') { + throw new BadRequestException('Template is already verified'); + } + if (existing.verificationStatus === 'pending') { + return { template: this.serialize(existing) }; + } + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { + verificationStatus: 'pending', + isVerified: false, + verifiedAt: null, + verifiedBy: null, + }, + }); + + return { template: this.serialize(updated) }; + } + + async removeForBusiness( + businessIdRaw: string, + templateIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const templateId = BigInt(templateIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.smsTemplate.findFirst({ + where: { id: templateId, businessId }, + }); + if (!existing) { + throw new NotFoundException('SMS template not found'); + } + + await this.prisma.smsTemplate.delete({ where: { id: templateId } }); + return { success: true }; + } + + async creditForBusiness(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertBusinessPermission(businessId, actor.id, 'orders.read'); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + select: { smsBalance: true }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + return { + balance: business.smsBalance, + credit: business.smsBalance, + currency: null, + message: null, + }; + } + + /** + * Super-admin list — only pending + verified (free templates stay in business UI only). + */ + async listAll( + actor: AuthUser, + status?: SmsTemplateVerificationStatus | 'all', + ) { + await this.assertSuperAdmin(actor); + + const statusFilter = + status === 'pending' || status === 'verified' + ? status + : undefined; + + const items = await this.prisma.smsTemplate.findMany({ + where: { + verificationStatus: statusFilter + ? statusFilter + : { in: ['pending', 'verified'] }, + }, + include: { + business: { select: { id: true, name: true, nameFa: true, slug: true } }, + }, + orderBy: [ + { verificationStatus: 'asc' }, + { updatedAt: 'desc' }, + { id: 'desc' }, + ], + take: 500, + }); + + return { + items: items.map((row) => this.serializeAdmin(row)), + variables: [...SMS_TEMPLATE_VARIABLES], + }; + } + + async verify(templateIdRaw: string, actor: AuthUser, verified: boolean) { + await this.assertSuperAdmin(actor); + const templateId = BigInt(templateIdRaw); + + const existing = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + }); + if (!existing) { + throw new NotFoundException('SMS template not found'); + } + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: verified + ? { + verificationStatus: 'verified', + isVerified: true, + verifiedAt: new Date(), + verifiedBy: actor.id, + } + : CLEAR_VERIFICATION, + include: { + business: { select: { id: true, name: true, nameFa: true, slug: true } }, + }, + }); + + return { template: this.serializeAdmin(updated) }; + } + + /** Super-admin edit. Content changes clear verification back to free. */ + async updateAsAdmin( + templateIdRaw: string, + dto: UpdateSmsTemplateDto, + actor: AuthUser, + ) { + await this.assertSuperAdmin(actor); + const templateId = BigInt(templateIdRaw); + + const existing = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + }); + if (!existing) { + throw new NotFoundException('SMS template not found'); + } + + const name = dto.name?.trim(); + const body = dto.body?.trim(); + const nameChanged = name !== undefined && name !== existing.name; + const bodyChanged = body !== undefined && body !== existing.body; + const contentChanged = nameChanged || bodyChanged; + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { + ...(name !== undefined ? { name } : {}), + ...(body !== undefined ? { body } : {}), + ...(contentChanged ? CLEAR_VERIFICATION : {}), + }, + include: { + business: { select: { id: true, name: true, nameFa: true, slug: true } }, + }, + }); + + return { template: this.serializeAdmin(updated) }; + } + + async getTemplateForSend(businessId: bigint, templateId: bigint) { + const template = await this.prisma.smsTemplate.findFirst({ + where: { id: templateId, businessId }, + }); + if (!template) { + throw new BadRequestException('SMS template not found'); + } + return template; + } + + private serializeAdmin( + row: { + id: bigint; + businessId: bigint; + name: string; + body: string; + verificationStatus: string; + isVerified: boolean; + verifiedAt: Date | null; + verifiedBy: bigint | null; + createdAt: Date; + updatedAt: Date; + business: { + id: bigint; + name: string; + nameFa: string | null; + slug: string; + }; + }, + ) { + return { + ...this.serialize(row), + business: { + id: row.business.id.toString(), + name: row.business.nameFa?.trim() || row.business.name, + slug: row.business.slug, + }, + }; + } + + private serialize(row: { + id: bigint; + businessId: bigint; + name: string; + body: string; + verificationStatus?: string | null; + isVerified: boolean; + verifiedAt: Date | null; + verifiedBy: bigint | null; + createdAt: Date; + updatedAt: Date; + }) { + const verificationStatus = this.normalizeStatus( + row.verificationStatus, + row.isVerified, + ); + return { + id: row.id.toString(), + businessId: row.businessId.toString(), + name: row.name, + body: row.body, + verificationStatus, + isVerified: verificationStatus === 'verified', + verifiedAt: row.verifiedAt?.toISOString() ?? null, + verifiedBy: row.verifiedBy?.toString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + private normalizeStatus( + raw: string | null | undefined, + isVerified: boolean, + ): SmsTemplateVerificationStatus { + if (raw === 'pending' || raw === 'verified' || raw === 'free') return raw; + return isVerified ? 'verified' : 'free'; + } + + private async assertBusinessPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + if (!allowed) { + throw new ForbiddenException('Missing permission'); + } + } + + private async assertSuperAdmin(actor: AuthUser) { + if (!(await this.permissions.isSuperAdmin(actor.id))) { + throw new ForbiddenException('Super admin only'); + } + } +} diff --git a/src/website-docs/static/SMS.md b/src/website-docs/static/SMS.md index f7405ae..37129ba 100644 --- a/src/website-docs/static/SMS.md +++ b/src/website-docs/static/SMS.md @@ -24,6 +24,7 @@ X-Api-Key: | `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) | | `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` | | `message` | yes | Free text, max 700 characters | +| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` | ### Example (Balout) @@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**. ## Sender line (v1) -Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet. +Default sender is the **advertising** shortcode (`9000590009` / `SMS_GAMA_SOURCE_ADVERTISE`). +Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint. ---