Add SMS templates, prepaid IRT billing, and advertising shortcode 9000590009.
Charge ceil(len/64)*400 IRT per recipient across OTP, customer, and partner SMS; seed new businesses with 500k IRT; default partner sends to the advertising line. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4623061a4f
commit
4a1d60dbef
+2
-2
@@ -30,8 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1
|
|||||||
SMS_GAMA_USERNAME=
|
SMS_GAMA_USERNAME=
|
||||||
SMS_GAMA_PASSWORD=
|
SMS_GAMA_PASSWORD=
|
||||||
SMS_GAMA_SOURCE_SERVICE=5000110005
|
SMS_GAMA_SOURCE_SERVICE=5000110005
|
||||||
# Optional later: SMS_GAMA_SOURCE_ADVERTISE=5000990009
|
# Optional: advertising / unverified-template shortcode (default 9000590009)
|
||||||
SMS_GAMA_SOURCE_ADVERTISE=5000990009
|
SMS_GAMA_SOURCE_ADVERTISE=9000590009
|
||||||
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
||||||
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
||||||
SMS_PARTNERS=
|
SMS_PARTNERS=
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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.';
|
||||||
@@ -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);
|
||||||
@@ -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.';
|
||||||
@@ -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.
|
- 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`
|
- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type`
|
||||||
- SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`)
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
|
|||||||
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
||||||
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
||||||
| `message` | yes | Free text, max 700 characters |
|
| `message` | yes | Free text, max 700 characters |
|
||||||
|
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` |
|
||||||
|
|
||||||
### Example (Balout)
|
### Example (Balout)
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
|
|||||||
|
|
||||||
## Sender line (v1)
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ model User {
|
|||||||
transactions Transaction[] @relation("TransactionCustomer")
|
transactions Transaction[] @relation("TransactionCustomer")
|
||||||
userProducts UserProduct[]
|
userProducts UserProduct[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
|
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
|
||||||
|
|
||||||
@@index([cellNumber], map: "idx_users_cell_number")
|
@@index([cellNumber], map: "idx_users_cell_number")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -69,6 +70,28 @@ model UserTitle {
|
|||||||
@@map("user_titles")
|
@@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 {
|
model Business {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
@@ -89,12 +112,14 @@ model Business {
|
|||||||
faviconMediaId BigInt? @map("favicon_media_id")
|
faviconMediaId BigInt? @map("favicon_media_id")
|
||||||
oldBusinessId BigInt? @map("old_business_id")
|
oldBusinessId BigInt? @map("old_business_id")
|
||||||
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
|
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
|
||||||
|
smsBalance Int @default(0) @map("sms_balance")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brands Brand[]
|
brands Brand[]
|
||||||
categoryAssignments BusinessCategoryAssignment[]
|
categoryAssignments BusinessCategoryAssignment[]
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
businessUsers BusinessUser[]
|
businessUsers BusinessUser[]
|
||||||
|
smsTemplates SmsTemplate[]
|
||||||
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
||||||
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
||||||
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
|
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
|||||||
import { PublicSmsModule } from './public-sms/public-sms.module';
|
import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
|
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
||||||
import { TorobModule } from './torob/torob.module';
|
import { TorobModule } from './torob/torob.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -93,6 +94,7 @@ import { TorobModule } from './torob/torob.module';
|
|||||||
AiPromptsModule,
|
AiPromptsModule,
|
||||||
PublicSmsModule,
|
PublicSmsModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
|
SmsTemplatesModule,
|
||||||
TorobModule,
|
TorobModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { BusinessPermissionGuard } from './guards/business-permission.guard';
|
import { BusinessPermissionGuard } from './guards/business-permission.guard';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
import { SmsBillingService } from './sms-billing.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { UserAddressesService } from './user-addresses.service';
|
import { UserAddressesService } from './user-addresses.service';
|
||||||
|
|
||||||
@@ -33,10 +34,17 @@ import { UserAddressesService } from './user-addresses.service';
|
|||||||
AuthService,
|
AuthService,
|
||||||
UserAddressesService,
|
UserAddressesService,
|
||||||
SmsService,
|
SmsService,
|
||||||
|
SmsBillingService,
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
BusinessPermissionGuard,
|
BusinessPermissionGuard,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
],
|
],
|
||||||
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
|
exports: [
|
||||||
|
AuthService,
|
||||||
|
PermissionsService,
|
||||||
|
BusinessPermissionGuard,
|
||||||
|
SmsService,
|
||||||
|
SmsBillingService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
+18
-11
@@ -28,6 +28,7 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
|
|||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { parseUserProfile } from './profile.util';
|
import { parseUserProfile } from './profile.util';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
import { SmsBillingService } from './sms-billing.service';
|
||||||
|
|
||||||
const OTP_TTL_SECONDS = 300;
|
const OTP_TTL_SECONDS = 300;
|
||||||
/** Pending cross-site password change (longer than OTP so resend still works). */
|
/** 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 config: ConfigService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -403,7 +405,7 @@ export class AuthService {
|
|||||||
return { message: 'Password changed successfully' };
|
return { message: 'Password changed successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtp(cellNumber: string, domain?: string) {
|
async sendOtp(cellNumber: string, domain: string) {
|
||||||
if (!this.sms.isEnabled()) {
|
if (!this.sms.isEnabled()) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -420,24 +422,29 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
throw new UnauthorizedException('Cell number is not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
let businessNameFa: string | undefined;
|
|
||||||
const host = domain?.trim();
|
const host = domain?.trim();
|
||||||
if (host) {
|
if (!host) {
|
||||||
try {
|
throw new BadRequestException(
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
'Domain is required to send SMS (prepaid balance is billed per business).',
|
||||||
businessNameFa =
|
);
|
||||||
business.nameFa?.trim() || business.name?.trim() || undefined;
|
|
||||||
} catch {
|
|
||||||
// Domain may be unknown — still send OTP without branding suffix
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessNameFa =
|
||||||
|
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||||
const code = this.generateOtpCode();
|
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);
|
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
|
await this.sms.sendMessage(cellNumber, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await this.smsBilling.refund(business.id, charged);
|
||||||
if (err instanceof HttpException) {
|
if (err instanceof HttpException) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendOtpDto {
|
export class SendOtpDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -7,9 +7,8 @@ export class SendOtpDto {
|
|||||||
})
|
})
|
||||||
cellNumber!: string;
|
cellNumber!: string;
|
||||||
|
|
||||||
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
|
/** Tenant host/apex — required so OTP SMS is billed to the business. */
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(253)
|
@MaxLength(253)
|
||||||
domain?: string;
|
domain!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<bigint | null> {
|
||||||
|
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<bigint> {
|
||||||
|
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<number> {
|
||||||
|
return this.chargeAmount(
|
||||||
|
businessId,
|
||||||
|
this.costForMessage(message, recipientCount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically deduct an explicit IRT amount. Returns charged amount. */
|
||||||
|
async chargeAmount(businessId: bigint, amount: number): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
const credit = Math.floor(amount);
|
||||||
|
if (credit <= 0) return;
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE businesses
|
||||||
|
SET sms_balance = sms_balance + ${credit}
|
||||||
|
WHERE id = ${businessId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+59
-1
@@ -21,7 +21,7 @@ export type SmsSendOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Default Meshkee advertising shortcode when a business has no own SMS number. */
|
/** 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. */
|
/** Gama batch size limit is undocumented; keep chunks conservative. */
|
||||||
const GAMA_BULK_CHUNK_SIZE = 100;
|
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<string>('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<string>('SMS_GAMA_USERNAME')?.trim();
|
||||||
|
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||||
|
const baseUrl = (
|
||||||
|
this.config.get<string>('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(
|
async sendVerificationCode(
|
||||||
cellNumber: string,
|
cellNumber: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT } from '../auth/sms-billing';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { AddDomainDto } from './dto/add-domain.dto';
|
import { AddDomainDto } from './dto/add-domain.dto';
|
||||||
@@ -81,6 +82,7 @@ type BusinessRow = {
|
|||||||
enabledModules: unknown;
|
enabledModules: unknown;
|
||||||
homeCharts: unknown;
|
homeCharts: unknown;
|
||||||
smsSenderNumber: string | null;
|
smsSenderNumber: string | null;
|
||||||
|
smsBalance: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
@@ -166,7 +168,8 @@ export class BusinessAdminService {
|
|||||||
b.settings->'branding'->>'themeMode' AS "themeMode",
|
b.settings->'branding'->>'themeMode' AS "themeMode",
|
||||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
b.settings->'modules'->'enabled' AS "enabledModules",
|
||||||
b.settings->'modules'->'charts' AS "homeCharts",
|
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
|
FROM businesses b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
|
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,
|
moduleCount: enabledModules.length,
|
||||||
homeCharts,
|
homeCharts,
|
||||||
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
|
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
|
||||||
|
smsBalance: Number(item.smsBalance ?? 0),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
@@ -387,6 +391,7 @@ export class BusinessAdminService {
|
|||||||
: new Date(),
|
: new Date(),
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||||
|
smsBalance: DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT,
|
||||||
settings: toPrismaJson({
|
settings: toPrismaJson({
|
||||||
...DEFAULT_BUSINESS_SETTINGS,
|
...DEFAULT_BUSINESS_SETTINGS,
|
||||||
modules: {
|
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) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const nextSettings =
|
const nextSettings =
|
||||||
dto.smsSenderNumber !== undefined
|
dto.smsSenderNumber !== undefined
|
||||||
@@ -503,6 +512,7 @@ export class BusinessAdminService {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
||||||
|
...(dto.smsBalance !== undefined ? { smsBalance: dto.smsBalance } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1272,6 +1282,7 @@ export class BusinessAdminService {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
|
smsBalance?: number;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
category: {
|
category: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -1316,6 +1327,7 @@ export class BusinessAdminService {
|
|||||||
createdAt: business.createdAt,
|
createdAt: business.createdAt,
|
||||||
updatedAt: business.updatedAt,
|
updatedAt: business.updatedAt,
|
||||||
smsSenderNumber: settings.sms.senderNumber,
|
smsSenderNumber: settings.sms.senderNumber,
|
||||||
|
smsBalance: business.smsBalance ?? 0,
|
||||||
categories: business.categoryAssignments.map((a) => ({
|
categories: business.categoryAssignments.map((a) => ({
|
||||||
id: a.category.id,
|
id: a.category.id,
|
||||||
name: a.category.name,
|
name: a.category.name,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -71,4 +72,11 @@ export class UpdateBusinessDto {
|
|||||||
message: 'smsSenderNumber must be an 8–16 digit shortcode',
|
message: 'smsSenderNumber must be an 8–16 digit shortcode',
|
||||||
})
|
})
|
||||||
smsSenderNumber?: string | null;
|
smsSenderNumber?: string | null;
|
||||||
|
|
||||||
|
/** Prepaid SMS message credits for this business (absolute set). */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
smsBalance?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { CustomersController } from './customers.controller';
|
import { CustomersController } from './customers.controller';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
|
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, SmsTemplatesModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService],
|
providers: [CustomersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { SmsBillingService } from '../auth/sms-billing.service';
|
||||||
import { SmsService } from '../auth/sms.service';
|
import { SmsService } from '../auth/sms.service';
|
||||||
import {
|
import {
|
||||||
buildDualDailyActivitySeries,
|
buildDualDailyActivitySeries,
|
||||||
@@ -21,6 +22,11 @@ import { SearchCustomersDto } from './dto/search-customers.dto';
|
|||||||
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
|
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
|
||||||
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
|
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
|
||||||
import { UpdateCustomerDto } from './dto/update-customer.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 = {
|
type CustomerListRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -43,6 +49,8 @@ export class CustomersService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
|
private readonly smsTemplates: SmsTemplatesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -692,7 +700,7 @@ export class CustomersService {
|
|||||||
businessId_userId: { businessId, userId },
|
businessId_userId: { businessId, userId },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: { include: { title: true } },
|
||||||
business: { select: { settings: true, isActive: true } },
|
business: { select: { settings: true, isActive: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -708,23 +716,36 @@ export class CustomersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = normalizeBusinessSettings(membership.business.settings);
|
const resolved = await this.resolveOutboundSms(
|
||||||
const source =
|
businessId,
|
||||||
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
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 {
|
try {
|
||||||
const result = await this.sms.sendMessage(
|
const result = await this.sms.sendMessage(
|
||||||
membership.user.cellNumber,
|
membership.user.cellNumber,
|
||||||
dto.message.trim(),
|
resolved.message,
|
||||||
{ source },
|
{ source: resolved.source },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: 'Message sent successfully',
|
message: 'Message sent successfully',
|
||||||
serverId: result.serverId,
|
serverId: result.serverId,
|
||||||
source,
|
source: resolved.source,
|
||||||
|
templateId: resolved.templateId,
|
||||||
|
templateVerified: resolved.templateVerified,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await this.smsBilling.refund(businessId, charged);
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
err instanceof ServiceUnavailableException
|
err instanceof ServiceUnavailableException
|
||||||
@@ -736,8 +757,8 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers.
|
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
|
||||||
* Selected userIds may include team members shown on the customers list.
|
* With a personalized template, messages are sent per-recipient (not one bulk payload).
|
||||||
*/
|
*/
|
||||||
async sendBulkSms(
|
async sendBulkSms(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
@@ -759,7 +780,14 @@ export class CustomersService {
|
|||||||
.map((id) => id.trim())
|
.map((id) => id.trim())
|
||||||
.filter(Boolean);
|
.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) {
|
if (selectedIds.length > 0) {
|
||||||
const userIds = selectedIds.map((id) => {
|
const userIds = selectedIds.map((id) => {
|
||||||
@@ -770,9 +798,21 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql`
|
const rows = await this.prisma.$queryRaw<
|
||||||
SELECT DISTINCT u.cell_number AS "cellNumber"
|
{
|
||||||
|
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
|
FROM users u
|
||||||
|
LEFT JOIN user_titles ut ON ut.id = u.title_id
|
||||||
LEFT JOIN business_customers bc
|
LEFT JOIN business_customers bc
|
||||||
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
||||||
LEFT JOIN business_users bu
|
LEFT JOIN business_users bu
|
||||||
@@ -780,16 +820,30 @@ export class CustomersService {
|
|||||||
WHERE u.id IN (${Prisma.join(userIds)})
|
WHERE u.id IN (${Prisma.join(userIds)})
|
||||||
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
|
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
|
||||||
`);
|
`);
|
||||||
cellNumbers = rows.map((r) => r.cellNumber);
|
recipients = rows;
|
||||||
} else {
|
} else {
|
||||||
const rows = await this.prisma.businessCustomer.findMany({
|
const rows = await this.prisma.businessCustomer.findMany({
|
||||||
where: { businessId, isEnabled: true },
|
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');
|
throw new BadRequestException('No recipients found to send SMS');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,26 +851,117 @@ export class CustomersService {
|
|||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
message: 'SMS is disabled. Message was not sent.',
|
message: 'SMS is disabled. Message was not sent.',
|
||||||
recipientCount: cellNumbers.length,
|
recipientCount: recipients.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = normalizeBusinessSettings(business.settings);
|
const sample = await this.resolveOutboundSms(
|
||||||
const source =
|
businessId,
|
||||||
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
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 {
|
try {
|
||||||
const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), {
|
if (!personalized) {
|
||||||
source,
|
const result = await this.sms.sendBulkMessage(
|
||||||
});
|
recipients.map((r) => r.cellNumber),
|
||||||
|
sample.message,
|
||||||
|
{ source: sample.source },
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: 'Message sent successfully',
|
message: 'Message sent successfully',
|
||||||
recipientCount: result.recipientCount,
|
recipientCount: result.recipientCount,
|
||||||
serverIds: result.serverIds,
|
serverIds: result.serverIds,
|
||||||
source,
|
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: serverIds.length,
|
||||||
|
serverIds,
|
||||||
|
source: sample.source,
|
||||||
|
templateId: sample.templateId,
|
||||||
|
templateVerified: sample.templateVerified,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (charged > 0) {
|
||||||
|
await this.smsBilling.refund(businessId, charged);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
err instanceof ServiceUnavailableException
|
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: {
|
private formatLabel(user: {
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendCustomerSmsDto {
|
export class SendCustomerSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(700)
|
@MaxLength(700)
|
||||||
message!: string;
|
message!: string;
|
||||||
|
|
||||||
|
/** Optional SMS template id — body may still be sent as the rendered text. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
templateId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export class SendCustomersBulkSmsDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@Type(() => String)
|
@Type(() => String)
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
templateId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RedisService } from '../redis/redis.service';
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { SmsBillingService } from '../auth/sms-billing.service';
|
||||||
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
|
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
|
||||||
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
|
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export class PublicSmsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -34,17 +36,31 @@ export class PublicSmsService {
|
|||||||
|
|
||||||
await this.assertRateLimits(partnerDomain, msisdn);
|
await this.assertRateLimits(partnerDomain, msisdn);
|
||||||
|
|
||||||
|
const businessId =
|
||||||
|
await this.smsBilling.requireBusinessIdByDomain(partnerDomain);
|
||||||
|
const charged = await this.smsBilling.charge(businessId, dto.message);
|
||||||
|
|
||||||
|
// 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, {
|
const result = await this.sms.sendMessage(msisdn, dto.message, {
|
||||||
source: dto.source?.trim() || undefined,
|
source,
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
|
`Partner SMS accepted domain=${partnerDomain} source=${source} serverId=${result.serverId} charged=${charged}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
serverId: result.serverId,
|
serverId: result.serverId,
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await this.smsBilling.refund(businessId, charged);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
|
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
|
|||||||
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
||||||
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
||||||
| `message` | yes | Free text, max 700 characters |
|
| `message` | yes | Free text, max 700 characters |
|
||||||
|
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` |
|
||||||
|
|
||||||
### Example (Balout)
|
### Example (Balout)
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
|
|||||||
|
|
||||||
## Sender line (v1)
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user