Class: Insika::ContactStore

Inherits:
Object
  • Object
show all
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

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

#cellsObject

-> { ":" => raw record } — the whole scope, for the doctor's contact summary and the Studio (read-only folds; the mutations go through the commands, D10).



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

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