import {Client} from '@prisma/client'; import {ClientRepository, ClientWithStats} from '../repositories/ClientRepository'; import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors'; export interface CreateClientDTO { name: string; email: string; phone?: string; address?: string; notes?: string; } export interface UpdateClientDTO { name?: string; email?: string; phone?: string; address?: string; notes?: string; } /** * Service for Client business logic * Implements Single Responsibility Principle - handles only business logic * Implements Dependency Inversion - depends on repository abstraction */ export class ClientService { constructor(private clientRepository: ClientRepository) {} /** * Create a new client */ async create(userId: string, data: CreateClientDTO): Promise { this.validateClientData(data); // Check for duplicate email const existing = await this.clientRepository.findByEmail(userId, data.email); if (existing) { throw new ConflictError('A client with this email already exists'); } return this.clientRepository.create({ name: data.name, email: data.email, phone: data.phone, address: data.address, notes: data.notes, user: { connect: {id: userId} } }); } /** * Get all clients for a user */ async getAllByUser(userId: string): Promise { return this.clientRepository.findAllByUser(userId); } /** * Get clients with statistics */ async getWithStats(userId: string): Promise { return this.clientRepository.getWithStats(userId); } /** * Get a single client by ID */ async getById(id: string, userId: string): Promise { const client = await this.clientRepository.findById(id); if (!client) { throw new NotFoundError('Client not found'); } // Ensure user owns this client if (client.userId !== userId) { throw new ForbiddenError('Access denied'); } return client; } /** * Update a client */ async update(id: string, userId: string, data: UpdateClientDTO): Promise { // Verify ownership await this.getById(id, userId); if (data.email) { this.validateClientData(data as CreateClientDTO); // Check for duplicate email (excluding current client) const existing = await this.clientRepository.findByEmail(userId, data.email); if (existing && existing.id !== id) { throw new ConflictError('A client with this email already exists'); } } return this.clientRepository.update(id, data); } /** * Delete a client */ async delete(id: string, userId: string): Promise { // Verify ownership await this.getById(id, userId); // Check if client has invoices - we still allow deletion due to cascade // but you might want to prevent deletion if there are invoices // Uncomment below to prevent deletion: // if (client.invoices && client.invoices.length > 0) { // throw new ValidationError('Cannot delete client with existing invoices'); // } await this.clientRepository.delete(id); } /** * Get total revenue from all clients */ async getTotalRevenue(userId: string): Promise { return this.clientRepository.getTotalRevenue(userId); } /** * Validate client data */ private validateClientData(data: CreateClientDTO | UpdateClientDTO): void { if (data.email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(data.email)) { throw new ValidationError('Invalid email format'); } } if (data.phone) { // Basic phone validation - at least 10 digits const phoneDigits = data.phone.replace(/\D/g, ''); if (phoneDigits.length < 10) { throw new ValidationError('Phone number must contain at least 10 digits'); } } } }