Class: Insika::FollowupStore

Inherits:
Object
  • Object
show all
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: "::::#uuid" — per-customer / per-agent scans are prefixes, and the scheduled 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

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

Returns:

  • (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