All files / services/crm lead-sources.service.ts

100% Statements 38/38
100% Branches 29/29
100% Functions 6/6
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107                      25x           2x             10x 10x 2x         8x 2x           6x 6x 14x   6x   3x 2x 2x 1x   1x   1x     3x                         5x 5x 1x   4x 1x       3x 1x     2x 2x 1x   1x             4x 4x 1x   3x 1x     2x 2x 1x   1x      
// Lead Sources Service - Business Logic Layer
import type { Dal } from "../../dal";
import type { LeadSource } from "../../db/schema";
import {
	ForbiddenError,
	NotFoundError,
	ValidationError,
	ConflictError,
} from "../../lib/errors";
 
export class LeadSourcesService {
	constructor(private dal: Dal) {}
 
	async getSources(
		proId: string,
		includeInactive = false,
	): Promise<LeadSource[]> {
		return this.dal.leadSources.findByProId(proId, includeInactive);
	}
 
	async createSource(
		proId: string,
		input: { name: string; icon?: string; color?: string },
	): Promise<LeadSource> {
		const name = input.name.trim();
		if (!name || name.length > 50) {
			throw new ValidationError(
				"Source name must be between 1 and 50 characters",
			);
		}
 
		if (input.color && !/^#[0-9A-Fa-f]{3,6}$/.test(input.color)) {
			throw new ValidationError(
				"Color must be a valid hex color (e.g. #FF6B35)",
			);
		}
 
		// Check for duplicate name (including inactive sources)
		const existing = await this.dal.leadSources.findByProId(proId, true);
		const duplicate = existing.find(
			(s) => s.name.toLowerCase() === name.toLowerCase(),
		);
		if (duplicate) {
			// If the duplicate is inactive, reactivate it instead of creating a new one
			if (!duplicate.isActive) {
				const reactivated = await this.dal.leadSources.reactivate(duplicate.id);
				if (!reactivated) {
					throw new NotFoundError("Lead source", String(duplicate.id));
				}
				return reactivated;
			}
			throw new ConflictError(`Source "${name}" already exists`);
		}
 
		return this.dal.leadSources.create({
			proId,
			name,
			icon: input.icon,
			color: input.color,
			isSystem: false,
		});
	}
 
	async deactivateSource(
		proId: string,
		sourceId: number,
	): Promise<LeadSource> {
		const source = await this.dal.leadSources.findById(sourceId);
		if (!source) {
			throw new NotFoundError("Lead source", String(sourceId));
		}
		if (source.proId !== proId) {
			throw new ForbiddenError("Source does not belong to this pro");
		}
 
		// Cannot deactivate the system Marketplace source
		if (source.isSystem) {
			throw new ValidationError("System sources cannot be deactivated");
		}
 
		const updated = await this.dal.leadSources.deactivate(sourceId);
		if (!updated) {
			throw new NotFoundError("Lead source", String(sourceId));
		}
		return updated;
	}
 
	async reactivateSource(
		proId: string,
		sourceId: number,
	): Promise<LeadSource> {
		const source = await this.dal.leadSources.findById(sourceId);
		if (!source) {
			throw new NotFoundError("Lead source", String(sourceId));
		}
		if (source.proId !== proId) {
			throw new ForbiddenError("Source does not belong to this pro");
		}
 
		const updated = await this.dal.leadSources.reactivate(sourceId);
		if (!updated) {
			throw new NotFoundError("Lead source", String(sourceId));
		}
		return updated;
	}
}