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;
}
|