All files / services/crm notes.service.ts

100% Statements 40/40
100% Branches 42/42
100% Functions 4/4
100% Lines 40/40

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 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178              5x                   24x                     17x 1x           16x 7x 1x       6x 1x       5x 1x           13x 13x 1x     12x 12x           12x                 12x 4x               4x             12x 8x 8x         2x         6x       2x         4x       4x                               8x   8x 1x               4x                 8x     8x 17x                 3x 3x 1x     2x         2x      
// Notes Service - Combined note + contacted + stage change action
import type { Dal } from "../../dal";
import type { Lead, NewLeadActivity } from "../../db/schema";
import { CONTACT_METHODS } from "../../db/schema/enums";
import { ValidationError, NotFoundError } from "../../lib/errors";
import { generateId } from "../../lib/utils";
 
const VALID_CONTACT_METHODS = new Set<string>(CONTACT_METHODS);
 
export type AddNoteInput = {
	content?: string;
	isContacted?: boolean;
	contactMethod?: string;
	newStageId?: number;
};
 
export class NotesService {
	constructor(private dal: Dal) {}
 
	/**
	 * Combined action: add note + mark as contacted + change stage
	 * All activities created with a shared groupId for visual clustering in timeline.
	 */
	async addNote(
		leadId: number,
		input: AddNoteInput,
	): Promise<{ activities: { activityType: string }[] }> {
		// Validate: at least one action must be specified
		if (!input.content && !input.isContacted && !input.newStageId) {
			throw new ValidationError(
				"At least one of note content, contacted, or stage change is required",
			);
		}
 
		// Validate: if contacted, note and method are required
		if (input.isContacted) {
			if (!input.content?.trim()) {
				throw new ValidationError(
					"Note content is required when marking as contacted",
				);
			}
			if (!input.contactMethod) {
				throw new ValidationError(
					"Contact method is required when marking as contacted",
				);
			}
			if (!VALID_CONTACT_METHODS.has(input.contactMethod)) {
				throw new ValidationError(
					`Invalid contact method. Allowed: ${CONTACT_METHODS.join(", ")}`,
				);
			}
		}
 
		const lead = await this.dal.leads.findById(leadId);
		if (!lead) {
			throw new NotFoundError("Lead", String(leadId));
		}
 
		const groupId = generateId();
		const activities: NewLeadActivity[] = [];
 
		// 1. Note activity
		/* v8 ignore start -- V8 artifact: ?. always defined in tests */
		if (input.content?.trim()) {
		/* v8 ignore stop */
			activities.push({
				leadId,
				activityType: "note_added",
				content: input.content.trim(),
				groupId,
			});
		}
 
		// 2. Contacted activity + update lead
		if (input.isContacted && input.contactMethod) {
			activities.push({
				leadId,
				activityType: "contacted",
				content: `Contacted via ${input.contactMethod}`,
				metadataJson: { method: input.contactMethod },
				groupId,
			});
 
			await this.dal.leads.update(leadId, {
				lastContactedAt: new Date(),
				lastContactMethod: input.contactMethod as Lead["lastContactMethod"],
			});
		}
 
		// 3. Stage change (if different from current)
		if (input.newStageId && input.newStageId !== lead.currentStageId) {
			const newStage = await this.dal.pipelineStages.findById(input.newStageId);
			if (
				!newStage ||
				newStage.deletedAt ||
				newStage.proId !== lead.proId
			) {
				throw new ValidationError("Invalid target stage");
			}
 
			// Block terminal stage moves — Won/Lost require additional data
			// (order value, loss reason) that must go through LeadsService.changeStage
			if (
				newStage.stageType === "system_terminal_won" ||
				newStage.stageType === "system_terminal_lost"
			) {
				throw new ValidationError(
					"Cannot move to Won/Lost stage from quick note. Use the pipeline view to capture required details.",
				);
			}
 
			const oldStage = await this.dal.pipelineStages.findById(
				lead.currentStageId,
			);
 
			activities.push({
				leadId,
				activityType: "stage_changed",
				content: `Stage changed from "${oldStage?.name ?? "Unknown"}" to "${newStage.name}"`,
				metadataJson: {
					fromStageId: lead.currentStageId,
					toStageId: input.newStageId,
					fromStageName: oldStage?.name,
					toStageName: newStage.name,
				},
				groupId,
			});
 
			// Detect reopening (terminal → non-terminal)
			// Note: newStage is guaranteed non-terminal by the guard above
			const wasTerminal =
				oldStage?.stageType === "system_terminal_won" ||
				oldStage?.stageType === "system_terminal_lost";
			if (wasTerminal) {
				activities.push({
					leadId,
					activityType: "lead_reopened",
					content: `Lead reopened from "${oldStage?.name}"`,
					groupId,
				});
			}
 
			await this.dal.leads.update(leadId, {
				currentStageId: input.newStageId,
			});
		}
 
		// Batch insert all activities
		/* v8 ignore start -- defensive guard: activities always non-empty */
		if (activities.length > 0) {
		/* v8 ignore stop */
			await this.dal.leadActivities.createMany(activities);
		}
 
		return {
			activities: activities.map((a) => ({ activityType: a.activityType })),
		};
	}
 
	/**
	 * Get activity timeline for a lead (paginated, reverse chronological).
	 */
	async getActivities(leadId: number, offset = 0, limit = 30) {
		// Verify lead exists
		const lead = await this.dal.leads.findById(leadId);
		if (!lead) {
			throw new NotFoundError("Lead", String(leadId));
		}
 
		const [activities, total] = await Promise.all([
			this.dal.leadActivities.findByLeadId(leadId, offset, limit),
			this.dal.leadActivities.countByLeadId(leadId),
		]);
 
		return { activities, total };
	}
}