All files / lib restriction.ts

100% Statements 15/15
100% Branches 8/8
100% Functions 3/3
100% Lines 14/14

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                                        87x               63x               56x 56x 55x   1x                     3x                                           45x 37x   8x     8x 32x 26x     8x    
// Restrict Direct Contact — the single enforcement predicate + contact redaction.
//
// A pro's personal contact (phone/WhatsApp/email) is hidden from homeowners, and
// homeowner→pro contact is forced through the portal inquiry flow, when EITHER:
//   • the global kill-switch `CONTACT_RESTRICTION_ENABLED === "true"`, OR
//   • that pro's `pro_restriction_status.restriction_active` is set (per-cohort).
//
// Everything downstream keys off `isContactRestricted`. Redaction happens at the
// API serialization boundary (`redactProContact`) so every consumer — marketplace,
// pro-sites, chatgpt-app — receives already-stripped data and cannot leak it.
 
type RestrictionDal = {
	proRestrictionStatus?: {
		get(proId: string): Promise<{ restrictionActive: boolean } | undefined>;
	};
};
 
export function isRestrictionFlagOn(
	env: Pick<CloudflareBindings, "CONTACT_RESTRICTION_ENABLED">,
): boolean {
	return env.CONTACT_RESTRICTION_ENABLED === "true";
}
 
export async function isContactRestricted(
	proId: string,
	env: Pick<CloudflareBindings, "CONTACT_RESTRICTION_ENABLED">,
	dal: RestrictionDal | undefined,
): Promise<boolean> {
	if (isRestrictionFlagOn(env)) return true;
	// Fail-safe: if no DAL / restriction table is available (partial route-test
	// mocks, or any context where dal wasn't attached), treat as not restricted.
	// The global flag above is checked first, so a kill-switch still forces
	// restriction regardless of per-pro status availability. A transient D1 read
	// failure must NOT propagate — this runs inside inquiry submission and pro
	// serialization, and an exception here would 500 the whole request; fall back
	// to the durable global flag (already false here) instead.
	try {
		const status = await dal?.proRestrictionStatus?.get(proId);
		return status?.restrictionActive ?? false;
	} catch {
		return false;
	}
}
 
// Personal-contact fields removed from a pro payload when restriction is active.
// `whatsappBusinessNumber` is included because pro onboarding keeps it in sync
// with `whatsapp` (steps.routes.ts / completion.routes.ts) — omitting it leaked
// the exact number the feature hides, in a sibling field. Portfolio website URL,
// social links, and business address are intentionally NOT here: the spec
// restricts CONTACT CHANNELS (phone / WhatsApp / email), not public business
// identity. If a new contact-bearing column is added to `pros`, add it here.
const REDACTED_CONTACT_FIELDS = [
	"whatsapp",
	"whatsappBusinessNumber",
	"phoneAlternate",
	"email",
] as const;
 
/**
 * Return a copy of a pro object with personal contact fields nulled and a
 * `contactRestricted` flag added. Pure — no DB access — so it is trivial to unit
 * test and safe to call in hot serialization paths. Pass the already-computed
 * restriction boolean; when false the object is returned with the flag set to
 * false and contact fields untouched.
 */
export function redactProContact<
	T extends {
		whatsapp?: string | null;
		whatsappBusinessNumber?: string | null;
		phoneAlternate?: string | null;
		email?: string | null;
	},
>(pro: T, restricted: boolean): T & { contactRestricted: boolean } {
	if (!restricted) {
		return { ...pro, contactRestricted: false };
	}
	const out = { ...pro, contactRestricted: true } as T & {
		contactRestricted: boolean;
	};
	for (const field of REDACTED_CONTACT_FIELDS) {
		if (field in out) {
			(out as Record<string, unknown>)[field] = null;
		}
	}
	return out;
}