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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 | 2x 2x 30x 10x 10x 10x 20x 2x 2x 52x 4x 48x 21x 24x 2x 2x 2x 14x 14x 14x 5x 14x 14x 13x 4x 4x 4x 13x 14x 13x 13x 2x 11x 11x 11x 11x 10x 10x 10x 11x 11x 2x 2x 2x 17x 17x 17x 17x 16x 17x 15x 14x 14x 17x 17x 17x 5x 5x 3x 11x 11x 11x 2x 10x 10x 10x 10x 9x 4x 3x 3x 2x 1x 1x 2x 2x 2x 4x 4x 4x 4x 3x 2x 2x 1x 1x 1x 2x 19x 1x 19x 1x 2x 21x 21x 21x 21x 20x 17x 16x 3x 13x 13x 8x 7x 12x 12x 2x 1x 10x 10x 5x 5x 1x 5x 5x 5x 5x 5x 5x 5x 1x 5x 5x 1x 5x 5x 1x 10x | import { and, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import type { Context } from "hono";
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../../dal";
import { IllegalTransitionError } from "../../../dal/partners/referrals.dal";
import {
RRM_CONFIG_DEFAULTS,
RRM_CONFIG_KEYS,
RrmConfigDal,
} from "../../../dal/rrm/config.dal";
import * as schema from "../../../db/schema";
import { REFERRAL_STATES } from "../../../db/schema/enums";
import {
ConflictError,
NotFoundError,
ValidationError,
} from "../../../lib/errors";
import { logger } from "../../../lib/logger";
import {
notifyPartnerReferralUpdate,
type PartnerNotifyState,
} from "../../../lib/partners/notify";
import {
decideReferralState,
type ReferralState,
} from "../../../lib/partners/state";
import { handleError, success } from "../../../lib/response";
import type { Services } from "../../../services";
import { recordOutcome } from "../../../services/rrm/earnings.service";
/**
* The operator's view of referrals — the verification queue and the table.
*
* FR-A-1's bar is 60 seconds per item and "ops never opens WhatsApp to
* decide". That second half is the design constraint: the detail response
* carries the conversation excerpt, so the decision is made on one screen.
*
* ── What is NOT here ─────────────────────────────────────────────────────
* The five Qualified-v2 slots (FR-V-1). They do not exist anywhere in the
* codebase, and the requirement that they be captured as a verbatim consent
* quote conflicts with `referral_events.payload`'s prohibition on storing what
* the homeowner told us. Until that is reconciled, verification is an
* operator's judgement made against the real conversation — which is what this
* endpoint puts in front of them.
*/
type Env = {
Bindings: CloudflareBindings;
// `services` comes from `contextMiddleware`, which the parent admin router
// already applies — the handoff below creates the marketplace inquiry
// through the SAME `InquiryService.create` the public form uses, rather
// than inserting a row of its own that skips every check that path makes.
Variables: { dal: Dal; user: { id: string } | null; services: Services };
};
const referrals = new Hono<Env>();
/**
* This router's own error boundary.
*
* Every handler below THROWS its refusals — `AppError` for the domain ones,
* `ZodError` from the query and body schemas — and there is no `app.onError`
* anywhere in `src/index.ts`. Without this, Hono's default handler turns all
* of them into a bare `500 Internal Server Error`: an operator opening a stale
* link would be told the server broke rather than that the referral is gone,
* and a missing rejection reason would read as an outage.
*
* Registered on the sub-router rather than per-handler (the shape
* `earnings.routes.ts` uses) because Hono's `route()` copies a sub-app's error
* handler onto every route it mounts, so one boundary covers all five and
* still applies under `/api/admin/rrm/referrals`.
*/
referrals.onError((err, c) => {
// `handleError` has no ZodError branch, so schema failures are named here
// rather than being swallowed as an unexpected error.
if (err instanceof z.ZodError) {
const detail = err.issues
.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
.join("; ");
return handleError(c, new ValidationError(detail));
}
return handleError(c, err);
});
const REFERRALS = schema.referrals;
const PARTNERS = schema.partners;
/** Money is only ever moved by a named human (FR-F-8). */
function requireActorId(user: { id: string } | null): string {
if (!user?.id) {
throw new ValidationError("An authenticated operator is required");
}
return user.id;
}
/**
* Tell the partner, off the request path.
*
* `notifyPartnerReferralUpdate` never throws, but it does make a network call
* to Meta, and an operator clearing a queue should not wait on it. `waitUntil`
* is the same escape hatch the handoff notification below already uses, for
* the same reason: the state change has happened and is already answered; the
* message about it is not allowed to hold the answer up or to fail it.
*/
function tellPartner(
c: Context<Env>,
referral: { id: string; partnerId: string },
state: PartnerNotifyState,
): void {
c.executionCtx.waitUntil(
notifyPartnerReferralUpdate(
{ db: c.get("dal").db, env: c.env, dal: c.get("dal") },
{ partner: referral.partnerId, referral: referral.id, state },
),
);
}
/**
* The homeowner's thread, however their number happens to be stored.
*
* Tried with and without a leading '+': `wa_conversations` stores what
* arrived, and the two paths into it disagree — the webhook writes what Meta
* sent, the outbound path writes what it dialled. The same lookup
* inbox.routes.ts does.
*
* Shared by the detail screen and by verify: the screen shows the operator the
* conversation they decided on, and verify records WHICH conversation that
* was. If those two resolved differently, the evidence on the event would name
* a thread nobody read.
*/
async function findConversation(dal: Dal, contactPhoneNorm: string) {
return (
(await dal.waConversations.findByPhoneNumber(contactPhoneNorm)) ??
(await dal.waConversations.findByPhoneNumber(`+${contactPhoneNorm}`))
);
}
const listQuery = z.object({
status: z.string().optional(),
partnerId: z.string().optional(),
search: z.string().trim().max(120).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
/**
* The queue's default: everything waiting on us, oldest first.
*
* `engaged` is the state that needs a decision — the homeowner has messaged and
* nobody has judged it yet. Oldest first because a referral that has been
* waiting is the one most likely to have lost the homeowner's interest.
*/
const QUEUE_STATES: ReferralState[] = ["engaged"];
// GET /api/admin/rrm/referrals
referrals.get("/", async (c) => {
const dal = c.get("dal");
const q = listQuery.parse(
Object.fromEntries(new URL(c.req.url).searchParams),
);
const statuses = q.status
? q.status
.split(",")
.filter((s) => (REFERRAL_STATES as readonly string[]).includes(s))
: QUEUE_STATES;
const filters = [inArray(REFERRALS.status, statuses as ReferralState[])];
if (q.partnerId) filters.push(eq(REFERRALS.partnerId, q.partnerId));
if (q.search) {
const term = `%${q.search}%`;
const match = or(
like(REFERRALS.contactName, term),
like(REFERRALS.contactPhoneNorm, term),
like(REFERRALS.society, term),
like(REFERRALS.code, term.toUpperCase()),
);
Eif (match) filters.push(match);
}
// Queue order is oldest-first; every other view is newest-first. An
// operator working a queue wants the thing that has waited longest; an
// operator browsing a table wants what just happened.
const isQueue = statuses.length === 1 && statuses[0] === "engaged";
const rows = await dal.db
.select({
id: REFERRALS.id,
code: REFERRALS.code,
status: REFERRALS.status,
contactName: REFERRALS.contactName,
contactPhoneNorm: REFERRALS.contactPhoneNorm,
society: REFERRALS.society,
config: REFERRALS.config,
mode: REFERRALS.mode,
dateCreated: REFERRALS.dateCreated,
engagedAt: REFERRALS.engagedAt,
partnerId: REFERRALS.partnerId,
partnerName: PARTNERS.name,
partnerPhone: PARTNERS.phoneNorm,
})
.from(REFERRALS)
.leftJoin(PARTNERS, eq(PARTNERS.id, REFERRALS.partnerId))
.where(and(...filters))
.orderBy(isQueue ? REFERRALS.engagedAt : desc(REFERRALS.dateCreated))
.limit(q.limit)
.offset(q.offset);
const [{ total }] = await dal.db
.select({ total: sql<number>`count(*)` })
.from(REFERRALS)
.where(and(...filters));
return success(c, {
referrals: rows,
total: Number(total ?? 0),
limit: q.limit,
offset: q.offset,
});
});
// GET /api/admin/rrm/referrals/:id
//
// Everything needed to decide, on one screen (FR-A-1).
referrals.get("/:id", async (c) => {
const dal = c.get("dal");
const id = c.req.param("id");
const referral = await dal.referrals.findById(id);
if (!referral) throw new NotFoundError("Referral", id);
const [partner, events, earnings, verifyAmountPaise, convertAmountPaise] =
await Promise.all([
dal.partners.findById(referral.partnerId),
dal.referrals.eventsFor(id),
dal.db
.select()
.from(schema.rrmEarnings)
.where(eq(schema.rrmEarnings.referralId, id)),
// The LIVE rate, read from where the LEDGER reads it.
//
// This screen tells an operator what verifying will pay, at the
// moment they decide. It used to read `program_config.
// verify_amount_paise` while `recordOutcome({ kind: "validated" })`
// credits from `rrm_config.validated_amount_paise` — two separate
// rows, agreeing only because both happen to default to ₹100. Change
// either one and the screen quotes a figure the ledger will not pay,
// which is discovered by a partner disputing their balance and
// nowhere earlier. Same rule as the conversion rate below: quote the
// row that pays.
new RrmConfigDal(dal.db).getNumber(
RRM_CONFIG_KEYS.validatedAmountPaise,
RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.validatedAmountPaise],
),
// The conversion rate, read from where the LEDGER reads it.
//
// `recordOutcome({ kind: "converted" })` prices the ₹1,000 from
// `rrm_config.converted_amount_paise` (services/rrm/earnings.service.ts),
// so that is the row this screen has to quote. Reading it from anywhere
// else would show an operator one number and credit another.
new RrmConfigDal(dal.db).getNumber(
RRM_CONFIG_KEYS.convertedAmountPaise,
RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.convertedAmountPaise],
),
]);
// The conversation, so the decision does not require a second window.
const conversation = await findConversation(dal, referral.contactPhoneNorm);
const messages = conversation
? await dal.waMessages.findByConversationId(conversation.id, 0, 50)
: [];
// An earlier referral of the same number, if any — the duplicate decision
// needs to see what it is a duplicate OF.
const earlier = referral.firstReferrerReferralId
? await dal.referrals.findById(referral.firstReferrerReferralId)
: null;
return success(c, {
referral,
verifyAmountPaise,
convertAmountPaise,
partner: partner
? {
id: partner.id,
name: partner.name,
firmName: partner.firmName,
phoneNorm: partner.phoneNorm,
suspendedAt: partner.suspendedAt,
}
: null,
events,
earnings,
conversation: conversation
? {
id: conversation.id,
lastCustomerMessageAt: conversation.lastCustomerMessageAt,
}
: null,
messages,
firstReferral: earlier
? { id: earlier.id, code: earlier.code, dateCreated: earlier.dateCreated }
: null,
});
});
const rejectSchema = z.object({
reason: z.string().trim().min(3).max(300),
});
/**
* Verify's body — optional, unlike reject's.
*
* A rejection needs a reason because the partner reads it. A verification does
* not: the money is the message. The note is for US, and an operator who has
* nothing to add must not be made to type something to get past a form.
*/
const verifySchema = z.object({
note: z.string().trim().max(300).optional(),
});
/**
* Verify — the money moment.
*
* Two writes that must both land: the referral moves to `verified`, and the
* ₹100 accrues. The transition goes first: `recordOutcome` refuses to credit a
* referral whose status cannot earn, so crediting before the move would depend
* on the order the two happened to run in.
*
* The evidence is attached rather than asked for. The operator decided against
* the conversation the screen showed them, so the event records WHICH
* conversation that was and how far it had got — a payment defended months
* later by "which thread?" is not defended at all. The note is the one part
* they type, and it is optional.
*/
referrals.post("/:id/verify", async (c) => {
const dal = c.get("dal");
const actorId = requireActorId(c.get("user"));
const id = c.req.param("id");
// Parsed BEFORE anything is read or written, the shape reject uses: an
// over-long note is a 400 with the referral untouched. An absent body is
// `{}` and verifies exactly as it did before this note existed.
const body = verifySchema.parse(await c.req.json().catch(() => ({})));
const note = body.note || null;
const referral = await dal.referrals.findById(id);
if (!referral) throw new NotFoundError("Referral", id);
// The same thread the detail screen put in front of them, resolved the same
// way. `findByConversationId`'s first 50, also as on screen — the operator
// judged what they could see, and this records that, not a fuller history
// they never read.
const conversation = await findConversation(dal, referral.contactPhoneNorm);
const messages = conversation
? await dal.waMessages.findByConversationId(conversation.id, 0, 50)
: [];
// The homeowner's last word. `wamid` is Meta's id, so this is the one
// pointer that still resolves if our own rows are ever in doubt; rows
// without one (anything we wrote before Meta acknowledged it) are skipped
// rather than reported as "no evidence".
const lastInboundWamid =
messages.filter((m) => m.direction === "inbound" && m.wamid).at(-1)
?.wamid ?? null;
try {
await dal.referrals.transition({
referral,
to: "verified",
actorType: "ops",
actorId,
// A NON-timeline event type, deliberately.
//
// `recordOutcome` below writes its own `referral.verified` carrying
// the earning id and the amount, and THAT is the line the partner
// should read — "Confirmed — ₹100 approved". If this transition also
// wrote the default `referral.verified`, the partner's timeline would
// say it twice, once without the money.
//
// `lib/partners/timeline.ts` drops unmapped types on purpose, so this
// stays in the audit log (FR-ST-2 — every transition writes one) and
// out of the partner's view.
eventType: "referral.verified.transition",
// What the decision was made on. Not the conversation itself: the
// table comment on `referral_events.payload` forbids storing what the
// homeowner told us, so this is a pointer to the thread and a
// timestamp, plus whatever the operator chose to add.
payload: {
note,
conversationId: conversation?.id ?? null,
lastCustomerMessageAt: conversation?.lastCustomerMessageAt ?? null,
lastInboundWamid,
},
});
} catch (error) {
Iif (!(error instanceof IllegalTransitionError)) throw error;
// A referral that is ALREADY `verified` is not a refusal to report: the
// state machine calls the move "unchanged", and this is what an
// operator's double-click looks like. Fall through to `recordOutcome`,
// which answers `already_credited` — and, if the process died between
// the two writes last time, credits the ₹100 that never landed.
if (referral.status !== "verified") {
throw new ValidationError(
`Cannot verify a referral that is ${referral.status}`,
);
}
}
const outcome = await recordOutcome(
{ db: dal.db },
{ referralId: id, kind: "validated", actorId, notes: note },
);
// Gated on the CREDIT, not on the transition. An operator's double-click
// takes the `IllegalTransitionError` path above and falls through to
// `recordOutcome`, which answers `already_credited` — so notifying on the
// transition alone would send the partner "Confirmed — ₹100 approved"
// once per click.
if (outcome.ok) tellPartner(c, referral, "verified");
return success(c, {
referral: await dal.referrals.findById(id),
credited: outcome.ok,
// `already_credited` is the common case on a double-click and is not an
// error — the operator sees the state, not a failure.
creditReason: outcome.ok ? null : outcome.reason,
});
});
// Reject, with a reason the partner will be shown (FR-P-5.4).
referrals.post("/:id/reject", async (c) => {
const dal = c.get("dal");
const actorId = requireActorId(c.get("user"));
const id = c.req.param("id");
const body = rejectSchema.parse(await c.req.json().catch(() => ({})));
const referral = await dal.referrals.findById(id);
if (!referral) throw new NotFoundError("Referral", id);
try {
await dal.referrals.transition({
referral,
to: "rejected",
actorType: "ops",
actorId,
// The reason is shown to the partner, so it is stored on the row and
// not only in the event — the dashboard reads the row.
payload: { reason: body.reason },
});
await dal.db
.update(REFERRALS)
.set({ rejectedReason: body.reason, dateUpdated: new Date() })
.where(eq(REFERRALS.id, id));
} catch (error) {
Eif (error instanceof IllegalTransitionError) {
throw new ValidationError(
`Cannot reject a referral that is ${referral.status}`,
);
}
throw error;
}
tellPartner(c, referral, "rejected");
return success(c, { referral: await dal.referrals.findById(id) });
});
// Mark as a duplicate an operator spotted that the automatic check did not.
referrals.post("/:id/duplicate", async (c) => {
const dal = c.get("dal");
const actorId = requireActorId(c.get("user"));
const id = c.req.param("id");
const referral = await dal.referrals.findById(id);
if (!referral) throw new NotFoundError("Referral", id);
try {
await dal.referrals.transition({
referral,
to: "duplicate",
actorType: "ops",
actorId,
});
} catch (error) {
Eif (error instanceof IllegalTransitionError) {
throw new ValidationError(
`Cannot mark a referral that is ${referral.status} as duplicate`,
);
}
throw error;
}
return success(c, { referral: await dal.referrals.findById(id) });
});
/**
* The rest of the ladder: `verified -> contacted -> quoted`, and `lost`.
*
* Until this existed nothing in the codebase wrote any of the three. The
* consequence was not cosmetic: `POST /referrals/:id/outcome` refuses to
* credit a conversion unless the referral is `quoted` ("mark it quoted first"),
* so the ₹1,000 was unreachable — and the referred homeowner never became a
* lead at all, because the only contact the programme had with `inquiries` was
* a read-only dedupe SELECT.
*
* ── Why the handoff is where the inquiry is created ──────────────────────
* `inquiries.pro_id` is NOT NULL with an FK to `pros`, so an inquiry cannot
* exist before a pro is chosen. The pro is chosen by an operator, on the
* screen, at the handoff — auto-matching is a later decision (owner, 3 Sep) —
* which makes `verified -> contacted` the first moment an inquiry CAN be
* written. That is why `to: "contacted"` needs a `proId` and the other two do
* not.
*/
const advanceSchema = z
.object({
to: z.enum(["contacted", "quoted", "lost"]),
proId: z.string().trim().min(1).optional(),
reason: z.string().trim().min(3).max(300).optional(),
})
.superRefine((body, ctx) => {
if (body.to === "contacted" && !body.proId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["proId"],
message: "Choose the professional this referral is going to",
});
}
if (body.to === "lost" && !body.reason) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["reason"],
message: "Say why this one is not going ahead",
});
}
});
referrals.post("/:id/advance", async (c) => {
const dal = c.get("dal");
const actorId = requireActorId(c.get("user"));
const id = c.req.param("id");
const body = advanceSchema.parse(await c.req.json().catch(() => ({})));
const referral = await dal.referrals.findById(id);
if (!referral) throw new NotFoundError("Referral", id);
// Asked BEFORE anything is created.
//
// `transition` would refuse this move anyway, but by then the inquiry
// below already exists — a real lead in a pro's CRM, for a referral that
// never moved. So the state machine is consulted first and the handler
// stops here, with the same answer it would have given later.
if (!decideReferralState(referral.status as ReferralState, body.to).change) {
throw new ConflictError(
`Cannot move a referral that is '${referral.status}' to '${body.to}'`,
);
}
// The referred homeowner becomes an actual lead.
//
// Through `InquiryService.create`, not a direct insert: that is where the
// phone is parsed to E.164, the pro is checked to exist and the per-phone
// active-inquiry cap is enforced. Its `ValidationError`/`NotFoundError`
// are left to the router's error boundary, which answers with THEIR
// message — an operator told "that pro was not found" or "3 active
// inquiries" can act; "something went wrong" leaves them clicking.
//
// `contactPhoneNorm` is the stored form ("919000000011", no plus);
// `parseIndianPhone` reads it as-is.
let inquiryId: string | null = null;
if (body.to === "contacted") {
const inquiry = await c.get("services").inquiry.create({
proId: body.proId as string,
type: "referral",
// What the PARTNER supplied — the only name and number we have that
// the homeowner did not give us in confidence.
customerName: referral.contactName,
customerPhone: referral.contactPhoneNorm,
customerLocation: referral.society ?? undefined,
requirement: referral.config ?? undefined,
notes: `Channel partner referral ${referral.code}`,
});
inquiryId = String(inquiry.id);
}
try {
await dal.referrals.transition({
referral,
to: body.to,
actorType: "ops",
actorId,
payload: {
...(inquiryId ? { inquiryId } : {}),
...(body.reason ? { reason: body.reason } : {}),
},
});
} catch (error) {
if (!(error instanceof IllegalTransitionError)) throw error;
// Only reachable if someone else moved the row between the check above
// and this write. D1 has no transactions, so an inquiry created a few
// lines up survives — one stray lead is recoverable, and the
// alternative (transition first) leaves a referral claiming a designer
// has it when no inquiry was ever written.
throw new ConflictError(
`Cannot move a referral that is '${referral.status}' to '${body.to}'`,
);
}
// The transition landed, so the partner is told what it was — `contacted`
// and `quoted` both read as "With a professional", `lost` as "Not
// proceeding". Only ever after the write, never before it.
tellPartner(c, referral, body.to);
if (inquiryId) {
// FR-A-6: the link back. Stored on the row, not only in the event, so
// the screen can show it without reducing the log.
//
// Swallowed, because by here the introduction HAS happened — the row is
// `contacted` and the inquiry exists, with its id on the event. Failing
// the request over the cosmetic write would send the operator back to a
// button that now answers 409, with no link to the lead they did make.
try {
await dal.db
.update(REFERRALS)
.set({ inquiryId, dateUpdated: new Date() })
.where(eq(REFERRALS.id, id));
} catch (err) {
logger.error("[RRM] handoff inquiry link not stamped:", err);
}
// The pro gets this lead the way the site gives them one: a CRM card
// first, then the notification that deep-links to it. `inquiries` is
// not what the CRM board reads — `leads` is — so without this the
// homeowner would exist only as a row no pro-facing screen lists.
//
// The whole chain is off the request path and each step is swallowed,
// exactly as `POST /marketplace/inquiries` does it: the handoff has
// already happened, and failing the request now would tell the operator
// the introduction did not, so they would make it twice.
//
// ponytail: in-app + push only. `NewInquiryHandler` (email + WhatsApp
// to every recipient) is the upgrade if pros miss these.
const services = c.get("services");
const proId = body.proId as string;
const inquiryNumber = Number(inquiryId);
c.executionCtx.waitUntil(
(async () => {
let lead: Awaited<ReturnType<typeof services.leads.createFromInquiry>> =
null;
try {
await services.pipelineStages.ensureInitialized(proId);
} catch (err) {
logger.error("[RRM] handoff pipeline init failed:", err);
}
try {
lead = await services.leads.createFromInquiry(proId, {
customerName: referral.contactName,
customerPhone: referral.contactPhoneNorm,
customerLocation: referral.society ?? undefined,
requirement: referral.config ?? undefined,
inquiryId: inquiryNumber,
});
} catch (err) {
logger.error("[RRM] handoff CRM lead creation failed:", err);
}
try {
await services.notification.notify({
proId,
eventType: "new_inquiry",
title: `New referral: ${referral.contactName}`,
body: referral.society
? `Introduced by a channel partner · ${referral.society}`
: "Introduced by a channel partner",
data: {
url: lead ? `/crm/leads/${lead.id}` : "/crm",
...(lead && { leadId: lead.id }),
inquiryId: inquiryNumber,
},
});
} catch (err) {
logger.error("[RRM] handoff notification failed:", err);
}
})(),
);
}
return success(c, {
referral: await dal.referrals.findById(id),
inquiryId,
});
});
export default referrals;
|