Files
grow-api/src/customers/customers.service.ts
T
2026-07-07 20:35:14 +03:30

366 lines
11 KiB
TypeScript

import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CustomerPolicy } from '../authorization/policies/customer.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { Reserve } from '../reserves/entities/reserve.entity';
import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
import { Salon } from '../salons/entities/salon.entity';
import { UserRole } from '../users/enums/user-role.enum';
import { UsersService } from '../users/users.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { CreateSalonCustomerDto } from './dto/salon-customer.dto';
import { FindSalonCustomersQueryDto } from './dto/salon-customer.dto';
import {
SalonCustomerDetailDto,
SalonCustomerListResponseDto,
SalonCustomerSummaryDto,
} from './dto/salon-customer-response.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import { Customer } from './entities/customer.entity';
import { SalonCustomer } from './entities/salon-customer.entity';
import {
daysBetween,
groupVisitsByCustomer,
mapSegmentToClientStatus,
parseDate,
resolveSegment,
sumTotalPurchase,
} from './helpers/customer-segmentation.helper';
@Injectable()
export class CustomersService {
constructor(
@InjectRepository(Customer)
private readonly customersRepository: Repository<Customer>,
@InjectRepository(SalonCustomer)
private readonly salonCustomersRepository: Repository<SalonCustomer>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
@InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>,
private readonly usersService: UsersService,
private readonly customerPolicy: CustomerPolicy,
) {}
async create(createCustomerDto: CreateCustomerDto): Promise<Customer> {
const user = await this.usersService.create({
firstName: createCustomerDto.firstName,
lastName: createCustomerDto.lastName,
mobile: createCustomerDto.mobile,
role: UserRole.CUSTOMER,
});
const customer = this.customersRepository.create({
userId: user.id,
dateOfBirth: createCustomerDto.dateOfBirth,
address: createCustomerDto.address,
});
const saved = await this.customersRepository.save(customer);
return this.findOne(saved.id);
}
findAll(): Promise<Customer[]> {
return this.customersRepository.find({ relations: { user: true } });
}
async findAllForSalon(
user: AuthUser,
query: FindSalonCustomersQueryDto,
): Promise<SalonCustomerListResponseDto> {
const salon = await this.resolveSalonForUser(user);
const summaries = await this.buildSalonCustomerSummaries(salon.id);
const filtered = this.filterSalonCustomers(summaries, query);
return {
customers: filtered,
stats: this.buildSalonCustomerStats(summaries),
};
}
async findOneForSalon(
id: string,
user: AuthUser,
): Promise<SalonCustomerDetailDto> {
const salon = await this.resolveSalonForUser(user);
const customer = await this.findOne(id);
if (!(await this.customerPolicy.canRead(user, customer))) {
throw new ForbiddenException();
}
const isLinked = await this.salonCustomersRepository.exists({
where: { salonId: salon.id, customerId: customer.id },
});
if (!isLinked) {
throw new NotFoundException(`Customer #${id} not found in this salon`);
}
return this.buildSalonCustomerDetail(customer, salon.id);
}
async createForSalon(
dto: CreateSalonCustomerDto,
user: AuthUser,
): Promise<SalonCustomerDetailDto> {
const salon = await this.resolveSalonForUser(user);
const customer = await this.findOrCreateByMobile(dto);
await this.linkToSalon(salon.id, customer.id);
return this.buildSalonCustomerDetail(customer, salon.id);
}
async linkToSalon(salonId: string, customerId: string): Promise<void> {
await this.salonCustomersRepository.upsert(
{ salonId, customerId },
{ conflictPaths: ['salonId', 'customerId'] },
);
}
async findOrCreateByMobile(dto: CreateSalonCustomerDto): Promise<Customer> {
const existingUser = await this.usersService.findByMobile(dto.mobile);
if (existingUser) {
let customer = await this.customersRepository.findOne({
where: { userId: existingUser.id },
relations: { user: true },
});
if (!customer) {
customer = await this.customersRepository.save(
this.customersRepository.create({
userId: existingUser.id,
dateOfBirth: dto.dateOfBirth ?? '2000-01-01',
address: dto.address ?? '-',
}),
);
customer = await this.findOne(customer.id);
}
return customer;
}
return this.create({
firstName: dto.firstName,
lastName: dto.lastName,
mobile: dto.mobile,
dateOfBirth: dto.dateOfBirth ?? '2000-01-01',
address: dto.address ?? '-',
});
}
async findOne(id: string, user?: AuthUser): Promise<Customer> {
const customer = await this.customersRepository.findOne({
where: { id },
relations: { user: true },
});
if (!customer) {
throw new NotFoundException(`Customer #${id} not found`);
}
if (user && !(await this.customerPolicy.canRead(user, customer))) {
throw new ForbiddenException();
}
return customer;
}
async update(
id: string,
updateCustomerDto: UpdateCustomerDto,
user: AuthUser,
): Promise<Customer> {
const customer = await this.findOne(id);
if (!this.customerPolicy.canUpdate(user, customer)) {
throw new ForbiddenException();
}
const { firstName, lastName, mobile, dateOfBirth, address } =
updateCustomerDto;
if (
firstName !== undefined ||
lastName !== undefined ||
mobile !== undefined
) {
await this.usersService.update(customer.userId, {
firstName,
lastName,
mobile,
});
}
if (dateOfBirth !== undefined) {
customer.dateOfBirth = dateOfBirth;
}
if (address !== undefined) {
customer.address = address;
}
await this.customersRepository.save(customer);
return this.findOne(customer.id, user);
}
async remove(id: string): Promise<void> {
const customer = await this.findOne(id);
await this.usersService.remove(customer.userId);
}
private async resolveSalonForUser(user: AuthUser): Promise<Salon> {
if (user.role !== UserRole.SALON) {
throw new ForbiddenException();
}
const salon = await this.salonsRepository.findOne({
where: { ownerId: user.id },
order: { createdAt: 'ASC' },
});
if (!salon) {
throw new NotFoundException('Salon not found for this user');
}
return salon;
}
private async buildSalonCustomerSummaries(
salonId: string,
): Promise<SalonCustomerSummaryDto[]> {
const links = await this.salonCustomersRepository.find({
where: { salonId },
relations: { customer: { user: true } },
order: { createdAt: 'ASC' },
});
const completedReserves = await this.reservesRepository.find({
where: { salonId, status: ReserveStatus.COMPLETED },
relations: { items: { serviceSkill: true, service: true } },
order: { appointmentDate: 'ASC' },
});
const visitsByCustomer = groupVisitsByCustomer(completedReserves);
const now = new Date();
return links.map((link) =>
this.buildSummaryFromCustomer(
link.customer,
link.createdAt,
visitsByCustomer[link.customer.id] ?? [],
now,
),
);
}
private async buildSalonCustomerDetail(
customer: Customer,
salonId: string,
): Promise<SalonCustomerDetailDto> {
const link = await this.salonCustomersRepository.findOne({
where: { salonId, customerId: customer.id },
});
const completedReserves = await this.reservesRepository.find({
where: {
salonId,
customerId: customer.id,
status: ReserveStatus.COMPLETED,
},
relations: { items: { serviceSkill: true, service: true } },
order: { appointmentDate: 'DESC' },
});
const visits = groupVisitsByCustomer(completedReserves)[customer.id] ?? [];
const summary = this.buildSummaryFromCustomer(
customer,
link?.createdAt ?? customer.createdAt,
visits,
new Date(),
);
const serviceHistory = completedReserves.flatMap((reserve) =>
(reserve.items ?? []).map((item) => ({
id: item.id,
serviceName: item.service?.title ?? 'خدمت',
serviceIcon: item.service?.iconUrl ?? null,
date: reserve.appointmentDate,
price: reserve.totalAmount,
})),
);
return {
...summary,
serviceHistory,
};
}
private buildSummaryFromCustomer(
customer: Customer,
membershipDate: Date,
visits: ReturnType<typeof groupVisitsByCustomer>[string],
referenceDate: Date,
): SalonCustomerSummaryDto {
const totalPurchase = sumTotalPurchase(visits);
const visitCount = visits.length;
const lastVisit = visits[visits.length - 1];
const lastVisitDate = lastVisit?.appointmentDate ?? null;
const daysSinceVisit = lastVisitDate
? daysBetween(parseDate(lastVisitDate), referenceDate)
: null;
const segment = lastVisit
? resolveSegment(daysSinceVisit ?? 0, lastVisit.renewalDays)
: 'active';
return {
id: customer.id,
firstName: customer.user.firstName,
lastName: customer.user.lastName,
mobile: customer.user.mobile,
dateOfBirth: customer.dateOfBirth,
address: customer.address,
membershipDate: membershipDate.toISOString(),
totalPurchase,
visitCount,
lastVisitDate,
daysSinceVisit,
status: mapSegmentToClientStatus(segment, totalPurchase),
};
}
private filterSalonCustomers(
customers: SalonCustomerSummaryDto[],
query: FindSalonCustomersQueryDto,
): SalonCustomerSummaryDto[] {
const search = query.search?.trim().toLowerCase();
return customers.filter((customer) => {
const fullName =
`${customer.firstName} ${customer.lastName}`.toLowerCase();
const matchesSearch =
!search ||
fullName.includes(search) ||
customer.mobile.includes(search);
const matchesStatus =
!query.status || customer.status === query.status;
return matchesSearch && matchesStatus;
});
}
private buildSalonCustomerStats(customers: SalonCustomerSummaryDto[]) {
return {
total: customers.length,
active: customers.filter((customer) => customer.status === 'active')
.length,
atRisk: customers.filter((customer) => customer.status === 'at-risk')
.length,
lost: customers.filter((customer) => customer.status === 'lost').length,
vip: customers.filter((customer) => customer.status === 'vip').length,
};
}
}