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 694 695 696 | 1x 1x 1x 1x 1x 504x 504x 5x 1x 73x 5x 5x 59x 59x 59x 25x 75x 25x 2x 23x 34x 28x 28x 6x 59x 48x 46x 21x 63x 21x 18x 40x 40x 46x 7x 7x 39x 19x 19x 15x 15x 2x 39x 39x 48x 48x 2x 46x 48x 48x 48x 5x 5x 41x 41x 2x 2x 39x 39x 39x 48x 39x 39x 39x 12x 12x 12x 27x 27x 27x 39x 39x 5x 39x 17x 17x 17x 17x 39x 12x 12x 12x 12x 12x 12x 8x 8x 8x 8x 12x 27x 27x 27x 26x 1x 27x 27x | /**
* Landing-page form submissions (F-21 §10.5, the organic path).
*
* The form on go.interioring.com/partners asks an agent for their name and
* WhatsApp number, posts it here, and only then opens WhatsApp. This module
* decides what that submission does to the CRM and what the browser should
* open next.
*
* Two rules shape everything here:
*
* 1. THE FORM NEVER BLOCKS A HUMAN. Every well-formed submission gets a
* `whatsappUrl` back — a typo in the phone, a number the import would have
* refused, a prospect that already exists — because the whole point of
* the page is to get an agent into a WhatsApp thread, and the inbound
* message then carries their real number whatever they typed.
* 2. THE SAME DEDUPE AS THE IMPORT. `resolveOrRejectProspectByPhone` is the
* one front door for "may this number become a prospect?". Without it,
* the exact person the CSV import refused could walk in through the form.
* The form differs only in what it does with a rejection: it KEEPS the
* submission for operator review rather than dropping it.
*/
import {
buildWhatsAppUrl,
normalizeToE164,
} from "@interioring/utils/validation/phone";
import { asc, eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { RrmEventsDal } from "../../dal/rrm/events.dal";
import type { RrmActorContext } from "../../dal/rrm/prospects.dal";
import { RrmProspectsDal } from "../../dal/rrm/prospects.dal";
import * as schema from "../../db/schema";
import type { RRM_CTAS, RRM_LOCALES } from "../../db/schema/enums";
import type {
NewRrmProspect,
NewRrmSubmission,
NewRrmTask,
RrmProspect,
} from "../../db/schema/rrm";
import { generateId } from "../../lib/ids";
import { logger } from "../../lib/logger";
import type { ConsentEvidence } from "../../lib/rrm/consent";
import { mintVisitToken } from "../../lib/rrm/token";
import {
type ProspectRejectReason,
resolveOrRejectProspectByPhone,
toProspectPhoneNorm,
} from "./prospect-resolver.service";
const PROSPECTS = schema.rrmProspects;
const SUBMISSIONS = schema.rrmSubmissions;
const TASKS = schema.rrmTasks;
export type RrmCta = (typeof RRM_CTAS)[number];
export type RrmLocale = (typeof RRM_LOCALES)[number];
/**
* The partner-program WhatsApp number, digits only, matching the
* `phone_norm` convention. Every `whatsappUrl` this module returns opens a
* chat with it.
*/
export const PARTNER_WHATSAPP_NUMBER = "918897226999";
/**
* `rrm_submissions.prospect_id` for a submission the resolver refused.
*
* The column is NOT NULL and the row has nothing real to point at, so every
* rejected submission carries this one value. It is exported and named
* because it is the operator's review query — `WHERE prospect_id =
* 'rejected'` — and that only works if there is exactly one value to look
* for. It can never collide with a real id: those are UUIDs.
*/
export const REJECTED_SUBMISSION_PROSPECT_ID = "rejected";
export type RecordSubmissionInput = {
cta: RrmCta;
/** The agent's own name, as typed. */
name: string;
/** The agent's WhatsApp number, any Indian format. */
phone: string;
firmName?: string;
/** `have_contact` only: the referred contact. */
contactName?: string;
contactPhone?: string;
contactProject?: string;
/**
* The realtor's own assertion that the person they are referring knows we
* may contact them (review D3) — not the contact's own consent, which is
* obtained on the first call and recorded there.
*/
contactConsentAsserted?: boolean;
lang: RrmLocale;
/** The notice version live on the page, e.g. `partners-v1-2026-08`. */
consentVersion: string;
/** Honeypot. Non-empty means a bot filled a field no human can see. */
hp?: string;
/** SHA-256(ip + secret), computed upstream. Never the raw IP. */
ipHash?: string;
userAgent?: string;
/**
* The `?src=` slug the link carried (review §1.3), e.g. `grp-madhapur-01`.
* Already re-validated by the caller, but normalised again here — a client
* can always be made to send something else.
*/
sourceDetail?: string;
};
export type RecordSubmissionContext = {
db: DrizzleD1Database<typeof schema>;
/**
* `RRM_TOKEN_SECRET`. Optional on purpose: a public form must not 503 over
* a missing secret. A prospect created without one has no visit link
* until the token is minted later, and that is logged, not fatal.
*/
tokenSecret?: string | null;
/** Injectable clock. One submission shares one instant on purpose. */
now?: Date;
};
export type SubmissionOutcome = "created" | "existing" | "rejected";
export type RecordSubmissionResult = {
submissionId: string;
outcome: SubmissionOutcome;
reason?: ProspectRejectReason;
/** The `wa.me` link the browser opens next, prefill built server-side. */
whatsappUrl: string;
/**
* "Already referred by X on Y — first submission wins." (review §2.2),
* present only when this referral names a contact an earlier submission
* already named. Route-only: it names the earlier submitter, so it must
* never reach the current submitter's own browser — see
* `rrm-submissions.routes.ts`, which strips it before returning the
* response and uses it only to build the operator notification.
*/
duplicateNote?: string;
};
/** Collapses the whitespace people paste into a text field. */
function tidy(value: string | undefined): string | undefined {
const cleaned = value?.replace(/\s+/g, " ").trim();
return cleaned ? cleaned : undefined;
}
/**
* `rrm_submissions.phone_norm` for a phone that never parsed into anything
* canonical (review §2.1). NOT NULL and not comparable to a real
* `phone_norm`, so it exists purely so the operator can see what was typed
* rather than nothing at all — digits only, capped to the column's shape.
*/
function rawPhoneDigits(raw: string): string {
return raw.replace(/\D/g, "").slice(0, 32);
}
/**
* A plain slug: letters, digits, underscore, hyphen, 1-64 characters. Mirrors
* `apps/go/src/lib/attribution.ts` (`SOURCE_DETAIL_PATTERN`) — the same shape
* is re-validated here because a client can always be made to send something
* else, and this becomes `rrm_submissions.source_detail`.
*/
const SOURCE_DETAIL_PATTERN = /^[a-z0-9_-]{1,64}$/i;
/**
* Trimmed, lowercased value when it matches {@link SOURCE_DETAIL_PATTERN},
* else `null` — dropped, never rejected (review §1.3): a malformed `?src=`
* slug must not cost a real agent their WhatsApp link.
*/
function normalizeSourceDetail(raw: string | undefined): string | null {
if (!raw) return null;
const trimmed = raw.trim();
return SOURCE_DETAIL_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
}
/**
* The prefilled chat message.
*
* Built here rather than in the browser so the agent's first inbound message
* confirms exactly what was submitted — the WhatsApp thread then carries the
* same facts as the row, and the operator reads one, not two. Kept short: it
* is a chat message, and a wall of text gets deleted before sending.
*/
export function buildPrefillText(input: RecordSubmissionInput): string {
const name = tidy(input.name) ?? "an agent";
// Only `te` (Telugu script) gets a translated prefill. `te-Latn` and `en`
// fall through to the English copy below (review §2.4).
const isTelugu = input.lang === "te";
if (input.cta === "have_contact") {
const details = [
tidy(input.contactName),
tidy(input.contactPhone),
tidy(input.contactProject),
].filter((part): part is string => part !== undefined);
if (isTelugu) {
// UNREVIEWED TELUGU — see form.te.ts header
return details.length > 0
? `హాయ్, నేను ${name}. ఒక కాంటాక్ట్ రిఫర్ చేయాలి: ${details.join(", ")}.`
: `హాయ్, నేను ${name}. ఒక కాంటాక్ట్ రిఫర్ చేయాలి.`;
}
return details.length > 0
? `Hi, I'm ${name}. I have a contact to refer: ${details.join(", ")}.`
: `Hi, I'm ${name}. I have a contact to refer.`;
}
if (isTelugu) {
// UNREVIEWED TELUGU — see form.te.ts header
const firm = tidy(input.firmName) ?? "హైదరాబాద్";
return `హాయ్, నేను ${name}, ${firm}. పార్ట్నర్ ప్రోగ్రామ్లో చేరాలనుకుంటున్నాను.`;
}
const firm = tidy(input.firmName) ?? "Hyderabad";
return `Hi, I'm ${name} from ${firm}. I'd like to join the partner program.`;
}
function whatsappUrlFor(input: RecordSubmissionInput): string {
// The partner number is a constant valid mobile, so `buildWhatsAppUrl`
// cannot return null for it; the fallback only exists to keep the return
// type honest without a cast.
return (
buildWhatsAppUrl(PARTNER_WHATSAPP_NUMBER, buildPrefillText(input)) ??
`https://wa.me/${PARTNER_WHATSAPP_NUMBER}`
);
}
/**
* What the operator reads about the referred contact.
*
* `rrm_submissions.contact_note` is the ONLY evidence the admin task queue
* shows on a `confirm_intent` task (see `contactNotes()` in
* routes/admin/rrm/tasks.routes.ts). The structured columns are the copy a
* machine acts on; this is the copy a human judges, so it carries the phone
* AS TYPED — which is also how a number that failed to normalise survives
* instead of being dropped.
*/
function referralNote(input: RecordSubmissionInput): string | undefined {
if (input.cta !== "have_contact") return undefined;
const parts = [
tidy(input.contactName),
tidy(input.contactPhone),
tidy(input.contactProject),
].filter((part): part is string => part !== undefined);
return parts.length > 0 ? `Referred contact: ${parts.join(", ")}` : undefined;
}
/** A referral is actionable only when there is someone to reach. */
function hasReferredContact(input: RecordSubmissionInput): boolean {
return (
tidy(input.contactName) !== undefined ||
tidy(input.contactPhone) !== undefined
);
}
/**
* The referred contact's number, digits only. `normalizeToE164` rather than
* `toProspectPhoneNorm`: a homeowner's landline is a perfectly good referral
* even though it could never be a WhatsApp prospect.
*/
function contactPhoneNorm(raw: string | undefined): string | null {
const e164 = normalizeToE164(raw);
return e164 ? e164.slice(1) : null;
}
function submissionRow(
input: RecordSubmissionInput,
fields: {
id: string;
prospectId: string;
phoneNorm: string;
contactNote: string | null;
now: Date;
/**
* Set only on the main flow, when this referral names a contact an
* earlier submission already named (review §2.2). Never set on either
* rejection path — there is no earlier-submission comparison to make
* for a row that never reaches a prospect.
*/
duplicateOfSubmissionId?: string | null;
},
): NewRrmSubmission {
return {
id: fields.id,
prospectId: fields.prospectId,
cta: input.cta,
name: tidy(input.name) ?? null,
firmName: tidy(input.firmName) ?? null,
phoneNorm: fields.phoneNorm,
contactNote: fields.contactNote,
contactName:
input.cta === "have_contact" ? (tidy(input.contactName) ?? null) : null,
contactPhoneNorm:
input.cta === "have_contact"
? contactPhoneNorm(tidy(input.contactPhone))
: null,
contactProject:
input.cta === "have_contact"
? (tidy(input.contactProject) ?? null)
: null,
contactConsentAsserted: input.contactConsentAsserted ?? false,
sourceDetail: normalizeSourceDetail(input.sourceDetail),
duplicateOfSubmissionId: fields.duplicateOfSubmissionId ?? null,
consentVersion: input.consentVersion,
ipHash: input.ipHash ?? null,
userAgent: input.userAgent ?? null,
locale: input.lang,
dateCreated: fields.now,
};
}
/**
* Writes the one row a rejected submission gets: no prospect, a synthetic
* note saying why, everything else as supplied (review §2.1). Shared by both
* rejection paths — an unnormalisable phone and a phone the resolver refuses
* — so the row shape is identical whichever produced it.
*/
async function writeRejectedSubmission(
ctx: RecordSubmissionContext,
input: RecordSubmissionInput,
fields: { id: string; phoneNorm: string; now: Date; syntheticNote: string },
): Promise<void> {
const referral = referralNote(input);
await ctx.db.insert(SUBMISSIONS).values(
submissionRow(input, {
id: fields.id,
prospectId: REJECTED_SUBMISSION_PROSPECT_ID,
phoneNorm: fields.phoneNorm,
contactNote: referral
? `${fields.syntheticNote} ${referral}`
: fields.syntheticNote,
now: fields.now,
}),
);
}
/**
* The earliest prior `rrm_submissions` row naming the same referred contact
* (review §2.2, "first submission wins"). A direct query rather than a new
* DAL method: this is the one place `recordSubmission` reads its own table,
* and it has to run before the row it might flag even exists.
*/
async function findDuplicateReferral(
db: DrizzleD1Database<typeof schema>,
input: RecordSubmissionInput,
): Promise<{ submissionId: string; line: string } | null> {
if (input.cta !== "have_contact") return null;
const contactNorm = contactPhoneNorm(tidy(input.contactPhone));
if (!contactNorm) return null;
const [prior] = await db
.select({
id: SUBMISSIONS.id,
name: SUBMISSIONS.name,
dateCreated: SUBMISSIONS.dateCreated,
})
.from(SUBMISSIONS)
.where(eq(SUBMISSIONS.contactPhoneNorm, contactNorm))
.orderBy(asc(SUBMISSIONS.dateCreated))
.limit(1);
if (!prior) return null;
const who = tidy(prior.name ?? undefined) ?? "another agent";
const dateStr = prior.dateCreated.toISOString().slice(0, 10);
return {
submissionId: prior.id,
line: `Already referred by ${who} on ${dateStr} — first submission wins.`,
};
}
/**
* Records one landing-page submission and returns what the browser opens next.
*
* Writes that touch two tables go through `db.batch` so D1 applies them
* together or not at all. Three batches at most — attach/create, stage, task
* — because `setStage` reads the row it moves and owns its own batch. A crash
* between them leaves a submission that is recorded but not staged, which an
* operator can see on the timeline; the reverse (a stage with no submission
* behind it) cannot happen.
*/
export async function recordSubmission(
input: RecordSubmissionInput,
ctx: RecordSubmissionContext,
): Promise<RecordSubmissionResult> {
const whatsappUrl = whatsappUrlFor(input);
// A filled honeypot is a bot. It gets the same shape a human gets — a real-
// looking id, `created`, a working link — and nothing is written. A 4xx or
// a distinguishable body would tell the bot which field to stop filling.
if (input.hp) {
return { submissionId: generateId(), outcome: "created", whatsappUrl };
}
const now = ctx.now ?? new Date();
const submissionId = generateId();
// `phone_norm` is NOT NULL and a number that does not parse has no
// canonical form to store, so an invalid phone is the one rejection that
// writes nothing. The link still opens; the agent's inbound message then
// carries the number they meant to type.
const phoneNorm = toProspectPhoneNorm(input.phone);
if (!phoneNorm) {
// Kept, not dropped (review §2.1): the operator can still recover the
// lead from the WhatsApp thread the browser is about to open.
await writeRejectedSubmission(ctx, input, {
id: submissionId,
phoneNorm: rawPhoneDigits(input.phone),
now,
syntheticNote: "[Rejected at submission: invalid_phone]",
});
return {
submissionId,
outcome: "rejected",
reason: "invalid_phone",
whatsappUrl,
};
}
const resolved = await resolveOrRejectProspectByPhone(input.phone, {
db: ctx.db,
});
if (resolved.outcome === "reject") {
// Suppressed, a registered pro, a pro's WhatsApp line, a homeowner: the
// import reports these and moves on. The form KEEPS them. Someone typed
// their number and pressed send — a pro's colleague, an opt-out who
// changed their mind — and a human should see that, so the row is
// written with no prospect and a note saying why it was refused.
await writeRejectedSubmission(ctx, input, {
id: submissionId,
phoneNorm,
now,
syntheticNote: `[Rejected at submission: ${resolved.reason}${
resolved.detail ? ` (${resolved.detail})` : ""
}]`,
});
return {
submissionId,
outcome: "rejected",
reason: resolved.reason,
whatsappUrl,
};
}
const events = new RrmEventsDal(ctx.db);
const prospects = new RrmProspectsDal(ctx.db);
const contactNote = referralNote(input) ?? null;
// §2.2: "first submission wins". Read before either batch below so the
// flag can land on THIS row's own insert rather than a follow-up update.
const duplicate = await findDuplicateReferral(ctx.db, input);
// The form is the person acting, on the web. Every row this writes is
// attributed that way so the timeline reads "they submitted", not "we did".
const actor: RrmActorContext = {
actorType: "prospect",
channel: "web",
reason: `form_submitted:${input.cta}`,
};
const consentEvidence: ConsentEvidence = {
formSubmissionId: submissionId,
capturedAt: now.toISOString(),
};
let prospectId: string;
let outcome: SubmissionOutcome;
if (resolved.outcome === "existing") {
prospectId = resolved.prospect.id;
outcome = "existing";
await ctx.db.batch([
ctx.db
.update(PROSPECTS)
.set(
existingProspectPatch(resolved.prospect, input, consentEvidence, now),
)
.where(eq(PROSPECTS.id, prospectId)),
ctx.db.insert(SUBMISSIONS).values(
submissionRow(input, {
id: submissionId,
prospectId,
phoneNorm: resolved.prospect.phoneNorm,
contactNote,
now,
duplicateOfSubmissionId: duplicate?.submissionId ?? null,
}),
),
events.buildEventStatement({
prospectId,
type: "form_submitted",
actorType: actor.actorType,
channel: actor.channel,
// The id, never the phone: this table is append-only and outlives
// an erasure request.
payload: { cta: input.cta, submissionId },
occurredAt: now,
}),
]);
} else {
prospectId = generateId();
outcome = "created";
// The row is built here rather than through `RrmProspectsDal.create()`
// because the visit token is HMAC(prospectId) and has to be minted
// before the insert, and because the prospect and its submission must
// land in one batch — a prospect whose consent evidence points at a
// submission that was never written is worse than no prospect.
await ctx.db.batch([
ctx.db.insert(PROSPECTS).values(
await newProspectRow({
id: prospectId,
phoneNorm: resolved.phoneNorm,
input,
consentEvidence,
tokenSecret: ctx.tokenSecret,
now,
}),
),
ctx.db.insert(SUBMISSIONS).values(
submissionRow(input, {
id: submissionId,
prospectId,
phoneNorm: resolved.phoneNorm,
contactNote,
now,
duplicateOfSubmissionId: duplicate?.submissionId ?? null,
}),
),
events.buildEventStatement({
prospectId,
type: "form_submitted",
actorType: actor.actorType,
channel: actor.channel,
payload: { cta: input.cta, submissionId },
occurredAt: now,
}),
]);
}
// Both CTAs mean "interested" — the person came to us. `setStage` applies
// the forward-only rule and writes the `stage_changed` event with the row,
// so a prospect already further along is left where they are.
//
// `viaInbound` is deliberately NOT set. It would pull a `do_not_contact`
// prospect back to `interested`, but `setStage` never clears the
// `do_not_contact` FLAG the send gate reads, and a row that says
// "interested" while every send is refused is a half-state nobody can
// reason about. A human lifts DNC; the submission is on the timeline
// for them to find.
const staged = await prospects.setStage(prospectId, "interested", actor);
if (!staged.changed) {
logger.info(
`[RRM submission] stage left at ${staged.prospect.stage} (${staged.reason}) for prospect ${prospectId}`,
);
}
// A referral with someone to reach raises a task for an operator to judge.
// NEVER `intent_stated` directly: that stage is the program's success
// metric and it is operator-confirmed only, so "maybe later" typed into a
// form cannot count itself.
//
// `contactNote` is always set when there is someone to reach — the note is
// built from the same fields — so the gate on it is what lets the task carry
// the note without a fallback that could never be read.
if (contactNote !== null && hasReferredContact(input)) {
const taskId = generateId();
// §2.2: when duplicate, the note STARTS with who referred this contact
// first and when, ahead of the same text the task always carried.
const notesPrefix = duplicate ? `${duplicate.line} ` : "";
const task: NewRrmTask = {
id: taskId,
prospectId,
type: "confirm_intent",
// Due now: a referral goes cold in days, and the queue sorts by due
// date, so "now" is what puts it at the top.
dueAt: now,
state: "open",
notes: `${notesPrefix}${contactNote} (submission ${submissionId})`,
dateCreated: now,
};
await ctx.db.batch([
ctx.db.insert(TASKS).values(task),
events.buildEventStatement({
prospectId,
type: "confirm_intent_requested",
actorType: actor.actorType,
channel: actor.channel,
// Ids only. The contact's name and number — and the earlier
// submitter's name — are personal data and do not belong in an
// append-only table.
payload: duplicate
? {
taskId,
submissionId,
duplicateOfSubmissionId: duplicate.submissionId,
}
: { taskId, submissionId },
occurredAt: now,
}),
]);
}
// The key is omitted rather than set to `undefined` when there is no
// duplicate: a bot and a genuine `interest` submission must return
// identically-shaped objects (see the honeypot test), and an explicit
// `duplicateNote: undefined` key would show up in `Object.keys()` even
// though `toEqual` cannot tell it apart from a missing one.
return duplicate
? { submissionId, outcome, whatsappUrl, duplicateNote: duplicate.line }
: { submissionId, outcome, whatsappUrl };
}
/**
* What a form submission may change on a prospect that already exists.
*
* Fills blanks and strengthens consent; never overwrites. An imported row has
* `name = null` by design (a listing title is not a person's name) and the
* form is the first time they told us their name themselves. Consent moves
* only from `none`: `user_initiated` and `stated_in_chat` are stronger
* evidence and replacing them would destroy what s.6(10) asks us to keep.
* Locale always updates — they chose it — and `localeSource = "stated"` is
* what stops a later detection from flipping it back.
*/
function existingProspectPatch(
current: RrmProspect,
input: RecordSubmissionInput,
consentEvidence: ConsentEvidence,
now: Date,
): Partial<NewRrmProspect> {
const patch: Partial<NewRrmProspect> = {
locale: input.lang,
localeSource: "stated",
dateUpdated: now,
};
const name = tidy(input.name);
if (current.name === null && name) patch.name = name;
const firmName = tidy(input.firmName);
if (current.firmName === null && firmName) patch.firmName = firmName;
if (current.consentBasis === "none") {
patch.consentBasis = "form_submitted";
patch.consentVersion = input.consentVersion;
patch.consentAt = now;
patch.consentEvidence = consentEvidence;
}
return patch;
}
async function newProspectRow(args: {
id: string;
phoneNorm: string;
input: RecordSubmissionInput;
consentEvidence: ConsentEvidence;
tokenSecret: string | null | undefined;
now: Date;
}): Promise<NewRrmProspect> {
const { id, phoneNorm, input, consentEvidence, tokenSecret, now } = args;
let visitToken: string | null = null;
if (tokenSecret) {
visitToken = await mintVisitToken(id, tokenSecret);
} else {
// The import refuses to run without the secret; the form cannot afford
// to. A prospect with no link is recoverable by minting one later; a
// person bounced off a public form over our configuration is not.
logger.warn(
`[RRM submission] RRM_TOKEN_SECRET unset — prospect ${id} created without a visit token`,
);
}
// §1.3: first touch only — this only runs on the CREATE path, so there is
// never an existing value to weigh overwriting. Stored under `sourceLabel`
// rather than a dedicated key: the column's JSON shape (schema/rrm.ts) was
// built for the CSV import's provenance and has no slug-shaped field, and
// it is off-limits here. `sourceLabel` is the one property whose meaning —
// "where did you get my number, in one click" — already covers this.
const sourceDetail = normalizeSourceDetail(input.sourceDetail);
return {
id,
phoneNorm,
source: "organic",
name: tidy(input.name) ?? null,
firmName: tidy(input.firmName) ?? null,
sourceDetail: sourceDetail ? { sourceLabel: sourceDetail } : undefined,
// Created at `sourced` and moved by `setStage` straight after, so the
// `interested` entry gets its `stage_changed` event and `interested_at`
// stamp like every other stage move. Writing `interested` here would
// skip both and the funnel would never see this person arrive.
stage: "sourced",
stageChangedAt: now,
locale: input.lang,
localeSource: "stated",
consentBasis: "form_submitted",
consentVersion: input.consentVersion,
consentAt: now,
consentEvidence,
visitToken,
dateCreated: now,
dateUpdated: now,
};
}
|