All files / lib/rrm stage.ts

100% Statements 14/14
100% Branches 16/16
100% Functions 4/4
100% Lines 12/12

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                                        28x                             28x               1387x       3x                                                 1298x     1289x 12x           1277x                                 107x 16x           91x                     4x    
import type { RRM_STAGES } from "../../db/schema/enums";
 
export type RrmStage = (typeof RRM_STAGES)[number];
 
/**
 * Ordinal rank for the forward-only part of the stage machine.
 *
 * The build spec states "never move backwards" as prose and gives an ordered
 * list. Prose is not something a webhook can be checked against, and there are
 * two routine ways to violate it:
 *
 *   1. WhatsApp status webhooks arrive out of order. A late `delivered` must
 *      not overwrite `read`.
 *   2. A prospect can submit the landing-page form without ever replying on
 *      WhatsApp, so `interested` legitimately follows `contacted`.
 *
 * Terminal stages are deliberately absent from this map: they are lateral, not
 * further along, and ranking them would let `not_now` (a snooze) outrank
 * `replied` and block a real re-engagement.
 */
export const STAGE_RANK = {
	sourced: 0,
	queued: 1,
	contacted: 2,
	delivered: 3,
	read: 4,
	replied: 5,
	interested: 6,
	intent_stated: 7,
	joined: 8,
} as const satisfies Partial<Record<RrmStage, number>>;
 
export type RankedStage = keyof typeof STAGE_RANK;
 
/** Lateral/absorbing. Reachable from anywhere; not part of the ordering. */
export const TERMINAL_STAGES = [
	"not_now",
	"unreachable",
	"do_not_contact",
] as const;
export type TerminalStage = (typeof TERMINAL_STAGES)[number];
 
export function isTerminalStage(stage: RrmStage): stage is TerminalStage {
	return (TERMINAL_STAGES as readonly string[]).includes(stage);
}
 
export function isRankedStage(stage: RrmStage): stage is RankedStage {
	return stage in STAGE_RANK;
}
 
export type StageDecision =
	| { change: true; stage: RrmStage }
	| { change: false; reason: "unchanged" | "backwards" | "absorbing" };
 
/**
 * Decides whether a proposed stage should be written.
 *
 * Every stage write goes through this. Returning a decision rather than a
 * boolean means the caller can record *why* nothing happened — a silently
 * dropped transition is indistinguishable from a bug at 2am.
 *
 * @param current  the stage on the row now
 * @param proposed the stage the caller wants to set
 * @param viaInbound true when the trigger is an inbound message from that
 *   person. This is the ONLY thing that releases `do_not_contact`: the spec
 *   makes it absorbing except when the person themselves gets back in touch.
 */
export function decideStage(
	current: RrmStage,
	proposed: RrmStage,
	viaInbound = false,
): StageDecision {
	if (current === proposed) return { change: false, reason: "unchanged" };
 
	// Absorbing. Someone who opted out stays opted out unless they message us.
	if (current === "do_not_contact") {
		return viaInbound
			? { change: true, stage: proposed }
			: { change: false, reason: "absorbing" };
	}
 
	// An operator (or the engine) can always mark a terminal outcome.
	if (isTerminalStage(proposed)) return { change: true, stage: proposed };
 
	// Leaving a non-absorbing terminal state — a snoozed or unreachable
	// prospect who re-engages.
	//
	// NOT "any ranked stage is forward from here", which is what this used to
	// say and it was wrong. A terminal state does not remember the rank it was
	// reached from, so allowing anything let a LATE WEBHOOK undo real progress:
	// a prospect who got to `interested`, was then snoozed to `not_now`, and
	// whose delayed `delivered` receipt finally arrived would be written back
	// to `delivered` — erasing the funnel position, and doing it via exactly
	// the out-of-order delivery this module exists to defend against.
	//
	// Only stages that represent NEW information about the person can pull
	// them out. `contacted` / `delivered` / `read` are message plumbing: they
	// say something happened to a message, not that the human did anything, so
	// they must never resurrect someone who was closed out.
	if (isTerminalStage(current)) {
		return STAGE_RANK[proposed] >= STAGE_RANK.replied
			? { change: true, stage: proposed }
			: { change: false, reason: "backwards" };
	}
 
	// Both ranked: the ordering applies.
	return STAGE_RANK[proposed] > STAGE_RANK[current]
		? { change: true, stage: proposed }
		: { change: false, reason: "backwards" };
}
 
/** Convenience wrapper for callers that only need the yes/no. */
export function canAdvance(
	current: RrmStage,
	proposed: RrmStage,
	viaInbound = false,
): boolean {
	return decideStage(current, proposed, viaInbound).change;
}