Add backend API for personal finance management application
- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL. - Added essential files including architecture documentation, environment configuration, and Docker setup. - Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow. - Established a robust database schema with Prisma for data management. - Integrated middleware for authentication and error handling. - Created service and repository layers to adhere to SOLID principles and clean architecture. - Included example environment variables for development, staging, and production setups.
This commit is contained in:
148
backend-api/src/services/ClientService.ts
Normal file
148
backend-api/src/services/ClientService.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {Client} from '@prisma/client';
|
||||
import {ClientRepository} 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<Client> {
|
||||
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<Client[]> {
|
||||
return this.clientRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients with statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
return this.clientRepository.getWithStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single client by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<Client> {
|
||||
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<Client> {
|
||||
// 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<void> {
|
||||
// Verify ownership
|
||||
const client = 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<number> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user