366 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|