All files / routes/pro/crm notes.routes.ts

100% Statements 24/24
100% Branches 8/8
100% Functions 2/2
100% Lines 24/24

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                                                  1x     1x 10x 10x 10x 10x     10x   7x                               7x 1x     6x             5x   5x         1x       7x 7x 7x 7x     7x   5x       7x   7x           5x             2x            
// CRM Notes & Activities Routes
import { Hono } from "hono";
import type { Dal } from "../../../dal";
import { ForbiddenError } from "../../../lib/errors";
import { handleError, success } from "../../../lib/response";
import {
	buildPaginationMeta,
	getPagination,
	parseRequiredId,
} from "../../../lib/utils";
import { requireProAccess, STAFF_WRITE_DENIED_MESSAGE } from "../../../middleware";
import type { Services } from "../../../services";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		proId: string;
		proRole: string;
	};
};
 
const notes = new Hono<Env>();
 
// Add note (combined action: note + contacted + stage change)
notes.post("/:proId/crm/leads/:leadId/notes", requireProAccess, async (c) => {
	try {
		const services = c.get("services");
		const proId = c.get("proId");
		const leadId = parseRequiredId(c.req.param("leadId"), "lead");
 
		// Verify ownership
		await services.leads.verifyProOwnership(leadId, proId);
 
		const body = await c.req.json<{
			content?: string;
			isContacted?: boolean;
			contactMethod?: string;
			newStageId?: number;
		}>();
 
		// This route is the ONE lead write on the staff allowlist
		// (`STAFF_WRITE_ALLOWLIST` in middleware/auth.middleware.ts), and it is
		// allowlisted for the note. `newStageId` moves the lead — including to
		// Won or Lost — which is precisely the capability the allowlist denies
		// everywhere else (`PATCH …/stage` is refused for staff). Without this
		// check the whole scope is one request away from being bypassed, and the
		// portal hiding the control (components/crm/lead-write-scope.ts) is not
		// an access control. Refuse the stage change rather than the note: the
		// member keeps the capability they do have.
		if (c.get("proRole") === "staff" && body.newStageId !== undefined) {
			throw new ForbiddenError(STAFF_WRITE_DENIED_MESSAGE);
		}
 
		const result = await services.notes.addNote(leadId, {
			content: body.content,
			isContacted: body.isContacted,
			contactMethod: body.contactMethod,
			newStageId: body.newStageId,
		});
 
		return success(c, result, 201);
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Get activity timeline
notes.get(
	"/:proId/crm/leads/:leadId/activities",
	requireProAccess,
	async (c) => {
		try {
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
 
			// Verify ownership
			await services.leads.verifyProOwnership(leadId, proId);
 
			const { page, limit } = getPagination(
				Number(c.req.query("page") || 1),
				Number(c.req.query("limit") || 30),
			);
			const offset = (page - 1) * limit;
 
			const { activities, total } = await services.notes.getActivities(
				leadId,
				offset,
				limit,
			);
 
			return success(
				c,
				activities,
				200,
				buildPaginationMeta(total, page, limit),
			);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
export default notes;