From d37b920c01222698670adde38c06581b9c44ae82 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 18 Sep 2026 17:17:59 +0330 Subject: [PATCH] Add businesses renewal sort, low-SMS alert, and contact form SMS. Co-authored-by: Cursor --- src/business-admin/business-admin.service.ts | 70 +++++++++- src/business-admin/dto/list-businesses.dto.ts | 7 +- .../contact-submissions.service.ts | 129 +++++++++++++++++- 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 8711beb..17c5b8e 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -130,6 +130,8 @@ export class BusinessAdminService { const nameLike = query.name ? `%${query.name.trim()}%` : null; const domainLike = query.domain ? `%${query.domain.trim()}%` : null; const categoryLike = query.category ? `%${query.category.trim()}%` : null; + const sortByRenewal = query.sort === 'renewal'; + const lowSmsThreshold = 100; const where = Prisma.sql` WHERE 1=1 @@ -151,7 +153,40 @@ export class BusinessAdminService { ` : Prisma.empty} `; - const [items, totalRow] = await Promise.all([ + // Matches super-admin nextAnnualRenewal(): future contract date as-is, + // otherwise this calendar year's month/day (overdue if already past). + const nextRenewalDateSql = Prisma.sql` + CASE + WHEN COALESCE(b.annual_renewal_at, b.created_at) IS NULL THEN NULL + WHEN COALESCE(b.annual_renewal_at, b.created_at)::date > CURRENT_DATE + THEN COALESCE(b.annual_renewal_at, b.created_at)::date + ELSE make_date( + EXTRACT(YEAR FROM CURRENT_DATE)::int, + EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int, + LEAST( + EXTRACT(DAY FROM COALESCE(b.annual_renewal_at, b.created_at))::int, + EXTRACT( + DAY FROM ( + DATE_TRUNC( + 'month', + make_date( + EXTRACT(YEAR FROM CURRENT_DATE)::int, + EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int, + 1 + ) + ) + INTERVAL '1 month - 1 day' + ) + )::int + ) + ) + END + `; + + const orderBy = sortByRenewal + ? Prisma.sql`ORDER BY ${nextRenewalDateSql} ASC NULLS LAST, b.created_at DESC` + : Prisma.sql`ORDER BY b.created_at DESC`; + + const [items, totalRow, lowSmsRows] = await Promise.all([ this.prisma.$queryRaw(Prisma.sql` SELECT b.id AS "id", @@ -205,7 +240,7 @@ export class BusinessAdminService { LIMIT 1 ) own ON TRUE ${where} - ORDER BY b.created_at DESC + ${orderBy} LIMIT ${pageSize} OFFSET ${skip} `), this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` @@ -213,6 +248,30 @@ export class BusinessAdminService { FROM businesses b ${where} `), + this.prisma.$queryRaw< + { + id: bigint; + name: string; + domain: string | null; + smsBalance: number; + }[] + >(Prisma.sql` + SELECT + b.id AS "id", + b.name AS "name", + dom.host AS "domain", + b.sms_balance AS "smsBalance" + FROM businesses b + LEFT JOIN LATERAL ( + SELECT d.host + FROM domains d + WHERE d.business_id = b.id + ORDER BY d.is_primary DESC, d.created_at DESC + LIMIT 1 + ) dom ON TRUE + WHERE b.sms_balance < ${lowSmsThreshold} + ORDER BY b.sms_balance ASC, b.name ASC + `), ]); return { @@ -260,6 +319,13 @@ export class BusinessAdminService { total: totalRow[0]?.total ?? 0, page, pageSize, + lowSmsBusinesses: lowSmsRows.map((row) => ({ + id: row.id, + name: row.name, + domain: row.domain, + smsBalance: Number(row.smsBalance ?? 0), + })), + lowSmsThreshold, }; } diff --git a/src/business-admin/dto/list-businesses.dto.ts b/src/business-admin/dto/list-businesses.dto.ts index d4070ae..8adec9d 100644 --- a/src/business-admin/dto/list-businesses.dto.ts +++ b/src/business-admin/dto/list-businesses.dto.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; export class ListBusinessesDto { @IsOptional() @@ -26,5 +26,10 @@ export class ListBusinessesDto { @IsOptional() @IsString() category?: string; + + /** `created` (default) or `renewal` (nearest next/overdue renewal first). */ + @IsOptional() + @IsIn(['created', 'renewal']) + sort?: 'created' | 'renewal'; } diff --git a/src/contact-submissions/contact-submissions.service.ts b/src/contact-submissions/contact-submissions.service.ts index fbdd876..1f42452 100644 --- a/src/contact-submissions/contact-submissions.service.ts +++ b/src/contact-submissions/contact-submissions.service.ts @@ -1,7 +1,15 @@ -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { SmsBillingService } from '../auth/sms-billing.service'; +import type { SmsSendType } from '../auth/sms-billing'; +import { SmsService, toGamaMsisdn } from '../auth/sms.service'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto'; @@ -19,10 +27,14 @@ type ContactSubmissionRow = { @Injectable() export class ContactSubmissionsService { + private readonly logger = new Logger(ContactSubmissionsService.name); + constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, + private readonly sms: SmsService, + private readonly smsBilling: SmsBillingService, ) {} async createPublic(host: string, dto: CreateContactSubmissionDto) { @@ -39,12 +51,127 @@ export class ContactSubmissionsService { }, }); + const businessLabel = + business.nameFa?.trim() || business.name?.trim() || undefined; + await this.notifyContactSubmissionSms({ + businessId: business.id, + businessLabel, + contactName: created.name, + contacterCellNumber: created.cellNumber, + }); + return { submission: this.serialize(created), message: 'Contact form submitted successfully', }; } + /** + * Best-effort SMS via Meshkee service (main) line: + * 1) business owner — new contact named in the message + * 2) contacter — confirmation, only when their cell number is valid + * Never fails the contact form submission. + */ + private async notifyContactSubmissionSms(input: { + businessId: bigint; + businessLabel?: string; + contactName: string; + contacterCellNumber: string | null; + }): Promise { + if (!this.sms.isEnabled()) { + return; + } + + const name = input.contactName.trim() || 'یک بازدیدکننده'; + + await this.sendBilledSms({ + businessId: input.businessId, + cellNumber: await this.resolveOwnerCellNumber(input.businessId), + message: `پیام جدید از فرم تماس با ما: ${name}`, + type: 'admin', + label: 'owner', + }); + + if (!toGamaMsisdn(input.contacterCellNumber ?? '')) { + return; + } + + const brand = input.businessLabel?.trim(); + const confirmation = brand + ? `${name} عزیز، پیام شما به ${brand} دریافت شد.` + : `${name} عزیز، پیام شما دریافت شد.`; + + await this.sendBilledSms({ + businessId: input.businessId, + cellNumber: input.contacterCellNumber, + message: confirmation, + type: 'auto_customer', + label: 'contacter', + }); + } + + private async resolveOwnerCellNumber( + businessId: bigint, + ): Promise { + const owner = await this.prisma.businessUser.findFirst({ + where: { businessId, isOwner: true }, + orderBy: { userId: 'asc' }, + select: { user: { select: { cellNumber: true } } }, + }); + return owner?.user.cellNumber?.trim() || null; + } + + private async sendBilledSms(input: { + businessId: bigint; + cellNumber: string | null | undefined; + message: string; + type: SmsSendType; + label: string; + }): Promise { + const cellNumber = input.cellNumber?.trim(); + if (!cellNumber || !toGamaMsisdn(cellNumber)) { + this.logger.warn( + `Contact form ${input.label} SMS skipped for business ${input.businessId}: invalid or missing cellphone`, + ); + return; + } + + let charged = 0; + try { + charged = await this.smsBilling.charge( + input.businessId, + input.message, + 1, + input.type, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Contact form ${input.label} SMS not charged for business ${input.businessId}: ${reason}`, + ); + return; + } + + try { + await this.sms.sendMessage(cellNumber, input.message, { + source: this.sms.getServiceSource(), + }); + } catch (err) { + if (charged > 0) { + await this.smsBilling.refund( + input.businessId, + charged, + 1, + input.type, + ); + } + const reason = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Contact form ${input.label} SMS failed for business ${input.businessId}: ${reason}`, + ); + } + } + async list( businessIdRaw: string, query: ListContactSubmissionsDto,