studio-engine 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ class Survey
5
+ # The per-question results the admin panel draws. Pure Ruby over plain rows,
6
+ # so the arithmetic is unit-tested without a database.
7
+ #
8
+ # rows: an Enumerable of hashes { answers: {key => {"value" => ...}},
9
+ # respondent: "alex" | "anonymous",
10
+ # at: Time }
11
+ #
12
+ # Counts are over the rows that ANSWERED the question, not all rows — a
13
+ # skipped optional question does not dilute its own distribution. A stored
14
+ # value whose option was later removed from the definition still counts,
15
+ # under its label snapshot and marked retired, so editing a survey never
16
+ # silently drops answers from the totals.
17
+ class Breakdown
18
+ Bucket = Struct.new(:value, :label, :face, :count, :percent, :retired, keyword_init: true)
19
+ TextEntry = Struct.new(:text, :respondent, :at, keyword_init: true)
20
+ Result = Struct.new(:question, :answered, :buckets, :average, :entries, keyword_init: true) do
21
+ def text? = question.text?
22
+ end
23
+
24
+ def initialize(survey, rows)
25
+ @survey = survey
26
+ @rows = rows.to_a
27
+ end
28
+
29
+ def results
30
+ @results ||= @survey.questions.map { |q| result_for(q) }
31
+ end
32
+
33
+ def self.percent(count, total)
34
+ return 0 if total.to_i.zero?
35
+
36
+ ((count.to_f / total) * 100).round
37
+ end
38
+
39
+ private
40
+
41
+ def answers_for(question)
42
+ @rows.filter_map do |row|
43
+ entry = (row[:answers] || {})[question.key]
44
+ next unless entry.is_a?(Hash) && !question.blank_value?(entry["value"])
45
+
46
+ [entry, row]
47
+ end
48
+ end
49
+
50
+ def result_for(question)
51
+ found = answers_for(question)
52
+ return text_result(question, found) if question.text?
53
+
54
+ counts = Hash.new(0)
55
+ snapshots = {}
56
+ found.each do |entry, _row|
57
+ displays = Array(entry["display"])
58
+ Array(entry["value"]).each_with_index do |v, i|
59
+ counts[v.to_s] += 1
60
+ snapshots[v.to_s] ||= displays[i]
61
+ end
62
+ end
63
+
64
+ total = found.size
65
+ buckets = question.options.map do |opt|
66
+ n = counts.delete(opt.value.to_s) || 0
67
+ Bucket.new(value: opt.value, label: opt.label, face: opt.face, count: n,
68
+ percent: self.class.percent(n, total), retired: false)
69
+ end
70
+ counts.sort.each do |value, n|
71
+ buckets << Bucket.new(value: value, label: snapshots[value].presence || value, count: n,
72
+ percent: self.class.percent(n, total), retired: true)
73
+ end
74
+
75
+ Result.new(question: question, answered: total, buckets: buckets, average: average(question, found), entries: [])
76
+ end
77
+
78
+ def average(question, found)
79
+ return nil unless question.scale?
80
+
81
+ nums = found.map { |entry, _| Integer(entry["value"].to_s, exception: false) }.compact
82
+ return nil if nums.empty?
83
+
84
+ (nums.sum.to_f / nums.size).round(2)
85
+ end
86
+
87
+ def text_result(question, found)
88
+ entries = found.map do |entry, row|
89
+ TextEntry.new(text: entry["value"].to_s, respondent: row[:respondent].presence || "anonymous", at: row[:at])
90
+ end
91
+ entries.sort_by! { |e| e.at || Time.at(0) }.reverse!
92
+ Result.new(question: question, answered: entries.size, buckets: [], average: nil, entries: entries)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ class Survey
5
+ # CSV for one survey's responses. One row per response; one column per
6
+ # question key in the CURRENT definition, then any retired key a stored
7
+ # answer still carries, so an export never drops data the definition forgot.
8
+ #
9
+ # Written without the csv gem (a bundled, not default, gem from Ruby 3.4):
10
+ # RFC 4180 quoting, plus a leading apostrophe on any cell a spreadsheet
11
+ # would execute as a formula (=, +, -, @, tab, CR) — respondents type these
12
+ # cells, so they are untrusted.
13
+ class Export
14
+ META = %w[response_id survey_version status started_at completed_at respondent user_id email_ref user_agent_class].freeze
15
+ FORMULA_LEAD = /\A[=+\-@\t\r]/
16
+
17
+ # rows: hashes with the META keys (as symbols) plus :answers. survey may be
18
+ # nil for a slug whose definition was deleted; its stored keys still export.
19
+ def initialize(survey, rows)
20
+ @survey = survey
21
+ @rows = rows.to_a
22
+ end
23
+
24
+ def keys
25
+ @keys ||= begin
26
+ current = @survey ? @survey.keys : []
27
+ retired = @rows.flat_map { |r| (r[:answers] || {}).keys }.uniq - current
28
+ current + retired.sort
29
+ end
30
+ end
31
+
32
+ def to_csv
33
+ lines = [line(META + keys)]
34
+ @rows.each do |row|
35
+ answers = row[:answers] || {}
36
+ cells = META.map { |k| format_time(row[k.to_sym]) }
37
+ cells += keys.map { |k| answer_cell(answers[k]) }
38
+ lines << line(cells)
39
+ end
40
+ lines.join
41
+ end
42
+
43
+ def self.cell(value)
44
+ text = value.to_s
45
+ text = "'#{text}" if text.match?(FORMULA_LEAD)
46
+ text.match?(/[",\n\r]/) || text != text.strip ? %("#{text.gsub('"', '""')}") : text
47
+ end
48
+
49
+ private
50
+
51
+ def line(cells) = "#{cells.map { |c| self.class.cell(c) }.join(",")}\r\n"
52
+
53
+ def format_time(value)
54
+ value.respond_to?(:iso8601) ? value.iso8601 : value
55
+ end
56
+
57
+ def answer_cell(entry)
58
+ return nil unless entry.is_a?(Hash)
59
+
60
+ value = entry["value"]
61
+ value.is_a?(Array) ? value.join("; ") : value
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Studio
7
+ # A SURVEY, defined in app code (docs/SURVEYS.md).
8
+ #
9
+ # # config/surveys/first_game.rb — loaded by the engine on boot and reload
10
+ # Studio.define_survey "first-game" do
11
+ # title "How was your first game?"
12
+ # intro "Two minutes, six questions. It shapes what we build next."
13
+ # thank_you "We read every answer."
14
+ # next_action label: "Play another game", url: "/play"
15
+ # allow_anonymous true
16
+ #
17
+ # emoji_scale :overall, "How was your first game?", required: true
18
+ # rating :rules, "How clear were the rules?", low_label: "Lost", high_label: "Crystal clear"
19
+ # choice :found_us, "How did you find us?", options: ["Email", "A friend", "Search", "Other"]
20
+ # multi_choice :liked, "What did you enjoy?", options: ["The board", "The pace", "The art"]
21
+ # short_text :one_word, "Describe it in one word."
22
+ # long_text :anything_else, "Anything else?", help: "Bugs, ideas, complaints — all welcome."
23
+ # end
24
+ #
25
+ # The definition is the CURRENT shape of the survey, nothing more. A stored
26
+ # answer carries its own question key, type and label snapshot (and the chosen
27
+ # option's label), so editing or removing a question never rewrites what an
28
+ # earlier respondent was asked. `version` defaults to a digest of the questions
29
+ # and is stamped on every response.
30
+ class Survey
31
+ class DefinitionError < ArgumentError; end
32
+
33
+ SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
34
+ KEY_FORMAT = /\A[a-z][a-z0-9_]{0,62}\z/
35
+ # The same two shapes, unanchored, for route constraints.
36
+ ROUTE_SLUG = /[a-z0-9]+(?:-[a-z0-9]+)*/
37
+ ROUTE_KEY = /[a-z][a-z0-9_]{0,62}/
38
+ TYPES = %i[emoji_scale rating choice multi_choice short_text long_text].freeze
39
+ SCALE_TYPES = %i[emoji_scale rating].freeze
40
+ SELECT_TYPES = %i[emoji_scale rating choice].freeze
41
+ TEXT_TYPES = %i[short_text long_text].freeze
42
+
43
+ EMOJI_FACES = ["\u{1F61E}", "\u{1F615}", "\u{1F610}", "\u{1F642}", "\u{1F60D}"].freeze
44
+ DEFAULT_EMOJI_LABELS = ["Awful", "Not great", "Okay", "Good", "Loved it"].freeze
45
+ TEXT_LIMITS = { short_text: 280, long_text: 5000 }.freeze
46
+
47
+ Option = Struct.new(:value, :label, :face, keyword_init: true)
48
+
49
+ # One question. Immutable once built.
50
+ class Question
51
+ attr_reader :key, :type, :label, :help, :options, :low_label, :high_label, :placeholder, :max_length
52
+
53
+ def initialize(key:, type:, label:, required: false, help: nil, options: nil, labels: nil,
54
+ low_label: nil, high_label: nil, placeholder: nil, max_length: nil)
55
+ @key = key.to_s
56
+ @type = type.to_sym
57
+ @label = label.to_s.strip
58
+ @required = required == true
59
+ @help = help.to_s.strip.presence
60
+ @low_label = low_label.to_s.strip.presence
61
+ @high_label = high_label.to_s.strip.presence
62
+ @placeholder = placeholder.to_s.strip.presence
63
+ validate_shape!(options, labels, max_length)
64
+ @options = build_options(options, labels).freeze
65
+ @max_length = text? ? Integer(max_length || TEXT_LIMITS.fetch(@type)) : nil
66
+ freeze
67
+ end
68
+
69
+ def required? = @required
70
+ def scale? = SCALE_TYPES.include?(type)
71
+ def select? = SELECT_TYPES.include?(type)
72
+ def multi? = type == :multi_choice
73
+ def text? = TEXT_TYPES.include?(type)
74
+ def numbered? = !text?
75
+
76
+ def option(value)
77
+ options.find { |o| o.value.to_s == value.to_s }
78
+ end
79
+
80
+ def blank_value?(value)
81
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.to_s.strip.empty?
82
+ end
83
+
84
+ # [normalized_value, error_message]. A blank input normalizes to nil, which
85
+ # the response stores as "unanswered".
86
+ def normalize(raw)
87
+ raw = Array(raw).map(&:to_s).reject { |v| v.strip.empty? } if multi?
88
+ return [nil, nil] if blank_value?(raw)
89
+
90
+ case type
91
+ when :emoji_scale, :rating
92
+ int = Integer(raw.to_s.strip, exception: false)
93
+ return [nil, "Pick one of the options."] unless int && option(int)
94
+
95
+ [int, nil]
96
+ when :choice
97
+ opt = option(raw.to_s)
98
+ opt ? [opt.value, nil] : [nil, "Pick one of the options."]
99
+ when :multi_choice
100
+ values = raw.uniq
101
+ return [nil, "Pick from the listed options."] unless values.all? { |v| option(v) }
102
+
103
+ # Keep the definition's order, not the click order.
104
+ [options.map(&:value).select { |v| values.include?(v) }, nil]
105
+ else
106
+ text = raw.to_s.strip.gsub("\r\n", "\n")
107
+ return [nil, "Keep it under #{max_length} characters."] if text.length > max_length
108
+
109
+ [text, nil]
110
+ end
111
+ end
112
+
113
+ # The human snapshot of an answer: the option label(s), or the text itself.
114
+ def display(value)
115
+ return nil if value.nil?
116
+ return Array(value).map { |v| option(v)&.label || v.to_s } if multi?
117
+ return value.to_s if text?
118
+
119
+ option(value)&.label || value.to_s
120
+ end
121
+
122
+ def to_h
123
+ { key: key, type: type, label: label, required: required?, help: help,
124
+ options: options.map { |o| [o.value, o.label] }, low_label: low_label, high_label: high_label }
125
+ end
126
+
127
+ private
128
+
129
+ def validate_shape!(options, labels, max_length)
130
+ raise DefinitionError, "question key #{key.inspect} must match #{KEY_FORMAT.inspect}" unless key.match?(KEY_FORMAT)
131
+ raise DefinitionError, "question #{key}: unknown type #{type.inspect} (one of #{TYPES.join(", ")})" unless TYPES.include?(type)
132
+ raise DefinitionError, "question #{key}: label is required" if label.empty?
133
+
134
+ if %i[choice multi_choice].include?(type)
135
+ raise DefinitionError, "question #{key}: #{type} needs at least two options" if Array(options).size < 2
136
+ elsif options
137
+ raise DefinitionError, "question #{key}: options: is only for choice and multi_choice"
138
+ end
139
+
140
+ if labels && type != :emoji_scale
141
+ raise DefinitionError, "question #{key}: labels: is only for emoji_scale"
142
+ end
143
+ if labels && Array(labels).size != 5
144
+ raise DefinitionError, "question #{key}: emoji_scale takes exactly five labels"
145
+ end
146
+ if (low_label || high_label) && type != :rating
147
+ raise DefinitionError, "question #{key}: low_label/high_label are only for rating"
148
+ end
149
+ if max_length && !text?
150
+ raise DefinitionError, "question #{key}: max_length: is only for short_text and long_text"
151
+ end
152
+ return unless max_length && !(Integer(max_length, exception: false).to_i.between?(1, TEXT_LIMITS[:long_text]))
153
+
154
+ raise DefinitionError, "question #{key}: max_length must be 1..#{TEXT_LIMITS[:long_text]}"
155
+ end
156
+
157
+ def build_options(options, labels)
158
+ case type
159
+ when :emoji_scale
160
+ names = Array(labels || DEFAULT_EMOJI_LABELS).map { |l| l.to_s.strip }
161
+ raise DefinitionError, "question #{key}: emoji labels cannot be blank" if names.any?(&:empty?)
162
+
163
+ names.each_with_index.map { |name, i| Option.new(value: i + 1, label: name, face: EMOJI_FACES[i]) }
164
+ when :rating
165
+ (1..5).map { |n| Option.new(value: n, label: n.to_s) }
166
+ when :choice, :multi_choice
167
+ opts = Array(options).map { |o| coerce_option(o) }
168
+ values = opts.map(&:value)
169
+ raise DefinitionError, "question #{key}: option values must be unique" if values.uniq.size != values.size
170
+
171
+ opts
172
+ else
173
+ []
174
+ end
175
+ end
176
+
177
+ def coerce_option(raw)
178
+ label, value =
179
+ case raw
180
+ when Hash then [raw[:label] || raw["label"], raw[:value] || raw["value"]]
181
+ when Array then [raw[1], raw[0]]
182
+ else [raw, nil]
183
+ end
184
+ label = label.to_s.strip
185
+ raise DefinitionError, "question #{key}: option labels cannot be blank" if label.empty?
186
+
187
+ value = (value.presence || label.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")).to_s
188
+ raise DefinitionError, "question #{key}: option #{label.inspect} has no usable value" if value.empty?
189
+
190
+ Option.new(value: value, label: label)
191
+ end
192
+ end
193
+
194
+ # The block DSL. Every setter is a plain method so `title "x"` reads well.
195
+ class Builder
196
+ def initialize(slug)
197
+ @attrs = { slug: slug.to_s, questions: [] }
198
+ end
199
+
200
+ def title(text) = @attrs[:title] = text
201
+ def intro(text) = @attrs[:intro] = text
202
+ def thank_you(text) = @attrs[:thank_you] = text
203
+ def allow_anonymous(flag = true) = @attrs[:allow_anonymous] = flag
204
+ def version(value) = @attrs[:version] = value
205
+
206
+ def next_action(label:, url:)
207
+ @attrs[:next_action] = { label: label.to_s.strip, url: url.to_s.strip }
208
+ end
209
+
210
+ TYPES.each do |type|
211
+ define_method(type) do |key, label, **opts|
212
+ @attrs[:questions] << Question.new(key: key, type: type, label: label, **opts)
213
+ end
214
+ end
215
+
216
+ def build = Survey.new(**@attrs)
217
+ end
218
+
219
+ attr_reader :slug, :title, :intro, :thank_you, :next_action, :questions, :version
220
+
221
+ def initialize(slug:, title: nil, intro: nil, thank_you: nil, next_action: nil, allow_anonymous: false,
222
+ version: nil, questions: [])
223
+ @slug = slug.to_s
224
+ @title = title.to_s.strip
225
+ @intro = intro.to_s.strip.presence
226
+ @thank_you = thank_you.to_s.strip.presence || "We appreciate you taking the time."
227
+ @next_action = next_action
228
+ @allow_anonymous = allow_anonymous == true
229
+ @questions = questions.dup.freeze
230
+ validate!
231
+ @version = (version.presence || fingerprint).to_s
232
+ freeze
233
+ end
234
+
235
+ def allow_anonymous? = @allow_anonymous
236
+
237
+ def question(key)
238
+ questions.find { |q| q.key == key.to_s }
239
+ end
240
+
241
+ def keys = questions.map(&:key)
242
+ def required_keys = questions.select(&:required?).map(&:key)
243
+
244
+ private
245
+
246
+ def validate!
247
+ raise DefinitionError, "survey slug #{slug.inspect} must be lowercase words joined by hyphens" unless slug.match?(SLUG_FORMAT)
248
+ raise DefinitionError, "survey #{slug}: title is required" if title.empty?
249
+ raise DefinitionError, "survey #{slug}: needs at least one question" if questions.empty?
250
+
251
+ dupes = keys.group_by(&:itself).select { |_, v| v.size > 1 }.keys
252
+ raise DefinitionError, "survey #{slug}: duplicate question keys #{dupes.join(", ")}" if dupes.any?
253
+
254
+ validate_next_action!
255
+ end
256
+
257
+ def validate_next_action!
258
+ return if next_action.nil?
259
+
260
+ label, url = next_action.values_at(:label, :url)
261
+ raise DefinitionError, "survey #{slug}: next_action needs a label and a url" if label.to_s.empty? || url.to_s.empty?
262
+ # A relative path or an http(s) URL — never javascript: or a protocol-relative //host.
263
+ return if url.match?(%r{\A/(?!/)}) || url.match?(%r{\Ahttps?://}i)
264
+
265
+ raise DefinitionError, "survey #{slug}: next_action url must be a /path or an http(s) URL"
266
+ end
267
+
268
+ def fingerprint
269
+ Digest::SHA256.hexdigest(questions.map(&:to_h).inspect)[0, 12]
270
+ end
271
+
272
+ # --- registry --------------------------------------------------------------
273
+
274
+ @registry = {}
275
+ @mutex = Mutex.new
276
+
277
+ class << self
278
+ def define(slug, &block)
279
+ raise DefinitionError, "define_survey needs a block" unless block
280
+
281
+ builder = Builder.new(slug)
282
+ builder.instance_eval(&block)
283
+ survey = builder.build
284
+ @mutex.synchronize { @registry[survey.slug] = survey }
285
+ survey
286
+ end
287
+
288
+ def find(slug) = @registry[slug.to_s]
289
+
290
+ def all = @registry.values.sort_by(&:slug)
291
+
292
+ def reset! = @mutex.synchronize { @registry.clear }
293
+
294
+ # Loads every *.rb under the app's survey directory. `load`, not `require`,
295
+ # so a development reload picks up an edited definition.
296
+ def load_definitions!(dir)
297
+ return [] unless dir && File.directory?(dir.to_s)
298
+
299
+ Dir[File.join(dir.to_s, "**", "*.rb")].sort.each { |path| load path }
300
+ end
301
+ end
302
+ end
303
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.87.0"
2
+ VERSION = "0.88.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -49,6 +49,9 @@ require "studio/email_smoke"
49
49
  require "studio/mail_transport"
50
50
  require "studio/redis"
51
51
  require "studio/cable"
52
+ require "studio/survey"
53
+ require "studio/survey/breakdown"
54
+ require "studio/survey/export"
52
55
 
53
56
  module Studio
54
57
  mattr_accessor :app_name, default: "Studio"
@@ -708,6 +711,48 @@ module Studio
708
711
  Booking.path_for(booking_path, view, drawn: draw_booking_routes)
709
712
  end
710
713
 
714
+ # ── Surveys (docs/SURVEYS.md) ──────────────────────────────────────────────
715
+ # The public survey pages, /surveys/:slug (Studio::SurveysController). OPT-IN:
716
+ # `/surveys` is a path an app may already own, and a duplicate helper name
717
+ # raises while the host's routes.rb loads, which kills every route in it.
718
+ #
719
+ # config.draw_survey_routes = true
720
+ mattr_accessor :draw_survey_routes, default: false
721
+
722
+ # The admin results panel, /admin/surveys (Studio::AdminSurveysController).
723
+ # OPT-IN for the same reason, and independent of the public pages, so an app
724
+ # can read results it collects elsewhere.
725
+ #
726
+ # config.draw_admin_survey_routes = true
727
+ mattr_accessor :draw_admin_survey_routes, default: false
728
+
729
+ # Where the app's survey definitions live, relative to Rails.root. Every *.rb
730
+ # in it is loaded at boot and on each development reload.
731
+ mattr_accessor :survey_definitions_path, default: "config/surveys"
732
+
733
+ # Attribution for an email arrival: ->(controller) { String | nil }. Called on
734
+ # every survey request; its answer is stamped on the response as email_ref the
735
+ # first time one is present. Cyvasse passes its EmailReferral ref here.
736
+ mattr_accessor :survey_ref_resolver, default: ->(_controller) {}
737
+
738
+ # Called once, after a response completes: ->(response) { ... }. A raise is
739
+ # logged and swallowed — the respondent still lands on the thank-you screen.
740
+ mattr_accessor :on_survey_completed, default: ->(_response) {}
741
+
742
+ # Define a survey in app code. See Studio::Survey for the DSL.
743
+ def self.define_survey(slug, &block)
744
+ Survey.define(slug, &block)
745
+ end
746
+
747
+ # Every defined survey, by slug.
748
+ def self.surveys
749
+ Survey.all
750
+ end
751
+
752
+ def self.survey(slug)
753
+ Survey.find(slug)
754
+ end
755
+
711
756
  # THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
712
757
  # page; a Hash of the schedule's measured box crops the frame to the slot
713
758
  # picker until it is used. The numbers belong to ONE schedule, so there is no
@@ -1558,6 +1603,25 @@ module Studio
1558
1603
  patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
1559
1604
  constraints: { variant: /[a-z_]+/ }
1560
1605
 
1606
+ # Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
1607
+ # Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
1608
+ if Studio.draw_survey_routes
1609
+ slug = { slug: Studio::Survey::ROUTE_SLUG }
1610
+ get "surveys/:slug", to: "studio/surveys#show", as: :studio_survey, constraints: slug
1611
+ post "surveys/:slug", to: "studio/surveys#submit", constraints: slug
1612
+ patch "surveys/:slug/answers/:key", to: "studio/surveys#answer", as: :studio_survey_answer,
1613
+ constraints: slug.merge(key: Studio::Survey::ROUTE_KEY)
1614
+ get "surveys/:slug/thanks", to: "studio/surveys#thanks", as: :studio_survey_thanks, constraints: slug
1615
+ end
1616
+
1617
+ if Studio.draw_admin_survey_routes
1618
+ get "admin/surveys", to: "studio/admin_surveys#index", as: :admin_surveys
1619
+ get "admin/surveys/:slug/export", to: "studio/admin_surveys#export", as: :admin_survey_export,
1620
+ constraints: { slug: %r{[^/.]+} }, defaults: { format: :csv }
1621
+ get "admin/surveys/:slug", to: "studio/admin_surveys#show", as: :admin_survey,
1622
+ constraints: { slug: %r{[^/.]+} }
1623
+ end
1624
+
1561
1625
  # Model-page protocol (v1) — a reusable per-record inspector. Drawn into
1562
1626
  # every consuming app: /models/:model/:id renders one record as pretty JSON
1563
1627
  # plus a copy/paste rails-console command; /models/:model/random bounces to
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.87.0
4
+ version: 0.88.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -336,6 +336,7 @@ files:
336
336
  - app/controllers/schema_controller.rb
337
337
  - app/controllers/sessions_controller.rb
338
338
  - app/controllers/solana_sessions_controller.rb
339
+ - app/controllers/studio/admin_surveys_controller.rb
339
340
  - app/controllers/studio/bookings_controller.rb
340
341
  - app/controllers/studio/email_images_controller.rb
341
342
  - app/controllers/studio/emails_controller.rb
@@ -350,6 +351,7 @@ files:
350
351
  - app/controllers/studio/public_users_controller.rb
351
352
  - app/controllers/studio/session_states_controller.rb
352
353
  - app/controllers/studio/site_identities_controller.rb
354
+ - app/controllers/studio/surveys_controller.rb
353
355
  - app/controllers/style_controller.rb
354
356
  - app/controllers/theme_settings_controller.rb
355
357
  - app/helpers/studio/admin_models_table_helper.rb
@@ -386,6 +388,7 @@ files:
386
388
  - app/models/studio/link.rb
387
389
  - app/models/studio/model_page.rb
388
390
  - app/models/studio/site_identity.rb
391
+ - app/models/studio/survey_response.rb
389
392
  - app/models/theme_setting.rb
390
393
  - app/services/google_oauth_validator.rb
391
394
  - app/services/studio/banner.rb
@@ -439,6 +442,8 @@ files:
439
442
  - app/views/studio/admin_models/_teams_table.html.erb
440
443
  - app/views/studio/admin_models/index.html.erb
441
444
  - app/views/studio/admin_models/show.html.erb
445
+ - app/views/studio/admin_surveys/index.html.erb
446
+ - app/views/studio/admin_surveys/show.html.erb
442
447
  - app/views/studio/banners/_app_banner.html.erb
443
448
  - app/views/studio/banners/_button.html.erb
444
449
  - app/views/studio/banners/_dev_mode_button.html.erb
@@ -544,6 +549,13 @@ files:
544
549
  - app/views/studio/site_footer/_map_assets.html.erb
545
550
  - app/views/studio/site_footer/_social_icon.html.erb
546
551
  - app/views/studio/site_identities/edit.html.erb
552
+ - app/views/studio/surveys/_question.html.erb
553
+ - app/views/studio/surveys/_script.html.erb
554
+ - app/views/studio/surveys/_styles.html.erb
555
+ - app/views/studio/surveys/not_found.html.erb
556
+ - app/views/studio/surveys/show.html.erb
557
+ - app/views/studio/surveys/sign_in_required.html.erb
558
+ - app/views/studio/surveys/thanks.html.erb
547
559
  - app/views/style/_host.html.erb
548
560
  - app/views/style/_modal_specimen.html.erb
549
561
  - app/views/style/_modals.html.erb
@@ -585,6 +597,7 @@ files:
585
597
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
586
598
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
587
599
  - db/migrate/20260930120000_create_studio_site_identities.rb
600
+ - db/migrate/20261005120000_create_studio_survey_responses.rb
588
601
  - docs/BOOKING.md
589
602
  - docs/SITE_FOOTER.md
590
603
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -627,6 +640,9 @@ files:
627
640
  - lib/studio/session_state.rb
628
641
  - lib/studio/sidebar_sections.rb
629
642
  - lib/studio/site_footer.rb
643
+ - lib/studio/survey.rb
644
+ - lib/studio/survey/breakdown.rb
645
+ - lib/studio/survey/export.rb
630
646
  - lib/studio/theme_resolver.rb
631
647
  - lib/studio/ui_primitives.rb
632
648
  - lib/studio/username_generator.rb