customers

This commit is contained in:
2026-07-07 20:35:14 +03:30
parent 94367752a2
commit 9fdc81bff1
11 changed files with 720 additions and 3 deletions
+9 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SalonCustomer } from '../customers/entities/salon-customer.entity';
import { Reserve } from '../reserves/entities/reserve.entity';
import { AuthorizationService } from './authorization.service';
import { Permission } from './entities/permission.entity';
@@ -13,7 +14,14 @@ import { StylistPolicy } from './policies/stylist.policy';
import { SubscriptionPolicy } from './policies/subscription.policy';
@Module({
imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])],
imports: [
TypeOrmModule.forFeature([
Permission,
UserPermission,
Reserve,
SalonCustomer,
]),
],
providers: [
AuthorizationService,
SalonPolicy,
+19 -1
View File
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
import { Customer } from '../../customers/entities/customer.entity';
import { SalonCustomer } from '../../customers/entities/salon-customer.entity';
import { Reserve } from '../../reserves/entities/reserve.entity';
import { UserRole } from '../../users/enums/user-role.enum';
import { AuthorizationService } from '../authorization.service';
@@ -13,6 +14,8 @@ export class CustomerPolicy {
private readonly authorizationService: AuthorizationService,
@InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>,
@InjectRepository(SalonCustomer)
private readonly salonCustomersRepository: Repository<SalonCustomer>,
) {}
async canRead(user: AuthUser, customer: Customer): Promise<boolean> {
@@ -23,7 +26,10 @@ export class CustomerPolicy {
return customer.userId === user.id;
}
if (user.role === UserRole.SALON) {
return this.hasReserveAtSalon(customer.id, user.id);
return (
(await this.hasReserveAtSalon(customer.id, user.id)) ||
(await this.hasLinkAtSalon(customer.id, user.id))
);
}
return false;
}
@@ -49,4 +55,16 @@ export class CustomerPolicy {
},
});
}
private async hasLinkAtSalon(
customerId: string,
salonOwnerId: string,
): Promise<boolean> {
return this.salonCustomersRepository.exists({
where: {
customerId,
salon: { ownerId: salonOwnerId },
},
});
}
}
+43
View File
@@ -6,6 +6,8 @@ import {
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@@ -19,6 +21,14 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum';
import { CustomersService } from './customers.service';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import {
CreateSalonCustomerDto,
FindSalonCustomersQueryDto,
} from './dto/salon-customer.dto';
import {
SalonCustomerDetailDto,
SalonCustomerListResponseDto,
} from './dto/salon-customer-response.dto';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import {
ApiStandardController,
@@ -35,6 +45,39 @@ import { Customer } from './entities/customer.entity';
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Roles(UserRole.SALON)
@Permissions(PermissionCode.CUSTOMERS_READ)
@ApiStandardOkResponse(SalonCustomerListResponseDto)
@Get('salon')
findAllForSalon(
@CurrentUser() user: AuthUser,
@Query() query: FindSalonCustomersQueryDto,
) {
return this.customersService.findAllForSalon(user, query);
}
@Roles(UserRole.SALON)
@Permissions(PermissionCode.CUSTOMERS_WRITE)
@ApiStandardOkResponse(SalonCustomerDetailDto)
@Post('salon')
createForSalon(
@Body() createSalonCustomerDto: CreateSalonCustomerDto,
@CurrentUser() user: AuthUser,
) {
return this.customersService.createForSalon(createSalonCustomerDto, user);
}
@Roles(UserRole.SALON)
@Permissions(PermissionCode.CUSTOMERS_READ)
@ApiStandardOkResponse(SalonCustomerDetailDto)
@Get('salon/:id')
findOneForSalon(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
) {
return this.customersService.findOneForSalon(id, user);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.CUSTOMERS_READ)
@ApiStandardOkResponse(Customer, { isArray: true })
+4 -1
View File
@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { Reserve } from '../reserves/entities/reserve.entity';
import { Salon } from '../salons/entities/salon.entity';
import { UsersModule } from '../users/users.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { Customer } from './entities/customer.entity';
import { SalonCustomer } from './entities/salon-customer.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Customer]),
TypeOrmModule.forFeature([Customer, SalonCustomer, Salon, Reserve]),
UsersModule,
AuthorizationModule,
AuthGuardsModule,
+262
View File
@@ -7,17 +7,42 @@ 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,
) {}
@@ -44,6 +69,91 @@ export class CustomersService {
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 },
@@ -100,4 +210,156 @@ export class CustomersService {
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,
};
}
}
@@ -0,0 +1,86 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SalonCustomerStatsDto {
@ApiProperty({ example: 42 })
total: number;
@ApiProperty({ example: 28 })
active: number;
@ApiProperty({ example: 8 })
atRisk: number;
@ApiProperty({ example: 4 })
lost: number;
@ApiProperty({ example: 6 })
vip: number;
}
export class SalonCustomerServiceHistoryDto {
@ApiProperty({ format: 'uuid' })
id: string;
@ApiProperty({ example: 'کوتاهی مو' })
serviceName: string;
@ApiPropertyOptional({ example: 'https://example.com/icon.png' })
serviceIcon: string | null;
@ApiProperty({ example: '2025-06-12' })
date: string;
@ApiProperty({ example: 850_000 })
price: number;
}
export class SalonCustomerSummaryDto {
@ApiProperty({ format: 'uuid' })
id: string;
@ApiProperty({ example: 'سارا' })
firstName: string;
@ApiProperty({ example: 'احمدی' })
lastName: string;
@ApiProperty({ example: '09123456789' })
mobile: string;
@ApiProperty({ example: '1995-03-15' })
dateOfBirth: string;
@ApiProperty({ example: 'تهران، ونک' })
address: string;
@ApiProperty({ example: '2024-01-10T10:00:00.000Z' })
membershipDate: string;
@ApiProperty({ example: 12_500_000 })
totalPurchase: number;
@ApiProperty({ example: 8 })
visitCount: number;
@ApiPropertyOptional({ example: '2025-06-12' })
lastVisitDate: string | null;
@ApiPropertyOptional({ example: 12 })
daysSinceVisit: number | null;
@ApiProperty({ enum: ['active', 'at-risk', 'lost', 'vip'] })
status: 'active' | 'at-risk' | 'lost' | 'vip';
}
export class SalonCustomerListResponseDto {
@ApiProperty({ type: [SalonCustomerSummaryDto] })
customers: SalonCustomerSummaryDto[];
@ApiProperty({ type: SalonCustomerStatsDto })
stats: SalonCustomerStatsDto;
}
export class SalonCustomerDetailDto extends SalonCustomerSummaryDto {
@ApiProperty({ type: [SalonCustomerServiceHistoryDto] })
serviceHistory: SalonCustomerServiceHistoryDto[];
}
+53
View File
@@ -0,0 +1,53 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Length,
Matches,
} from 'class-validator';
export class CreateSalonCustomerDto {
@ApiProperty({ example: 'سارا' })
@IsString()
@Length(1, 100)
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: 'احمدی' })
@IsString()
@Length(1, 100)
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: '09123456789' })
@Matches(/^09\d{9}$/)
mobile: string;
@ApiPropertyOptional({ example: '1995-03-15' })
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@ApiPropertyOptional({ example: 'تهران، ونک' })
@IsOptional()
@IsString()
@Length(1, 500)
address?: string;
}
export class FindSalonCustomersQueryDto {
@ApiPropertyOptional({ description: 'Search by name or mobile' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
enum: ['active', 'at-risk', 'lost', 'vip'],
})
@IsOptional()
@IsIn(['active', 'at-risk', 'lost', 'vip'])
status?: 'active' | 'at-risk' | 'lost' | 'vip';
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Salon } from '../../salons/entities/salon.entity';
import { Customer } from './customer.entity';
@Entity('salon_customers')
@Unique(['salonId', 'customerId'])
@Index(['salonId'])
@Index(['customerId'])
export class SalonCustomer {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
salonId: string;
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'salonId' })
salon: Salon;
@Column()
customerId: string;
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customerId' })
customer: Customer;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,112 @@
import { ReserveItem } from '../../reserves/entities/reserve-item.entity';
import { Reserve } from '../../reserves/entities/reserve.entity';
export const DEFAULT_RENEWAL_DAYS = 30;
export const AT_RISK_GRACE_DAYS = 15;
export const RETURNABLE_GRACE_DAYS = 30;
export const VIP_PURCHASE_THRESHOLD = 5_000_000;
export type CustomerSegment = 'active' | 'at-risk' | 'returnable' | 'lost';
export interface CustomerVisit {
appointmentDate: string;
renewalDays: number;
totalAmount: number;
}
export function groupVisitsByCustomer(
reserves: Reserve[],
): Record<string, CustomerVisit[]> {
const grouped: Record<string, CustomerVisit[]> = {};
for (const reserve of reserves) {
const renewalDays = resolveRenewalDays(reserve.items);
const visit: CustomerVisit = {
appointmentDate: reserve.appointmentDate,
renewalDays,
totalAmount: reserve.totalAmount,
};
if (!grouped[reserve.customerId]) {
grouped[reserve.customerId] = [];
}
grouped[reserve.customerId].push(visit);
}
return grouped;
}
export function resolveRenewalDays(items: ReserveItem[] | undefined): number {
if (!items?.length) {
return DEFAULT_RENEWAL_DAYS;
}
const renewalDays = items
.map((item) => item.serviceSkill?.renewalDays ?? DEFAULT_RENEWAL_DAYS)
.filter((value) => value > 0);
if (renewalDays.length === 0) {
return DEFAULT_RENEWAL_DAYS;
}
return Math.max(...renewalDays);
}
export function resolveSegment(
daysSince: number,
renewalDays: number,
): CustomerSegment {
if (daysSince <= renewalDays) {
return 'active';
}
const atRiskLimit = renewalDays + AT_RISK_GRACE_DAYS;
if (daysSince <= atRiskLimit) {
return 'at-risk';
}
const returnableLimit = atRiskLimit + RETURNABLE_GRACE_DAYS;
if (daysSince <= returnableLimit) {
return 'returnable';
}
return 'lost';
}
export function daysBetween(from: Date, to: Date): number {
const start = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
const end = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
return Math.floor((end - start) / (1000 * 60 * 60 * 24));
}
export function parseDate(value: string): Date {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
export function sumTotalPurchase(visits: CustomerVisit[]): number {
return visits.reduce((sum, visit) => sum + visit.totalAmount, 0);
}
export function isVipCustomer(totalPurchase: number): boolean {
return totalPurchase >= VIP_PURCHASE_THRESHOLD;
}
export function mapSegmentToClientStatus(
segment: CustomerSegment,
totalPurchase: number,
): 'active' | 'at-risk' | 'lost' | 'vip' {
if (isVipCustomer(totalPurchase)) {
return 'vip';
}
if (segment === 'returnable' || segment === 'lost') {
return 'lost';
}
if (segment === 'at-risk') {
return 'at-risk';
}
return 'active';
}
@@ -0,0 +1,89 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
TableUnique,
} from 'typeorm';
export class AddSalonCustomers1721100000000 implements MigrationInterface {
name = 'AddSalonCustomers1721100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'salon_customers',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'salonId', type: 'uuid' },
{ name: 'customerId', type: 'uuid' },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createForeignKey(
'salon_customers',
new TableForeignKey({
columnNames: ['salonId'],
referencedTableName: 'salons',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'salon_customers',
new TableForeignKey({
columnNames: ['customerId'],
referencedTableName: 'customers',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'salon_customers',
new TableIndex({
name: 'IDX_salon_customers_salonId',
columnNames: ['salonId'],
}),
);
await queryRunner.createIndex(
'salon_customers',
new TableIndex({
name: 'IDX_salon_customers_customerId',
columnNames: ['customerId'],
}),
);
await queryRunner.createUniqueConstraint(
'salon_customers',
new TableUnique({
name: 'UQ_salon_customers_salonId_customerId',
columnNames: ['salonId', 'customerId'],
}),
);
await queryRunner.query(`
INSERT INTO salon_customers ("salonId", "customerId", "createdAt", "updatedAt")
SELECT DISTINCT "salonId", "customerId", NOW(), NOW()
FROM reserves
ON CONFLICT ("salonId", "customerId") DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('salon_customers');
}
}
+1
View File
@@ -89,6 +89,7 @@ export class ReservesService {
});
const saved = await this.reservesRepository.save(reserve);
await this.customersService.linkToSalon(saved.salonId, saved.customerId);
await this.logStatusChange(saved.id, null, saved.status, user.id);
return this.findOne(saved.id, user);
}