Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures. Co-authored-by: Cursor <cursoragent@cursor.com>
1116 lines
32 KiB
TypeScript
1116 lines
32 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
HttpException,
|
|
Injectable,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { randomBytes } from 'crypto';
|
|
import { AuthUser } from '../auth/auth.types';
|
|
import { PermissionsService } from '../auth/permissions.service';
|
|
import { SmsBillingService } from '../auth/sms-billing.service';
|
|
import { SmsService } from '../auth/sms.service';
|
|
import {
|
|
buildDualDailyActivitySeries,
|
|
startOfLocalDay,
|
|
} from '../common/daily-activity';
|
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
buildCustomersExcel,
|
|
parseCustomersExcel,
|
|
} from './customers-excel';
|
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
|
import { ListCustomersDto } from './dto/list-customers.dto';
|
|
import { SearchCustomersDto } from './dto/search-customers.dto';
|
|
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
|
|
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.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 = {
|
|
id: bigint;
|
|
cellNumber: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
firstNameEn: string | null;
|
|
lastNameEn: string | null;
|
|
email: string | null;
|
|
createdAt: Date;
|
|
isEnabled: boolean;
|
|
businessMemberId: bigint | null;
|
|
isBusinessOwner: boolean | null;
|
|
teamRole: string | null;
|
|
};
|
|
|
|
@Injectable()
|
|
export class CustomersService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionsService,
|
|
private readonly sms: SmsService,
|
|
private readonly smsBilling: SmsBillingService,
|
|
private readonly smsTemplates: SmsTemplatesService,
|
|
) {}
|
|
|
|
async list(
|
|
businessIdRaw: string,
|
|
query: ListCustomersDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.read');
|
|
|
|
const page = query.page ?? 1;
|
|
const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100);
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
|
|
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
|
|
const access = query.access ?? 'all';
|
|
|
|
const accessFilter =
|
|
access === 'customers'
|
|
? Prisma.sql`AND bc.id IS NOT NULL AND bu.id IS NULL`
|
|
: access === 'managers'
|
|
? Prisma.sql`AND bu.id IS NOT NULL`
|
|
: Prisma.sql`AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)`;
|
|
|
|
const where = Prisma.sql`
|
|
WHERE 1=1
|
|
${accessFilter}
|
|
${nameLike ? Prisma.sql`
|
|
AND (
|
|
u.first_name ILIKE ${nameLike}
|
|
OR u.last_name ILIKE ${nameLike}
|
|
OR u.first_name_en ILIKE ${nameLike}
|
|
OR u.last_name_en ILIKE ${nameLike}
|
|
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
|
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
|
|
)
|
|
` : Prisma.empty}
|
|
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
|
|
`;
|
|
|
|
const [items, totalRow] = await Promise.all([
|
|
this.prisma.$queryRaw<CustomerListRow[]>(Prisma.sql`
|
|
SELECT
|
|
u.id AS "id",
|
|
u.cell_number AS "cellNumber",
|
|
u.first_name AS "firstName",
|
|
u.last_name AS "lastName",
|
|
u.first_name_en AS "firstNameEn",
|
|
u.last_name_en AS "lastNameEn",
|
|
u.email AS "email",
|
|
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
|
|
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
|
|
bu.id AS "businessMemberId",
|
|
bu.is_owner AS "isBusinessOwner",
|
|
r.slug AS "teamRole"
|
|
FROM users u
|
|
LEFT JOIN business_customers bc
|
|
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
|
LEFT JOIN business_users bu
|
|
ON bu.user_id = u.id AND bu.business_id = ${businessId}
|
|
LEFT JOIN roles r ON r.id = bu.role_id
|
|
${where}
|
|
ORDER BY COALESCE(bc.created_at, bu.created_at) DESC
|
|
LIMIT ${pageSize} OFFSET ${skip}
|
|
`),
|
|
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
|
SELECT COUNT(*)::int AS "total"
|
|
FROM users u
|
|
LEFT JOIN business_customers bc
|
|
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
|
LEFT JOIN business_users bu
|
|
ON bu.user_id = u.id AND bu.business_id = ${businessId}
|
|
${where}
|
|
`),
|
|
]);
|
|
|
|
return {
|
|
items: items.map((user) => ({
|
|
id: user.id.toString(),
|
|
cellNumber: user.cellNumber,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
firstNameEn: user.firstNameEn,
|
|
lastNameEn: user.lastNameEn,
|
|
email: user.email,
|
|
label: this.formatLabel(user),
|
|
createdAt: user.createdAt.toISOString(),
|
|
isEnabled: user.isEnabled,
|
|
businessMemberId: user.businessMemberId?.toString() ?? null,
|
|
isBusinessOwner: user.isBusinessOwner ?? false,
|
|
teamRole: user.isBusinessOwner
|
|
? 'business_owner'
|
|
: user.teamRole ?? null,
|
|
})),
|
|
total: totalRow[0]?.total ?? 0,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
/** Lightweight daily registration + active-login counts for dashboard charts. */
|
|
async dailyActivity(
|
|
businessIdRaw: string,
|
|
daysRaw: number | undefined,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.read');
|
|
|
|
const days = Math.min(Math.max(daysRaw ?? 30, 1), 366);
|
|
const from = startOfLocalDay(days - 1);
|
|
|
|
const [registeredRows, activeRows] = await Promise.all([
|
|
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
|
|
Prisma.sql`
|
|
SELECT created_at::date AS day,
|
|
COUNT(*)::int AS count
|
|
FROM business_customers
|
|
WHERE business_id = ${businessId}
|
|
AND created_at >= ${from}
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
`,
|
|
),
|
|
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
|
|
Prisma.sql`
|
|
SELECT u.last_login_at::date AS day,
|
|
COUNT(*)::int AS count
|
|
FROM business_customers bc
|
|
JOIN users u ON u.id = bc.user_id
|
|
WHERE bc.business_id = ${businessId}
|
|
AND u.last_login_at IS NOT NULL
|
|
AND u.last_login_at >= ${from}
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
`,
|
|
),
|
|
]);
|
|
|
|
return buildDualDailyActivitySeries(days, registeredRows, activeRows);
|
|
}
|
|
|
|
async search(
|
|
businessIdRaw: string,
|
|
query: SearchCustomersDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertCustomerSearchPermission(businessId, actor.id);
|
|
|
|
const q = query.q.trim();
|
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
|
const like = `%${q}%`;
|
|
|
|
const items = await this.prisma.$queryRaw<
|
|
{
|
|
id: bigint;
|
|
cellNumber: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
email: string | null;
|
|
}[]
|
|
>(Prisma.sql`
|
|
SELECT
|
|
u.id AS "id",
|
|
u.cell_number AS "cellNumber",
|
|
u.first_name AS "firstName",
|
|
u.last_name AS "lastName",
|
|
u.email AS "email"
|
|
FROM business_customers bc
|
|
JOIN users u ON u.id = bc.user_id
|
|
WHERE bc.business_id = ${businessId}
|
|
AND bc.is_enabled = TRUE
|
|
AND u.is_active = TRUE
|
|
AND (
|
|
u.cell_number ILIKE ${like}
|
|
OR u.first_name ILIKE ${like}
|
|
OR u.last_name ILIKE ${like}
|
|
OR u.email ILIKE ${like}
|
|
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like}
|
|
)
|
|
ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST
|
|
LIMIT ${limit}
|
|
`);
|
|
|
|
return {
|
|
items: items.map((user) => ({
|
|
id: user.id.toString(),
|
|
cellNumber: user.cellNumber,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
email: user.email,
|
|
label: this.formatLabel(user),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async exportExcel(businessIdRaw: string, actor: AuthUser) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.read');
|
|
|
|
const rows = await this.prisma.$queryRaw<
|
|
{
|
|
cellNumber: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
}[]
|
|
>(Prisma.sql`
|
|
SELECT
|
|
u.cell_number AS "cellNumber",
|
|
u.first_name AS "firstName",
|
|
u.last_name AS "lastName"
|
|
FROM business_customers bc
|
|
JOIN users u ON u.id = bc.user_id
|
|
WHERE bc.business_id = ${businessId}
|
|
ORDER BY bc.created_at DESC, u.id DESC
|
|
`);
|
|
|
|
return buildCustomersExcel(
|
|
rows.map((row) => ({
|
|
phone: row.cellNumber,
|
|
firstName: row.firstName?.trim() ?? '',
|
|
lastName: row.lastName?.trim() ?? '',
|
|
})),
|
|
);
|
|
}
|
|
|
|
async importExcel(
|
|
businessIdRaw: string,
|
|
file: Express.Multer.File | undefined,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.create');
|
|
|
|
if (!file?.buffer?.length) {
|
|
throw new BadRequestException('Excel file is required.');
|
|
}
|
|
|
|
const name = file.originalname?.toLowerCase() ?? '';
|
|
if (name && !name.endsWith('.xlsx')) {
|
|
throw new BadRequestException('Please upload an .xlsx Excel file.');
|
|
}
|
|
|
|
const business = await this.prisma.business.findUnique({
|
|
where: { id: businessId },
|
|
});
|
|
if (!business?.isActive) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const customerRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'customer' },
|
|
});
|
|
if (!customerRole) {
|
|
throw new Error('Customer role is missing. Run database migrations first.');
|
|
}
|
|
|
|
const rows = await parseCustomersExcel(file.buffer);
|
|
const phones = rows.map((row) => row.phone);
|
|
|
|
const existingUsers = await this.prisma.user.findMany({
|
|
where: { cellNumber: { in: phones } },
|
|
select: {
|
|
id: true,
|
|
cellNumber: true,
|
|
cellVerifiedAt: true,
|
|
businessCustomers: {
|
|
where: { businessId },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
});
|
|
const userByPhone = new Map(
|
|
existingUsers.map((user) => [user.cellNumber, user]),
|
|
);
|
|
|
|
const existingUserIds = existingUsers.map((user) => user.id);
|
|
const staffMemberships =
|
|
existingUserIds.length === 0
|
|
? []
|
|
: await this.prisma.businessUser.findMany({
|
|
where: {
|
|
businessId,
|
|
userId: { in: existingUserIds },
|
|
},
|
|
select: { userId: true },
|
|
});
|
|
const staffUserIds = new Set(
|
|
staffMemberships.map((row) => row.userId.toString()),
|
|
);
|
|
|
|
let created = 0;
|
|
let linked = 0;
|
|
let skipped = 0;
|
|
const skipSamples: string[] = [];
|
|
|
|
const noteSkip = (message: string) => {
|
|
skipped += 1;
|
|
if (skipSamples.length < 12) {
|
|
skipSamples.push(message);
|
|
}
|
|
};
|
|
|
|
const placeholderPasswordHash = await bcrypt.hash(
|
|
randomBytes(32).toString('hex'),
|
|
10,
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const existing = userByPhone.get(row.phone);
|
|
|
|
if (existing?.businessCustomers.length) {
|
|
noteSkip(`Row ${row.rowNumber}: already a customer (${row.phone}).`);
|
|
continue;
|
|
}
|
|
|
|
if (existing && staffUserIds.has(existing.id.toString())) {
|
|
noteSkip(
|
|
`Row ${row.rowNumber}: user is staff of this business (${row.phone}).`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const wasNew = !existing;
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
let userId = existing?.id;
|
|
|
|
if (!userId) {
|
|
const account = await tx.user.create({
|
|
data: {
|
|
cellNumber: row.phone,
|
|
passwordHash: placeholderPasswordHash,
|
|
firstName: row.firstName,
|
|
lastName: row.lastName,
|
|
cellVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
userId = account.id;
|
|
userByPhone.set(row.phone, {
|
|
id: account.id,
|
|
cellNumber: row.phone,
|
|
cellVerifiedAt: account.cellVerifiedAt,
|
|
businessCustomers: [{ id: BigInt(0) }],
|
|
});
|
|
} else if (existing && !existing.cellVerifiedAt) {
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: { cellVerifiedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
await tx.businessCustomer.create({
|
|
data: {
|
|
businessId,
|
|
userId,
|
|
},
|
|
});
|
|
|
|
const hasCustomerRole = await tx.userRole.findUnique({
|
|
where: {
|
|
userId_roleId: {
|
|
userId,
|
|
roleId: customerRole.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!hasCustomerRole) {
|
|
await tx.userRole.create({
|
|
data: {
|
|
userId,
|
|
roleId: customerRole.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
const cached = userByPhone.get(row.phone);
|
|
if (cached && cached.businessCustomers.length === 0) {
|
|
cached.businessCustomers = [{ id: BigInt(0) }];
|
|
}
|
|
});
|
|
|
|
if (wasNew) {
|
|
created += 1;
|
|
} else {
|
|
linked += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
message: 'Customers imported.',
|
|
total: rows.length,
|
|
created,
|
|
linked,
|
|
skipped,
|
|
skipSamples,
|
|
};
|
|
}
|
|
|
|
async create(
|
|
businessIdRaw: string,
|
|
dto: CreateCustomerDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.create');
|
|
|
|
const business = await this.prisma.business.findUnique({
|
|
where: { id: businessId },
|
|
});
|
|
|
|
if (!business?.isActive) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const customerRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'customer' },
|
|
});
|
|
|
|
if (!customerRole) {
|
|
throw new Error('Customer role is missing. Run database migrations first.');
|
|
}
|
|
|
|
const existingUser = await this.prisma.user.findUnique({
|
|
where: { cellNumber: dto.cellNumber },
|
|
include: {
|
|
businessCustomers: { where: { businessId } },
|
|
},
|
|
});
|
|
|
|
if (existingUser?.businessCustomers.length) {
|
|
throw new ConflictException('User is already a customer of this business');
|
|
}
|
|
|
|
if (existingUser) {
|
|
const staffMembership = await this.prisma.businessUser.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId: existingUser.id },
|
|
},
|
|
});
|
|
if (staffMembership) {
|
|
throw new ConflictException('User is already staff of this business');
|
|
}
|
|
}
|
|
|
|
const passwordHash = existingUser
|
|
? existingUser.passwordHash
|
|
: await bcrypt.hash(
|
|
// Password optional: random unusable hash when omitted (OTP login still works).
|
|
dto.password?.trim() || randomBytes(32).toString('hex'),
|
|
10,
|
|
);
|
|
|
|
const firstName = dto.firstName?.trim() || null;
|
|
const lastName = dto.lastName?.trim() || null;
|
|
if (!firstName && !lastName) {
|
|
throw new BadRequestException('firstName or lastName is required');
|
|
}
|
|
|
|
const membership = await this.prisma.$transaction(async (tx) => {
|
|
const account =
|
|
existingUser ??
|
|
(await tx.user.create({
|
|
data: {
|
|
cellNumber: dto.cellNumber,
|
|
passwordHash,
|
|
email: dto.email,
|
|
firstName,
|
|
lastName,
|
|
firstNameEn: dto.firstNameEn?.trim() || null,
|
|
lastNameEn: dto.lastNameEn?.trim() || null,
|
|
cellVerifiedAt: new Date(),
|
|
},
|
|
}));
|
|
|
|
if (existingUser && !existingUser.cellVerifiedAt) {
|
|
await tx.user.update({
|
|
where: { id: account.id },
|
|
data: { cellVerifiedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
const link = await tx.businessCustomer.create({
|
|
data: {
|
|
businessId,
|
|
userId: account.id,
|
|
},
|
|
});
|
|
|
|
const hasCustomerRole = await tx.userRole.findUnique({
|
|
where: {
|
|
userId_roleId: {
|
|
userId: account.id,
|
|
roleId: customerRole.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!hasCustomerRole) {
|
|
await tx.userRole.create({
|
|
data: {
|
|
userId: account.id,
|
|
roleId: customerRole.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { account, link };
|
|
});
|
|
|
|
const user = membership.account;
|
|
const verifiedAt = user.cellVerifiedAt ?? new Date();
|
|
|
|
return {
|
|
id: user.id.toString(),
|
|
cellNumber: user.cellNumber,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
firstNameEn: user.firstNameEn,
|
|
lastNameEn: user.lastNameEn,
|
|
email: user.email,
|
|
label: this.formatLabel(user),
|
|
createdAt: membership.link.createdAt.toISOString(),
|
|
isEnabled: membership.link.isEnabled,
|
|
isVerified: verifiedAt !== null,
|
|
role: 'customer',
|
|
};
|
|
}
|
|
|
|
async update(
|
|
businessIdRaw: string,
|
|
userIdRaw: string,
|
|
dto: UpdateCustomerDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const userId = BigInt(userIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
|
|
|
const membership = await this.prisma.businessCustomer.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId },
|
|
},
|
|
include: { user: true },
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new NotFoundException('Customer not found for this business');
|
|
}
|
|
|
|
if (dto.isEnabled !== undefined) {
|
|
await this.prisma.businessCustomer.update({
|
|
where: { id: membership.id },
|
|
data: { isEnabled: dto.isEnabled },
|
|
});
|
|
}
|
|
|
|
const hasProfileUpdate =
|
|
dto.firstName !== undefined ||
|
|
dto.lastName !== undefined ||
|
|
dto.firstNameEn !== undefined ||
|
|
dto.lastNameEn !== undefined ||
|
|
dto.email !== undefined ||
|
|
dto.cellNumber !== undefined;
|
|
|
|
if (hasProfileUpdate) {
|
|
const user = membership.user;
|
|
|
|
if (dto.cellNumber && dto.cellNumber !== user.cellNumber) {
|
|
const existing = await this.prisma.user.findUnique({
|
|
where: { cellNumber: dto.cellNumber },
|
|
});
|
|
if (existing && existing.id !== userId) {
|
|
throw new BadRequestException('Cell number is already in use');
|
|
}
|
|
}
|
|
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
firstName: dto.firstName?.trim(),
|
|
lastName: dto.lastName?.trim(),
|
|
firstNameEn:
|
|
dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined,
|
|
lastNameEn:
|
|
dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined,
|
|
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
|
|
cellNumber: dto.cellNumber?.trim(),
|
|
},
|
|
});
|
|
}
|
|
|
|
const refreshed = await this.prisma.businessCustomer.findUnique({
|
|
where: { id: membership.id },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (!refreshed) {
|
|
throw new NotFoundException('Customer not found for this business');
|
|
}
|
|
|
|
const user = refreshed.user;
|
|
return {
|
|
id: user.id.toString(),
|
|
cellNumber: user.cellNumber,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
firstNameEn: user.firstNameEn,
|
|
lastNameEn: user.lastNameEn,
|
|
email: user.email,
|
|
label: this.formatLabel(user),
|
|
isEnabled: refreshed.isEnabled,
|
|
};
|
|
}
|
|
|
|
async remove(businessIdRaw: string, userIdRaw: string, actor: AuthUser) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const userId = BigInt(userIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
|
|
|
const membership = await this.prisma.businessCustomer.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId },
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new NotFoundException('Customer not found for this business');
|
|
}
|
|
|
|
await this.prisma.businessCustomer.delete({
|
|
where: { id: membership.id },
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
async sendSms(
|
|
businessIdRaw: string,
|
|
userIdRaw: string,
|
|
dto: SendCustomerSmsDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
const userId = BigInt(userIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
|
|
|
const membership = await this.prisma.businessCustomer.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId },
|
|
},
|
|
include: {
|
|
user: { include: { title: true } },
|
|
business: { select: { settings: true, isActive: true } },
|
|
},
|
|
});
|
|
|
|
if (!membership || !membership.business.isActive) {
|
|
throw new NotFoundException('Customer not found for this business');
|
|
}
|
|
|
|
if (!this.sms.isEnabled()) {
|
|
return {
|
|
enabled: false,
|
|
message: 'SMS is disabled. Message was not sent.',
|
|
};
|
|
}
|
|
|
|
const resolved = await this.resolveOutboundSms(
|
|
businessId,
|
|
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,
|
|
1,
|
|
'singular',
|
|
);
|
|
|
|
try {
|
|
const result = await this.sms.sendMessage(
|
|
membership.user.cellNumber,
|
|
resolved.message,
|
|
{ source: resolved.source },
|
|
);
|
|
return {
|
|
enabled: true,
|
|
message: 'Message sent successfully',
|
|
serverId: result.serverId,
|
|
source: resolved.source,
|
|
templateId: resolved.templateId,
|
|
templateVerified: resolved.templateVerified,
|
|
};
|
|
} catch (err) {
|
|
await this.smsBilling.refund(businessId, charged, 1, 'singular');
|
|
if (
|
|
err instanceof BadRequestException ||
|
|
err instanceof ServiceUnavailableException
|
|
) {
|
|
throw err;
|
|
}
|
|
throw new ServiceUnavailableException('Unable to send SMS');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
|
|
* Always sends per-recipient so one failure does not abort the rest.
|
|
* Returns a report with sent/failed counts and per-number errors.
|
|
*/
|
|
async sendBulkSms(
|
|
businessIdRaw: string,
|
|
dto: SendCustomersBulkSmsDto,
|
|
actor: AuthUser,
|
|
) {
|
|
const businessId = BigInt(businessIdRaw);
|
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
|
|
|
const business = await this.prisma.business.findUnique({
|
|
where: { id: businessId },
|
|
select: { settings: true, isActive: true },
|
|
});
|
|
if (!business?.isActive) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const selectedIds = (dto.userIds ?? [])
|
|
.map((id) => id.trim())
|
|
.filter(Boolean);
|
|
|
|
type Recipient = {
|
|
cellNumber: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
titleName: string | null;
|
|
};
|
|
|
|
let recipients: Recipient[];
|
|
|
|
if (selectedIds.length > 0) {
|
|
const userIds = selectedIds.map((id) => {
|
|
try {
|
|
return BigInt(id);
|
|
} catch {
|
|
throw new BadRequestException('Invalid user id');
|
|
}
|
|
});
|
|
|
|
const rows = await this.prisma.$queryRaw<
|
|
{
|
|
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
|
|
LEFT JOIN user_titles ut ON ut.id = u.title_id
|
|
LEFT JOIN business_customers bc
|
|
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
|
LEFT JOIN business_users bu
|
|
ON bu.user_id = u.id AND bu.business_id = ${businessId}
|
|
WHERE u.id IN (${Prisma.join(userIds)})
|
|
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
|
|
`);
|
|
recipients = rows;
|
|
} else {
|
|
const rows = await this.prisma.businessCustomer.findMany({
|
|
where: { businessId, isEnabled: true },
|
|
select: {
|
|
user: {
|
|
select: {
|
|
cellNumber: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
title: { select: { nameFa: true, nameEn: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
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 (recipients.length === 0) {
|
|
throw new BadRequestException('No recipients found to send SMS');
|
|
}
|
|
|
|
if (!this.sms.isEnabled()) {
|
|
return {
|
|
enabled: false,
|
|
message: 'SMS is disabled. Message was not sent.',
|
|
recipientCount: recipients.length,
|
|
sentCount: 0,
|
|
failedCount: 0,
|
|
failures: [],
|
|
};
|
|
}
|
|
|
|
const payloads: Array<{
|
|
cellNumber: string;
|
|
message: string;
|
|
source: string;
|
|
cost: number;
|
|
templateId: string | null;
|
|
templateVerified: boolean | null;
|
|
}> = [];
|
|
|
|
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,
|
|
},
|
|
);
|
|
payloads.push({
|
|
cellNumber: recipient.cellNumber,
|
|
message: resolved.message,
|
|
source: resolved.source,
|
|
cost: this.smsBilling.costForMessage(resolved.message),
|
|
templateId: resolved.templateId,
|
|
templateVerified: resolved.templateVerified,
|
|
});
|
|
}
|
|
|
|
const serverIds: string[] = [];
|
|
const failures: Array<{ cellNumber: string; error: string }> = [];
|
|
let sentCount = 0;
|
|
let balanceExhausted = false;
|
|
|
|
for (const payload of payloads) {
|
|
if (balanceExhausted) {
|
|
failures.push({
|
|
cellNumber: payload.cellNumber,
|
|
error: 'Insufficient SMS balance. Contact Meshkee to top up.',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let charged = 0;
|
|
try {
|
|
charged = await this.smsBilling.chargeAmount(
|
|
businessId,
|
|
payload.cost,
|
|
1,
|
|
'mass',
|
|
);
|
|
} catch (err) {
|
|
const message = this.smsFailureMessage(err);
|
|
if (message.toLowerCase().includes('insufficient sms balance')) {
|
|
balanceExhausted = true;
|
|
}
|
|
failures.push({ cellNumber: payload.cellNumber, error: message });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await this.sms.sendMessage(
|
|
payload.cellNumber,
|
|
payload.message,
|
|
{ source: payload.source },
|
|
);
|
|
serverIds.push(result.serverId);
|
|
sentCount += 1;
|
|
} catch (err) {
|
|
if (charged > 0) {
|
|
await this.smsBilling.refund(businessId, charged, 1, 'mass');
|
|
}
|
|
failures.push({
|
|
cellNumber: payload.cellNumber,
|
|
error: this.smsFailureMessage(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
const sample = payloads[0];
|
|
const failedCount = failures.length;
|
|
const message =
|
|
failedCount === 0
|
|
? 'Message sent successfully'
|
|
: sentCount === 0
|
|
? 'No messages were sent'
|
|
: `Sent ${sentCount} of ${payloads.length}; ${failedCount} failed`;
|
|
|
|
return {
|
|
enabled: true,
|
|
message,
|
|
recipientCount: payloads.length,
|
|
sentCount,
|
|
failedCount,
|
|
serverIds,
|
|
failures,
|
|
source: sample?.source,
|
|
templateId: sample?.templateId,
|
|
templateVerified: sample?.templateVerified,
|
|
};
|
|
}
|
|
|
|
private smsFailureMessage(err: unknown): string {
|
|
if (err instanceof HttpException) {
|
|
const response = err.getResponse();
|
|
if (typeof response === 'string' && response.trim()) {
|
|
return response.trim();
|
|
}
|
|
if (typeof response === 'object' && response !== null && 'message' in response) {
|
|
const message = (response as { message?: string | string[] }).message;
|
|
if (Array.isArray(message)) {
|
|
return message.filter(Boolean).join(', ') || err.message;
|
|
}
|
|
if (typeof message === 'string' && message.trim()) {
|
|
return message.trim();
|
|
}
|
|
}
|
|
return err.message || 'Unable to send SMS';
|
|
}
|
|
if (err instanceof Error && err.message.trim()) {
|
|
return err.message.trim();
|
|
}
|
|
return 'Unable to send SMS';
|
|
}
|
|
|
|
/**
|
|
* 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: {
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
cellNumber: string;
|
|
email: string | null;
|
|
}) {
|
|
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
|
if (name) {
|
|
return `${name} · ${user.cellNumber}`;
|
|
}
|
|
return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber;
|
|
}
|
|
|
|
private async assertCustomerSearchPermission(
|
|
businessId: bigint,
|
|
userId: bigint,
|
|
) {
|
|
const allowed =
|
|
(await this.permissions.hasBusinessPermission(
|
|
userId,
|
|
businessId,
|
|
'orders.create',
|
|
)) ||
|
|
(await this.permissions.hasBusinessPermission(
|
|
userId,
|
|
businessId,
|
|
'workshops.update',
|
|
));
|
|
|
|
if (!allowed) {
|
|
throw new ForbiddenException(
|
|
'Missing permission to search customers for this business',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async assertPermission(
|
|
businessId: bigint,
|
|
userId: bigint,
|
|
permission: string,
|
|
) {
|
|
const allowed = await this.permissions.hasBusinessPermission(
|
|
userId,
|
|
businessId,
|
|
permission,
|
|
);
|
|
|
|
if (!allowed) {
|
|
throw new ForbiddenException(
|
|
`Missing permission: ${permission} for this business`,
|
|
);
|
|
}
|
|
}
|
|
}
|