Files
backend/src/users/users.service.ts
T
Alireza HassaniandCursor fb04425df7 Clarify technical-info docs and ship pending owner/color fixes.
Document that product specs need /technical-info for labels, auto-verify admin-created business owners, and expand category color presets with Farsi aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 06:46:28 +03:30

594 lines
17 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { AuthUser, ASSIGNABLE_GLOBAL_ROLES } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { SmsService } from '../auth/sms.service';
import { PrismaService } from '../prisma/prisma.service';
import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersDto } from './dto/list-users.dto';
import { SearchUsersDto } from './dto/search-users.dto';
import { SendUserMessageDto } from './dto/send-user-message.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
type UserListRow = {
id: bigint;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
cellNumber: string;
roles: string | null;
roleSlug: string | null;
businesses: string | null;
businessMemberId: bigint | null;
isBusinessOwner: boolean | null;
teamRole: string | null;
createdAt: Date;
isActive: boolean;
};
/**
* ILIKE patterns so local Iranian input (09…) matches stored E.164 (+98…).
* `09024300` must match `+989024300340`; short fragments like `4300` still match.
*/
function cellNumberLikePatterns(input: string): string[] {
const trimmed = input.trim();
if (!trimmed) return [];
const patterns = new Set<string>();
patterns.add(`%${trimmed}%`);
const digits = trimmed.replace(/\D/g, '');
if (!digits) return [...patterns];
patterns.add(`%${digits}%`);
let rest = digits;
if (rest.startsWith('00')) rest = rest.slice(2);
if (rest.startsWith('98')) {
patterns.add(`%+${rest}%`);
const national = rest.slice(2);
if (national) {
patterns.add(`%0${national}%`);
patterns.add(`%${national}%`);
}
} else if (rest.startsWith('0')) {
const national = rest.slice(1);
if (national) {
patterns.add(`%+98${national}%`);
patterns.add(`%98${national}%`);
}
} else if (rest.startsWith('9')) {
patterns.add(`%+98${rest}%`);
patterns.add(`%98${rest}%`);
patterns.add(`%0${rest}%`);
}
return [...patterns];
}
function cellNumberIlikeFilter(input: string | undefined): Prisma.Sql {
const patterns = cellNumberLikePatterns(input ?? '');
if (!patterns.length) return Prisma.empty;
return Prisma.sql`AND (${Prisma.join(
patterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
' OR ',
)})`;
}
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly sms: SmsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
async updateRole(userIdRaw: string, dto: UpdateUserRoleDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const userId = BigInt(userIdRaw);
if (userId === actor.id && dto.roleSlug !== 'super_admin') {
throw new BadRequestException('You cannot change your own role');
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.isActive) {
throw new NotFoundException('User not found');
}
const targetRole = await this.prisma.role.findUnique({
where: { slug: dto.roleSlug },
});
if (!targetRole) {
throw new BadRequestException('Invalid role');
}
const replaceableRoles = await this.prisma.role.findMany({
where: { slug: { in: [...ASSIGNABLE_GLOBAL_ROLES] } },
select: { id: true },
});
const replaceableRoleIds = replaceableRoles.map((role) => role.id);
await this.prisma.$transaction(async (tx) => {
await tx.userRole.deleteMany({
where: {
userId,
roleId: { in: replaceableRoleIds },
},
});
await tx.userRole.create({
data: {
userId,
roleId: targetRole.id,
},
});
if (dto.roleSlug === 'business_owner') {
await tx.user.update({
where: { id: userId },
data: { cellVerifiedAt: user.cellVerifiedAt ?? new Date() },
});
}
});
const userRoles = await this.prisma.userRole.findMany({
where: { userId },
include: { role: true },
});
return {
id: userId,
role: dto.roleSlug,
roles: userRoles.map((entry) => entry.role.slug),
};
}
async list(query: ListUsersDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 24;
const skip = (page - 1) * pageSize;
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
const roleSlug = query.role?.trim() || null;
const businessId = query.businessId ?? null;
const membership = query.membership ?? null;
const where = Prisma.sql`
WHERE 1=1
${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}
${cellNumberIlikeFilter(query.cellNumber)}
${roleSlug ? Prisma.sql`
AND EXISTS (
SELECT 1
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = u.id AND r.slug = ${roleSlug}
)
` : Prisma.empty}
${businessId && membership === 'staff' ? Prisma.sql`
AND EXISTS (
SELECT 1
FROM business_users bu
WHERE bu.user_id = u.id AND bu.business_id = ${businessId}
)
` : Prisma.empty}
${businessId && membership === 'all' ? Prisma.sql`
AND EXISTS (
SELECT 1
FROM (
SELECT user_id FROM business_users WHERE business_id = ${businessId}
UNION
SELECT user_id FROM business_customers WHERE business_id = ${businessId}
) ub
WHERE ub.user_id = u.id
)
` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
this.prisma.$queryRaw<UserListRow[]>(Prisma.sql`
SELECT
u.id AS "id",
u.first_name AS "firstName",
u.last_name AS "lastName",
u.first_name_en AS "firstNameEn",
u.last_name_en AS "lastNameEn",
u.cell_number AS "cellNumber",
u.created_at AS "createdAt",
u.is_active AS "isActive",
roles.roles AS "roles",
global_role.slug AS "roleSlug",
biz.businesses AS "businesses",
biz_member.member_id AS "businessMemberId",
biz_member.is_owner AS "isBusinessOwner",
biz_member.team_slug AS "teamRole"
FROM users u
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT r.name, ', ' ORDER BY r.name) AS roles
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = u.id
) roles ON TRUE
LEFT JOIN LATERAL (
SELECT r.slug
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = u.id
AND r.slug IN (${Prisma.join([...ASSIGNABLE_GLOBAL_ROLES])})
ORDER BY r.name
LIMIT 1
) global_role ON TRUE
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT b.name, ', ' ORDER BY b.name) AS businesses
FROM (
SELECT business_id FROM business_users WHERE user_id = u.id
UNION
SELECT business_id FROM business_customers WHERE user_id = u.id
) ub
JOIN businesses b ON b.id = ub.business_id
) biz ON TRUE
${businessId ? Prisma.sql`
LEFT JOIN LATERAL (
SELECT bu.id AS member_id, bu.is_owner, r.slug AS team_slug
FROM business_users bu
LEFT JOIN roles r ON r.id = bu.role_id
WHERE bu.user_id = u.id AND bu.business_id = ${businessId}
LIMIT 1
) biz_member ON TRUE
` : Prisma.sql`
LEFT JOIN LATERAL (
SELECT NULL::bigint AS member_id, NULL::boolean AS is_owner, NULL::text AS team_slug
) biz_member ON TRUE
`}
${where}
ORDER BY u.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM users u
${where}
`),
]);
return {
items,
total: totalRow[0]?.total ?? 0,
page,
pageSize,
};
}
async create(dto: CreateUserDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(dto.businessId);
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');
}
}
if (!existingUser && !dto.password) {
throw new BadRequestException('password is required for new users');
}
const passwordHash = existingUser
? existingUser.passwordHash
: await bcrypt.hash(dto.password!, 10);
const user = await this.prisma.$transaction(async (tx) => {
const account =
existingUser ??
(await tx.user.create({
data: {
cellNumber: dto.cellNumber,
passwordHash,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
cellVerifiedAt: new Date(),
},
}));
if (existingUser && !existingUser.cellVerifiedAt) {
await tx.user.update({
where: { id: account.id },
data: { cellVerifiedAt: new Date() },
});
}
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;
});
const verifiedAt = user.cellVerifiedAt ?? new Date();
return {
id: user.id,
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
cellVerifiedAt: verifiedAt,
isVerified: verifiedAt !== null,
businessId,
role: 'customer',
};
}
async search(query: SearchUsersDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const q = query.q.trim();
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
const like = `%${q}%`;
const cellPatterns = cellNumberLikePatterns(q);
const cellMatch = Prisma.join(
cellPatterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
' OR ',
);
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 users u
WHERE u.is_active = TRUE
AND (
${cellMatch}
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,
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
label: this.formatLabel(user),
})),
};
}
async update(userIdRaw: string, dto: UpdateUserDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const userId = BigInt(userIdRaw);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
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');
}
}
const isBusinessOwner =
Boolean(
await this.prisma.businessUser.findFirst({
where: { userId, isOwner: true },
select: { id: true },
}),
) ||
Boolean(
await this.prisma.userRole.findFirst({
where: { userId, role: { slug: 'business_owner' } },
select: { id: true },
}),
);
const updated = 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(),
...(isBusinessOwner
? { cellVerifiedAt: user.cellVerifiedAt ?? new Date() }
: {}),
},
});
return {
id: updated.id,
firstName: updated.firstName,
lastName: updated.lastName,
firstNameEn: updated.firstNameEn,
lastNameEn: updated.lastNameEn,
email: updated.email,
cellNumber: updated.cellNumber,
};
}
async resetPassword(userIdRaw: string, dto: AdminResetPasswordDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const userId = BigInt(userIdRaw);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash },
});
return { message: 'Password reset successfully' };
}
async remove(userIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const userId = BigInt(userIdRaw);
if (userId === actor.id) {
throw new BadRequestException('You cannot remove your own account');
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
await this.prisma.user.update({
where: { id: userId },
data: { isActive: false },
});
return { message: 'User removed' };
}
async sendMessage(userIdRaw: string, dto: SendUserMessageDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const userId = BigInt(userIdRaw);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (!this.sms.isEnabled()) {
return {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
};
}
try {
await this.sms.sendMessage(user.cellNumber, dto.message.trim());
} catch {
throw new ServiceUnavailableException('SMS provider is not configured yet');
}
return {
enabled: true,
message: 'Message sent successfully',
};
}
private formatLabel(user: {
firstName: string | null;
lastName: string | null;
cellNumber: string;
}): string {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
return name ? `${name} (${user.cellNumber})` : user.cellNumber;
}
}