Class: Insika::ContactStore
- Inherits:
-
Object
- Object
- Insika::ContactStore
- Defined in:
- lib/insika/contact_store.rb
Overview
the durable CONTACT STATE cell per (tenant, customer)— ONE
derived cell, never a transition log (D2): granted | revoked | unavailable, the sends-without-reply counter and the last outbound
timestamp. Dumb domain store: it holds no policy and no follow-up records
(the firer and the inbound hook own those transformations).
Invariants (the firer enforces them, the store only records):
· only `granted` may be messaged; absent = never messaged (:consent block);
· `revoked` is immediate and permanent until the customer speaks;
· `unavailable` means silence ≠ refusal — set when sends_without_reply
reaches the policy's ceiling; further fires block until the customer
speaks; any customer message reopens (sets granted + zeroes the counter).
The customer identifier is the SAME string the message contract carries
(customer: on /v1/messages, WS8).
Defined Under Namespace
Classes: Cell
Constant Summary collapse
- SCOPE =
"contacts"- STATES =
%w[granted revoked unavailable].freeze
Instance Method Summary collapse
-
#bump_outbound(tenant:, customer:, now: Time.now.utc) ⇒ Object
The firer's call: sends_without_reply += 1, last_outbound_at = now.
-
#cells ⇒ Object
-> { "
: " => raw record } — the whole scope, for the doctor's contact summary and the Studio (read-only folds; the mutations go through the commands, D10). -
#consent(tenant:, customer:, now: Time.now.utc) ⇒ Object
D7: the schedule tool's consent write.
-
#delete(tenant:, customer:) ⇒ Object
-> true | false (did the cell exist?).
-
#delete_older_than(time) ⇒ Object
Cells untouched past the cutoff (WS8 retention).
-
#get(tenant:, customer:) ⇒ Object
-> [Cell] | nil (absent = never messaged — the :consent block).
-
#initialize(store:) ⇒ ContactStore
constructor
A new instance of ContactStore.
-
#mark_unavailable(tenant:, customer:, now: Time.now.utc) ⇒ Object
Silence reached the policy's ceiling (the firer counts sends without a reply); further fires block until the customer speaks.
-
#purge(tenant:) ⇒ Object
-> count removed.
-
#set_granted(tenant:, customer:, now: Time.now.utc) ⇒ Object
Any customer message reopens the conversation:
granted+ the counter reset. -
#set_revoked(tenant:, customer:, now: Time.now.utc) ⇒ Object
Keyword / channel opt-out / operator: immediate and permanent until the customer speaks.
Constructor Details
#initialize(store:) ⇒ ContactStore
Returns a new instance of ContactStore.
27 28 29 |
# File 'lib/insika/contact_store.rb', line 27 def initialize(store:) @store = store end |
Instance Method Details
#bump_outbound(tenant:, customer:, now: Time.now.utc) ⇒ Object
The firer's call: sends_without_reply += 1, last_outbound_at = now. Creates the cell when absent (granted — the firer checks the GO before bumping, so a bump only happens after the consent gate).
107 108 109 110 111 112 113 |
# File 'lib/insika/contact_store.rb', line 107 def bump_outbound(tenant:, customer:, now: Time.now.utc) write(tenant, customer, now) do |record| record["state"] ||= "granted" record["sends_without_reply"] = record["sends_without_reply"].to_i + 1 record["last_outbound_at"] = now.iso8601 end end |
#cells ⇒ Object
-> { "
43 44 45 46 47 48 |
# File 'lib/insika/contact_store.rb', line 43 def cells @store.list(SCOPE).each_with_object({}) do |k, acc| record = @store.get(SCOPE, k) acc[k] = record if record end end |
#consent(tenant:, customer:, now: Time.now.utc) ⇒ Object
D7: the schedule tool's consent write. The customer agreeing
in-conversation IS the consent — but ONLY a customer message reopens
(D2): this NEVER lifts :unavailable and NEVER resets
sends_without_reply, so a re-booking inside the scheduled turn cannot
clear the silence protection. Creates the cell when absent (the first
consent). Raises Insika::ValidationError on :revoked — an opt-out is
permanent; the caller refuses.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/insika/contact_store.rb', line 69 def (tenant:, customer:, now: Time.now.utc) raise Insika::ValidationError, "customer is required" if customer.to_s.empty? @store.transaction do record = @store.get(SCOPE, key(tenant, customer)) || { "state" => "granted", "sends_without_reply" => 0, "last_outbound_at" => nil, "updated_at" => nil } if record["state"] == "revoked" raise Insika::ValidationError, "this customer opted out — you cannot schedule a follow-up" end record["state"] ||= "granted" record["updated_at"] = now.iso8601 @store.set(SCOPE, key(tenant, customer), record) to_cell(record) end end |
#delete(tenant:, customer:) ⇒ Object
-> true | false (did the cell exist?)
119 120 121 122 123 |
# File 'lib/insika/contact_store.rb', line 119 def delete(tenant:, customer:) return false if customer.to_s.empty? @store.delete(SCOPE, key(tenant, customer)) end |
#delete_older_than(time) ⇒ Object
Cells untouched past the cutoff (WS8 retention). -> count removed.
134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/insika/contact_store.rb', line 134 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 && record["updated_at"].to_s < cutoff @store.delete(SCOPE, k) removed += 1 end removed end |
#get(tenant:, customer:) ⇒ Object
-> [Cell] | nil (absent = never messaged — the :consent block). nil customer -> nil (an untagged conversation has no cell).
33 34 35 36 37 38 |
# File 'lib/insika/contact_store.rb', line 33 def get(tenant:, customer:) return nil if customer.to_s.empty? record = @store.get(SCOPE, key(tenant, customer)) record && to_cell(record) end |
#mark_unavailable(tenant:, customer:, now: Time.now.utc) ⇒ Object
Silence reached the policy's ceiling (the firer counts sends without a reply); further fires block until the customer speaks.
98 99 100 101 102 |
# File 'lib/insika/contact_store.rb', line 98 def mark_unavailable(tenant:, customer:, now: Time.now.utc) write(tenant, customer, now) do |record| record["state"] = "unavailable" end end |
#purge(tenant:) ⇒ Object
-> count removed.
126 127 128 129 130 131 |
# File 'lib/insika/contact_store.rb', line 126 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 |
#set_granted(tenant:, customer:, now: Time.now.utc) ⇒ Object
Any customer message reopens the conversation: granted + the counter
reset. The consent record itself (D2/D7) is the schedule tool call.
55 56 57 58 59 60 |
# File 'lib/insika/contact_store.rb', line 55 def set_granted(tenant:, customer:, now: Time.now.utc) write(tenant, customer, now) do |record| record["state"] = "granted" record["sends_without_reply"] = 0 end end |
#set_revoked(tenant:, customer:, now: Time.now.utc) ⇒ Object
Keyword / channel opt-out / operator: immediate and permanent until the customer speaks. Nothing auto-revokes.
90 91 92 93 94 |
# File 'lib/insika/contact_store.rb', line 90 def set_revoked(tenant:, customer:, now: Time.now.utc) write(tenant, customer, now) do |record| record["state"] = "revoked" end end |