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(); 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(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; } }