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