Class: Insika::FollowupStore
- Inherits:
-
Object
- Object
- Insika::FollowupStore
- Defined in:
- lib/insika/followup_store.rb
Overview
the schedule records of the follow-up feature. The store owns
the pending|fired|cancelled|blocked states (never written by a consumer —
the task_store.rb state-machine idiom) and the (customer, reason) scans.
It holds no policy (C2) and no contact cells (C3).
States:
pending -> fired | cancelled | blocked
fired is set ONLY inside the same transaction that created the synthetic
task (D5 — the record and the turn commit together or together fail); a
blocked record carries blocked_reason — auditable, never silent.
Record key: "at lives in the key so a
fired record keeps its scheduled time for the A/B card. Blank tenant -> the
literal "platform" (outcome_store.rb's rule — contact, follow-up and
outcome keys share one tenant segment so the purge prefix scans line up).
Defined Under Namespace
Classes: Record
Constant Summary collapse
- SCOPE =
"followups"- STATUSES =
%w[pending fired cancelled blocked].freeze
Instance Method Summary collapse
-
#block(id:, reason:, now: Time.now.utc) ⇒ Object
pending -> blocked, with the failing rule name.
-
#cancel(id:, now: Time.now.utc) ⇒ Object
-> Record; NotFoundError on a nonexistent id.
-
#cancel_pending_for(tenant:, customer:) ⇒ Object
cancels every PENDING record of the customer inside ONE transaction — the opt-out discipline (D2: a half-cancelled opt-out is the spam bug).
-
#create(tenant:, agent:, customer:, session_id:, at:, reason:, arm:, transport: nil, id: SecureRandom.uuid, now: Time.now.utc) ⇒ Object
-> Record (status :pending).
-
#delete_older_than(time) ⇒ Object
-> count removed.
-
#due(now: Time.now.utc) ⇒ Object
pending AND at <= now, oldest first (at, then id — determinism).
-
#find(id) ⇒ Object
-> Record | nil.
- #fired_in_window(tenant:, customer:, since:) ⇒ Object
-
#for_agent(tenant:, agent:) ⇒ Object
ALL records of one (tenant, agent) — the Follow-ups page's read (C10), lexicographic (the scheduled at first, so a fired record keeps its position for the A/B card).
-
#initialize(store:) ⇒ FollowupStore
constructor
A new instance of FollowupStore.
-
#pending_for(tenant:, agent:, customer:, reason:) ⇒ Object
The dedup scans (D7): the OLDEST pending record of the pair (nil when none) and how many fired records of the customer fall inside the window (the frequency gate).
- #pending_for?(tenant:, agent:, customer:, reason:) ⇒ Boolean
- #purge(tenant:) ⇒ Object
- #purge_customer(tenant:, customer:) ⇒ Object
-
#transition_fired(id:, task_id:, now: Time.now.utc) ⇒ Object
The engine's atomic claim: pending -> fired, WITH task_id.
Constructor Details
#initialize(store:) ⇒ FollowupStore
Returns a new instance of FollowupStore.
31 32 33 |
# File 'lib/insika/followup_store.rb', line 31 def initialize(store:) @store = store end |
Instance Method Details
#block(id:, reason:, now: Time.now.utc) ⇒ Object
pending -> blocked, with the failing rule name. -> Record
94 95 96 97 98 99 100 101 |
# File 'lib/insika/followup_store.rb', line 94 def block(id:, reason:, now: Time.now.utc) mutate(id, now) do |record| raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — only a pending record blocks" unless record["status"] == "pending" record["status"] = "blocked" record["blocked_reason"] = reason.to_s end end |
#cancel(id:, now: Time.now.utc) ⇒ Object
-> Record; NotFoundError on a nonexistent id. The only path out of pending besides the engine's fired/blocked. Idempotent: an already-cancelled record returns as-is (a repeat of the same call is not an error).
66 67 68 69 70 71 72 73 74 75 |
# File 'lib/insika/followup_store.rb', line 66 def cancel(id:, now: Time.now.utc) mutate(id, now) do |record| status = record["status"] unless %w[pending cancelled].include?(status) raise ArgumentError, "follow-up #{id}: cannot cancel a #{status} record" end record["status"] = "cancelled" end end |
#cancel_pending_for(tenant:, customer:) ⇒ Object
cancels every PENDING record of the customer inside ONE transaction — the opt-out discipline (D2: a half-cancelled opt-out is the spam bug). Blocked/fired records are never touched. -> count cancelled.
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/insika/followup_store.rb', line 173 def cancel_pending_for(tenant:, customer:) count = 0 @store.transaction do @store.list(SCOPE).each do |k| record = @store.get(SCOPE, k) next unless record && record["status"] == "pending" next unless record["tenant"] == tenant_id(tenant) next unless record["customer"] == customer.to_s record["status"] = "cancelled" record["updated_at"] = Time.now.utc.iso8601 @store.set(SCOPE, k, record) count += 1 end end count end |
#create(tenant:, agent:, customer:, session_id:, at:, reason:, arm:, transport: nil, id: SecureRandom.uuid, now: Time.now.utc) ⇒ Object
-> Record (status :pending). at must be a future ISO8601 (a Time or an
ISO8601 string — ValidationError otherwise, "the follow-up would already
be due"). The caller decided the arm (C1) and the transport (C7).
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/insika/followup_store.rb', line 38 def create(tenant:, agent:, customer:, session_id:, at:, reason:, arm:, transport: nil, id: SecureRandom.uuid, now: Time.now.utc) time = parse_at(at) if time <= now raise Insika::ValidationError, "follow-up #{id.inspect} would already be due (at #{time.iso8601} <= now)" end if (prior = pending_for(tenant: tenant, agent: agent, customer: customer, reason: reason)) raise Insika::ValidationError, "a follow-up for (customer #{customer.inspect}, reason #{reason.inspect}) is already " \ "pending: #{prior.id}" end time = time.utc.iso8601 record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s, "customer" => customer.to_s, "session_id" => session_id.to_s, "at" => time, "reason" => reason.to_s, "arm" => arm.to_s, "status" => "pending", "task_id" => nil, "blocked_reason" => nil, "transport" => transport.to_s, "created_at" => now.iso8601, "updated_at" => now.iso8601, "fired_at" => nil } @store.set(SCOPE, key_for(record), record) to_record(record) end |
#delete_older_than(time) ⇒ Object
-> count removed.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/insika/followup_store.rb', line 212 def delete_older_than(time) cutoff = time.utc.iso8601 removed = 0 @store.list(SCOPE).each do |k| record = @store.get(SCOPE, k) next unless record terminal = record["status"] != "pending" # TERMINAL records age by their updated_at; a PENDING record is a # zombie when its scheduled `at` has passed (it will never fire — # retention ages it out rather than firing late). next unless (terminal && record["updated_at"].to_s < cutoff) || (!terminal && record["at"].to_s < cutoff) @store.delete(SCOPE, k) removed += 1 end removed end |
#due(now: Time.now.utc) ⇒ Object
pending AND at <= now, oldest first (at, then id — determinism).
110 111 112 113 114 115 116 117 118 |
# File 'lib/insika/followup_store.rb', line 110 def due(now: Time.now.utc) cutoff = now.iso8601 @store.list(SCOPE).filter_map do |k| record = @store.get(SCOPE, k) next unless record && record["status"] == "pending" && record["at"].to_s <= cutoff to_record(record) end.sort_by { |r| [r.at.to_s, r.id] } end |
#find(id) ⇒ Object
-> Record | nil
104 105 106 107 |
# File 'lib/insika/followup_store.rb', line 104 def find(id) record = @store.get(SCOPE, key_for_id(id)) record && to_record(record) end |
#fired_in_window(tenant:, customer:, since:) ⇒ Object
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/insika/followup_store.rb', line 145 def fired_in_window(tenant:, customer:, since:) boundary = since.iso8601 count = 0 @store.list(SCOPE).each do |k| record = @store.get(SCOPE, k) next unless record next unless record["status"] == "fired" next unless record["tenant"] == tenant_id(tenant) next unless record["customer"] == customer.to_s # the frequency gate counts FIRES, never the scheduled time — a record # booked long ago and fired after a backlog still lands in the window. next unless record["fired_at"].to_s >= boundary count += 1 end count end |
#for_agent(tenant:, agent:) ⇒ Object
ALL records of one (tenant, agent) — the Follow-ups page's read (C10), lexicographic (the scheduled at first, so a fired record keeps its position for the A/B card).
123 124 125 126 127 128 129 130 |
# File 'lib/insika/followup_store.rb', line 123 def for_agent(tenant:, agent:) prefix = "#{tenant_id(tenant)}:#{agent}:" @store.list(SCOPE).filter_map do |k| next unless k.start_with?(prefix) to_record(@store.get(SCOPE, k)) end end |
#pending_for(tenant:, agent:, customer:, reason:) ⇒ Object
The dedup scans (D7): the OLDEST pending record of the pair (nil when
none) and how many fired records of the customer fall inside the window
(the frequency gate). pending_for scans the pair's keys in key order —
the key embeds the scheduled at and the id, so the first pending record
found IS the oldest pending.
137 138 139 |
# File 'lib/insika/followup_store.rb', line 137 def pending_for(tenant:, agent:, customer:, reason:) pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason) end |
#pending_for?(tenant:, agent:, customer:, reason:) ⇒ Boolean
141 142 143 |
# File 'lib/insika/followup_store.rb', line 141 def pending_for?(tenant:, agent:, customer:, reason:) !pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason).nil? end |
#purge(tenant:) ⇒ Object
204 205 206 207 208 209 |
# File 'lib/insika/followup_store.rb', line 204 def purge(tenant:) prefix = "#{tenant_id(tenant)}:" keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) } keys.each { |k| @store.delete(SCOPE, k) } keys.size end |
#purge_customer(tenant:, customer:) ⇒ Object
191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/insika/followup_store.rb', line 191 def purge_customer(tenant:, customer:) removed = 0 @store.list(SCOPE).each do |k| record = @store.get(SCOPE, k) next unless record && record["tenant"] == tenant_id(tenant) next unless record["customer"] == customer.to_s @store.delete(SCOPE, k) removed += 1 end removed end |
#transition_fired(id:, task_id:, now: Time.now.utc) ⇒ Object
The engine's atomic claim: pending -> fired, WITH task_id. Read-check-
write inside Store#transaction (D5 — the record and the task commit
together). A second claim raises. fired_at stamps WHEN the fire
happened — the frequency ceiling and the A/B card count FIRES, never the
scheduled time (a record booked days ago and fired after a tick outage
must still count against the cap).
83 84 85 86 87 88 89 90 91 |
# File 'lib/insika/followup_store.rb', line 83 def transition_fired(id:, task_id:, now: Time.now.utc) mutate(id, now) do |record| raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — it fires once" unless record["status"] == "pending" record["status"] = "fired" record["task_id"] = task_id.to_s record["fired_at"] = now.utc.iso8601 end end |