Module: Tina4::Feedback

Defined in:
lib/tina4/feedback.rb

Overview

── Customer feedback widget — server-side plumbing ─────────────────

Mirrors tina4_python/dev_admin/__init__.py lines 1436-1645. The widget is for END USERS of a shipped Tina4 app (not developers). Whitelisted users get a floating bubble that proxies one conversational turn at a time to the Rust agent’s intake endpoint at <supervisor>/feedback/intake.

Flow:

1. Framework middleware injects <script src="/__feedback/widget.js">
   into HTML responses for whitelisted users.
2. Widget POSTs to /__feedback/api/turn for each conversational turn.
3. The Ruby handler verifies whitelist + rate-limit, stamps the
   user identity server-side (client cannot fake `sender`), then
   forwards to the Rust agent's /feedback/intake.
4. Finalised tickets land in .tina4/chat/threads.json with
   kind:"feedback". Developer sees them in the dev admin sidebar.

Constant Summary collapse

RATE_WINDOW =

1 hour

3600
RATE_MAX =

submissions/turns per user per hour

5

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.rate_mutexObject (readonly)

Returns the value of attribute rate_mutex.



35
36
37
# File 'lib/tina4/feedback.rb', line 35

def rate_mutex
  @rate_mutex
end

Class Method Details

.feedback_enabled?Boolean

Hard master switch.

Both gates required for the widget to render or the API to accept submissions:

- TINA4_ENABLE_FEEDBACK=true (explicit opt-in — off by default)
- TINA4_FEEDBACK_WHITELIST=... (non-empty list of users)

Splitting the toggle from the whitelist lets the developer leave the whitelist intact while pausing the feature in production for a release (set TINA4_ENABLE_FEEDBACK=false → widget vanishes everywhere; whitelist comes back online with one env flip).

Returns:

  • (Boolean)


53
54
55
56
# File 'lib/tina4/feedback.rb', line 53

def feedback_enabled?
  raw = ENV["TINA4_ENABLE_FEEDBACK"].to_s.strip.downcase
  %w[1 true yes on].include?(raw)
end

.feedback_identify_user(request) ⇒ Object

Best-effort user identity from auth headers.

Priority:

1. JWT/Bearer token via Tina4::Auth.authenticate_request —
   pulls email/sub/user_id claim.
2. TINA4_FEEDBACK_DEV_USER env var override (LOCAL DEV ONLY —
   lets the framework owner test the widget without a full
   auth setup in the test project).


74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/tina4/feedback.rb', line 74

def feedback_identify_user(request)
  begin
    headers = request_headers(request)
    payload = Tina4::Auth.authenticate_request(headers)
    if payload.is_a?(Hash)
      %w[email sub user_id].each do |key|
        v = payload[key] || payload[key.to_sym]
        return v.to_s.strip.downcase if v && !v.to_s.strip.empty?
      end
    end
  rescue StandardError
    # fall through to dev-user override
  end
  dev_user = ENV["TINA4_FEEDBACK_DEV_USER"].to_s.strip
  return dev_user.downcase unless dev_user.empty?
  nil
end

.feedback_is_whitelisted(request) ⇒ Object

Returns [allowed, identity]. Both halves must be truthy to act.



93
94
95
96
97
98
99
# File 'lib/tina4/feedback.rb', line 93

def feedback_is_whitelisted(request)
  wl = feedback_whitelist
  return [false, nil] if wl.empty?
  user = feedback_identify_user(request)
  return [false, nil] unless user
  [wl.include?(user), user]
end

.feedback_rate_limit_ok(user) ⇒ Object

5 turns/hour per user. Prunes old timestamps lazily.



102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/tina4/feedback.rb', line 102

def feedback_rate_limit_ok(user)
  now = Time.now.to_f
  @rate_mutex.synchronize do
    hits = (@rate_limit[user] || []).select { |t| now - t < RATE_WINDOW }
    if hits.size >= RATE_MAX
      @rate_limit[user] = hits
      return false
    end
    hits << now
    @rate_limit[user] = hits
    true
  end
end

.feedback_whitelistObject

Comma-separated emails / user IDs in env. Empty = no one allowed.



59
60
61
62
63
64
# File 'lib/tina4/feedback.rb', line 59

def feedback_whitelist
  return [] unless feedback_enabled?
  raw = ENV["TINA4_FEEDBACK_WHITELIST"].to_s.strip
  return [] if raw.empty?
  raw.split(",").map { |e| e.strip.downcase }.reject(&:empty?)
end

.handle_request(env) ⇒ Object

Dispatcher used by RackApp — returns a Rack triple if the path matches a /__feedback route, else nil.



216
217
218
219
220
221
222
223
224
225
# File 'lib/tina4/feedback.rb', line 216

def handle_request(env)
  path = env["PATH_INFO"] || "/"
  method = env["REQUEST_METHOD"]
  case [method, path]
  when ["GET", "/__feedback/widget.js"]
    handle_widget_js(env)
  when ["POST", "/__feedback/api/turn"]
    handle_turn(env)
  end
end

.handle_turn(env) ⇒ Object

POST /__feedback/api/turn — whitelist check + rate-limit + stamp ‘sender` server-side, forward to Rust agent <supervisor>/feedback/intake. Returns a Rack response triple.



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/tina4/feedback.rb', line 148

def handle_turn(env)
  request = build_request_wrapper(env)
  allowed, user = feedback_is_whitelisted(request)
  return json_response({ error: "not authorised for feedback" }, 403) unless allowed
  unless feedback_rate_limit_ok(user)
    return json_response({
      error: "rate limit exceeded",
      hint: "max #{RATE_MAX} turns per hour"
    }, 429)
  end

  body = read_json_body(env)
  return json_response({ error: "expected JSON body" }, 400) unless body.is_a?(Hash)

  forward_body = body.dup
  forward_body["sender"] = user  # server-stamped identity

  base = supervisor_base
  uri = URI.parse("#{base}/feedback/intake")
  begin
    req = Net::HTTP::Post.new(uri)
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(forward_body)
    resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
    parsed = begin
      JSON.parse(resp.body.to_s)
    rescue JSON::ParserError
      nil
    end
    status_code = resp.code.to_i
    status_code = 200 if status_code.zero?
    if parsed
      json_response(parsed, status_code)
    else
      [status_code, { "content-type" => "text/plain; charset=utf-8" }, [resp.body.to_s]]
    end
  rescue StandardError => e
    json_response({
      error: "agent unreachable",
      detail: e.message
    }, 502)
  end
end

.handle_widget_js(_env) ⇒ Object

GET /__feedback/widget.js — serve the bundle at lib/tina4/public/__feedback/widget.js. Cache-Control: no-cache, must-revalidate so browsers re-check the bundle on every load. Without this an old cached bundle (e.g. one that pre-dates the path-block guard against rendering on /__dev/) can persist for days and keep painting the bubble on the dev admin even after the server-side script-tag injection is fixed.



199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/tina4/feedback.rb', line 199

def handle_widget_js(_env)
  js_path = File.expand_path("public/__feedback/widget.js", __dir__)
  body = if File.file?(js_path)
           File.binread(js_path)
         else
           "console.warn('tina4-feedback-widget bundle not built yet');"
         end
  headers = {
    "content-type" => "application/javascript",
    "cache-control" => "no-cache, must-revalidate",
    "pragma" => "no-cache"
  }
  [200, headers, [body]]
end

.inject_feedback_widget(request, html) ⇒ Object

Insert the widget <script> into HTML responses for whitelisted users.

Called from the Rack middleware right before the body is sent. No-op if:

- The request is for a /__dev or /__feedback path (developer
  dashboard / widget assets — never inject the customer widget
  on developer pages; the dev admin has its OWN chat trigger).
- TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST not both set
- Requesting user isn't in the whitelist
- Response doesn't have a closing </body> tag (fragment, JSON, etc.)

Idempotent: a second call won’t double-inject (looks for marker).



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/tina4/feedback.rb', line 127

def inject_feedback_widget(request, html)
  return html if html.nil? || html.empty?
  # The customer feedback widget is for END USERS of the shipped
  # app — injecting on developer-only paths creates a confusing
  # "two bubbles" UX where the dev chat trigger + customer
  # feedback bubble sit on top of each other. Hard exclusion at
  # the framework layer.
  path = request_path(request)
  return html if path.start_with?("/__dev") || path.start_with?("/__feedback")
  allowed, _user = feedback_is_whitelisted(request)
  return html unless allowed
  return html if html.include?("data-tina4-feedback")
  snippet = '<script src="/__feedback/widget.js" data-tina4-feedback></script>'
  idx = html.rindex("</body>")
  return html unless idx
  html[0...idx] + snippet + html[idx..]
end

.reset_rate_limit!Object

Test/reset helper — clears the in-memory rate-limit table.



38
39
40
# File 'lib/tina4/feedback.rb', line 38

def reset_rate_limit!
  @rate_mutex.synchronize { @rate_limit = {} }
end