All files / routes/pro/crm inquiry-messages.routes.ts

97.56% Statements 40/41
91.66% Branches 11/12
60% Functions 3/5
97.56% Lines 40/41

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                                                  1x   1x                 7x 6x 6x     6x       1x 4x 4x 4x 4x 4x 4x 3x 1x     2x 2x             2x 2x 1x     1x               2x   2x   1x         1x       1x         3x 3x 3x 3x 3x 3x 3x 3x 1x         2x           2x 2x               2x   1x            
// Pro-side on-platform message thread (Restrict Direct Contact / Phase 3).
//
// Lets a pro read and reply to a homeowner's inquiry from the CRM lead detail,
// so all communication stays on the platform. Scoped to a lead the pro owns; the
// lead carries the source `inquiryId` that the thread and event log key off.
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../../dal";
import { NotFoundError, ValidationError } from "../../../lib/errors";
import { handleError, success } from "../../../lib/response";
import { parseRequiredId } from "../../../lib/utils";
import { requireProAccess } from "../../../middleware";
import type { Services } from "../../../services";
import { CommunicationLogService } from "../../../services/communication-log.service";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		dal: Dal;
		services: Services;
		proId: string;
	};
};
 
const MAX_MESSAGE_LEN = 2000;
 
const app = new Hono<Env>();
 
// Resolve a lead the pro owns → its linked inquiry id (or undefined).
async function resolveInquiryId(
	dal: Dal,
	services: Services,
	leadId: number,
	proId: string,
): Promise<number | undefined> {
	await services.leads.verifyProOwnership(leadId, proId);
	const lead = await dal.leads.findById(leadId);
	Iif (!lead) {
		throw new NotFoundError("Lead", String(leadId));
	}
	return lead.inquiryId ?? undefined;
}
 
// GET /:proId/crm/leads/:leadId/messages — thread + pro-visible timeline.
app.get("/:proId/crm/leads/:leadId/messages", requireProAccess, async (c) => {
	try {
		const dal = c.get("dal");
		const services = c.get("services");
		const proId = c.get("proId");
		const leadId = parseRequiredId(c.req.param("leadId"), "lead");
		const inquiryId = await resolveInquiryId(dal, services, leadId, proId);
		if (!inquiryId) {
			return success(c, { messages: [], timeline: [] });
		}
 
		const commLog = new CommunicationLogService(dal);
		const [messages, timeline] = await Promise.all([
			dal.inquiryMessages.listByInquiry(inquiryId),
			commLog.getTimeline(inquiryId, "pro"),
		]);
 
		// First view: advance the match to "viewed" and log the homeowner-visible
		// "Pro viewed your inquiry" event exactly once (guarded on current status).
		const match = await dal.homeownerInquiries.getMatch(inquiryId, proId);
		if (match && match.proStatus === "notified") {
			await dal.homeownerInquiries
				.setMatchStatus(inquiryId, proId, "viewed")
				.catch(() => {});
			await commLog.logEventSafe({
				inquiryId,
				eventType: "lead_viewed_by_pro",
				actorType: "pro",
				actorId: proId,
			});
		}
		// Clear the pro's unread count of homeowner messages.
		await dal.inquiryMessages.markRead(inquiryId, "pro").catch(() => {});
 
		return success(c, { messages, timeline });
	} catch (err) {
		return handleError(c, err);
	}
});
 
// POST /:proId/crm/leads/:leadId/messages — pro replies on-platform.
const messageSchema = z.object({
	body: z.string().trim().min(1).max(MAX_MESSAGE_LEN),
});
 
app.post(
	"/:proId/crm/leads/:leadId/messages",
	requireProAccess,
	zValidator("json", messageSchema),
	async (c) => {
		try {
			const dal = c.get("dal");
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
			const { body } = c.req.valid("json");
			const inquiryId = await resolveInquiryId(dal, services, leadId, proId);
			if (!inquiryId) {
				throw new ValidationError(
					"This lead is not linked to an on-platform inquiry.",
				);
			}
 
			const message = await dal.inquiryMessages.create({
				inquiryId,
				senderType: "pro",
				senderId: proId,
				body,
			});
			const commLog = new CommunicationLogService(dal);
			await commLog.logEventSafe({
				inquiryId,
				eventType: "message_sent",
				actorType: "pro",
				actorId: proId,
				metadata: { messageId: message.id },
			});
			// TODO(follow-up): email the homeowner that the pro replied.
			return success(c, message, 201);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
export default app;