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 };
}
}
|