import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { buildDualDailyActivitySeries, startOfLocalDay, } from '../common/daily-activity'; import { PrismaService } from '../prisma/prisma.service'; import { CreateCustomerDto } from './dto/create-customer.dto'; import { ListCustomersDto } from './dto/list-customers.dto'; import { SearchCustomersDto } from './dto/search-customers.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto'; type CustomerListRow = { id: bigint; cellNumber: string; firstName: string | null; lastName: string | null; email: string | null; createdAt: Date; isEnabled: boolean; }; @Injectable() export class CustomersService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} 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 where = Prisma.sql` WHERE bc.business_id = ${businessId} ${nameLike ? Prisma.sql` AND ( u.first_name ILIKE ${nameLike} OR u.last_name ILIKE ${nameLike} OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} ) ` : Prisma.empty} ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} `; const [items, totalRow] = await Promise.all([ this.prisma.$queryRaw(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", bc.created_at AS "createdAt", bc.is_enabled AS "isEnabled" FROM business_customers bc JOIN users u ON u.id = bc.user_id ${where} ORDER BY bc.created_at DESC LIMIT ${pageSize} OFFSET ${skip} `), this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` SELECT COUNT(*)::int AS "total" FROM business_customers bc JOIN users u ON u.id = bc.user_id ${where} `), ]); 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), createdAt: user.createdAt.toISOString(), isEnabled: user.isEnabled, })), 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), 90); 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.assertPermission(businessId, actor.id, 'orders.create'); 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 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'); } } 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 membership = 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() }, }); } 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, 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.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(), 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, 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 }; } 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 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`, ); } } }