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 | import { Hono } from "hono";
import type { Dal } from "../../dal";
import { logger } from "../../lib/logger";
import { normaliseReferralCode } from "../../lib/partners/code";
import { success } from "../../lib/response";
/**
* Resolving a referral code for the public homeowner page.
*
* Called by `apps/go` for `/e/<code>` and `/r/<code>`. It sits under the
* internal router, so the `X-Internal-API-Key` guard and `contextMiddleware`
* have already run — nothing here re-applies either.
*
* ── What may cross this wire ─────────────────────────────────────────────
* The page is PUBLIC: anyone holding the code sees it. So the response carries
* only what the page renders and what the PARTNER supplied — their first name,
* the society, the home size. Never `contactPhoneNorm`, never `partnerId`,
* never anything we later learn from the homeowner (FR-P-5.3).
*
* Not even the homeowner's own name: the SRS's own example line is "Suresh has
* arranged a free interior estimate for your 3BHK at My Home Apas", and adding
* a name would mean a guessed code reveals who lives where.
*/
type Env = {
Bindings: CloudflareBindings;
Variables: { dal: Dal };
};
const referralLinks = new Hono<Env>();
/** What the public page is allowed to render. */
type ResolvedLink = {
kind: "referral" | "share";
/** First name only — enough to say who arranged it, not who they are. */
partnerFirstName: string | null;
society: string | null;
config: string | null;
/** Present for a referral, absent for an open share link. */
code: string;
};
function firstName(name: string | null | undefined): string | null {
const first = name?.trim().split(/\s+/)[0];
return first || null;
}
// GET /api/internal/referral-links/:code
//
// 404 for unknown, expired, suspended — anything that is not a live link. A
// distinguishable error would confirm which codes exist, and the code space is
// the only thing protecting a homeowner's page from being enumerated.
referralLinks.get("/:code", async (c) => {
const dal = c.get("dal");
const code = normaliseReferralCode(c.req.param("code"));
if (!code) return c.json({ success: false, error: "not_found" }, 404);
const referral = await dal.referrals.findByCode(code);
if (referral) {
// A referral that never earns and never proceeds should not keep
// rendering a personalised invitation. FR-H-6: neutral page, no shaming.
const dead = ["expired", "rejected", "opted_out"].includes(referral.status);
if (dead) return c.json({ success: false, error: "not_found" }, 404);
const partner = await dal.partners.findById(referral.partnerId);
if (!partner || partner.suspendedAt) {
return c.json({ success: false, error: "not_found" }, 404);
}
// Fire-and-forget: the page must render whether or not we managed to
// record the visit. FR-H-2 wants the number, not at the cost of the page.
await logOpen(dal, referral.id, c);
const payload: ResolvedLink = {
kind: "referral",
partnerFirstName: firstName(partner.name),
society: referral.society,
config: referral.config,
code: referral.code,
};
return success(c, payload);
}
// Not a referral — an open share link (`/r/<code>`), which identifies the
// partner rather than a homeowner. No referral exists yet; one is minted
// when they actually message (see services/partners/engagement.service.ts).
const partner = await dal.partners.findByShareCode(code);
if (!partner || partner.suspendedAt) {
return c.json({ success: false, error: "not_found" }, 404);
}
const payload: ResolvedLink = {
kind: "share",
partnerFirstName: firstName(partner.name),
society: null,
config: null,
code,
};
return success(c, payload);
});
/**
* `referral.link_opened` — FR-H-2.
*
* Server-side on purpose. A client beacon is eaten by ad blockers and by the
* in-app browsers our homeowners actually use, and this is the number that
* separates "the partner never forwarded" from "the homeowner ignored it" —
* the most useful single figure in the funnel.
*
* It is an EVENT, not a transition. `lib/partners/state.ts` reserves `engaged`
* for the webhook (API-3): opening a page is not messaging us.
*
* Never throws. A failed write must not cost the homeowner their page.
*/
async function logOpen(
dal: Dal,
referralId: string,
c: { req: { header(name: string): string | undefined } },
): Promise<void> {
try {
await dal.referrals.addEvent({
referralId,
type: "referral.link_opened",
actorType: "homeowner",
// No name, no number, no code — schema/partners.ts forbids anything
// the homeowner told us, and a user-agent is as far as this goes.
payload: {
userAgent: c.req.header("user-agent")?.slice(0, 256) ?? null,
},
});
} catch (error) {
logger.error("[referral-links] failed to log link_opened", error);
}
}
export default referralLinks;
|