Add businesses renewal sort, low-SMS alert, and contact form SMS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-18 17:17:59 +03:30
co-authored by Cursor
parent 94cb7eaa33
commit d37b920c01
3 changed files with 202 additions and 4 deletions
+68 -2
View File
@@ -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<BusinessRow[]>(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,
};
}
@@ -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';
}
@@ -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<void> {
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<string | null> {
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<void> {
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,