Add businesses renewal sort, low-SMS alert, and contact form SMS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
94cb7eaa33
commit
d37b920c01
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user