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 nameLike = query.name ? `%${query.name.trim()}%` : null;
|
||||||
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
||||||
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
||||||
|
const sortByRenewal = query.sort === 'renewal';
|
||||||
|
const lowSmsThreshold = 100;
|
||||||
|
|
||||||
const where = Prisma.sql`
|
const where = Prisma.sql`
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -151,7 +153,40 @@ export class BusinessAdminService {
|
|||||||
` : Prisma.empty}
|
` : 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`
|
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
b.id AS "id",
|
b.id AS "id",
|
||||||
@@ -205,7 +240,7 @@ export class BusinessAdminService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) own ON TRUE
|
) own ON TRUE
|
||||||
${where}
|
${where}
|
||||||
ORDER BY b.created_at DESC
|
${orderBy}
|
||||||
LIMIT ${pageSize} OFFSET ${skip}
|
LIMIT ${pageSize} OFFSET ${skip}
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||||
@@ -213,6 +248,30 @@ export class BusinessAdminService {
|
|||||||
FROM businesses b
|
FROM businesses b
|
||||||
${where}
|
${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 {
|
return {
|
||||||
@@ -260,6 +319,13 @@ export class BusinessAdminService {
|
|||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
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 { 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 {
|
export class ListBusinessesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
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 { Prisma } from '@prisma/client';
|
||||||
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 type { SmsSendType } from '../auth/sms-billing';
|
||||||
|
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||||
@@ -19,10 +27,14 @@ type ContactSubmissionRow = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContactSubmissionsService {
|
export class ContactSubmissionsService {
|
||||||
|
private readonly logger = new Logger(ContactSubmissionsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
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 {
|
return {
|
||||||
submission: this.serialize(created),
|
submission: this.serialize(created),
|
||||||
message: 'Contact form submitted successfully',
|
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(
|
async list(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
query: ListContactSubmissionsDto,
|
query: ListContactSubmissionsDto,
|
||||||
|
|||||||
Reference in New Issue
Block a user