diff --git a/app/components/app_card.rb b/app/components/app_card.rb
new file mode 100644
index 0000000..c326ce2
--- /dev/null
+++ b/app/components/app_card.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class Components::AppCard < Components::Base
+ def initialize(app:)
+ @app = app
+ end
+
+ def view_template
+ if @app[:special]
+ render_link_card
+ else
+ render_saml_card
+ end
+ end
+
+ private
+
+ def render_saml_card
+ form_with(url: idp_initiated_saml_path(slug: @app[:slug]), method: :post, html: { class: "sso-app-card-form", target: "_blank" }) do
+ button(type: "submit", class: "sso-app-card secondary") do
+ render_card_content(launch_text: t("home.apps.launch"))
+ end
+ end
+ end
+
+ def render_link_card
+ div(class: "sso-app-card-form") do
+ a(href: @app[:url], class: "sso-app-card secondary", target: "_blank") do
+ render_card_content(launch_text: @app[:launch_text] || t("home.apps.launch"))
+ end
+ end
+ end
+
+ def render_card_content(launch_text:)
+ div(class: "card-header") do
+ div(class: "app-icon") do
+ if @app[:icon].present?
+ vite_image_tag("images/sso_apps/#{@app[:icon]}")
+ else
+ span { @app[:friendly_name][0] }
+ end
+ end
+
+ div(class: "app-info") do
+ h3 { @app[:friendly_name] }
+ p(class: "app-tagline") { @app[:tagline] }
+ end
+ end
+
+ div(class: "card-footer") do
+ span(class: "launch-text") do
+ plain launch_text
+ inline_icon "external", size: 24
+ end
+ end
+ end
+end
diff --git a/app/components/sso_app_grid.rb b/app/components/sso_app_grid.rb
index c151158..3ac8ebf 100644
--- a/app/components/sso_app_grid.rb
+++ b/app/components/sso_app_grid.rb
@@ -1,18 +1,21 @@
# frozen_string_literal: true
class Components::SSOAppGrid < Components::Base
- def initialize(apps:)
+ def initialize(apps:, special_apps: [])
@apps = apps
+ @special_apps = special_apps
end
def view_template
div(class: "sso-app-grid") do
h2 { t "home.apps.heading" }
- if @apps.any?
+ all_apps = @apps + @special_apps.map(&:to_h)
+
+ if all_apps.any?
div(class: "grid") do
- @apps.each do |app|
- render Components::SSOAppCard.new(app: app)
+ all_apps.each do |app|
+ render Components::AppCard.new(app: app)
end
end
else
diff --git a/app/controllers/backend/programs_controller.rb b/app/controllers/backend/programs_controller.rb
index b24b88f..6fe1022 100644
--- a/app/controllers/backend/programs_controller.rb
+++ b/app/controllers/backend/programs_controller.rb
@@ -78,6 +78,10 @@ class Backend::ProgramsController < Backend::ApplicationController
permitted_params += [ :description, :active, :trust_level, scopes_array: [] ]
end
+ if policy(@program).update_onboarding_scenario?
+ permitted_params << :onboarding_scenario
+ end
+
params.require(:program).permit(permitted_params)
end
end
diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb
index ba55247..95d9a23 100644
--- a/app/controllers/identities_controller.rb
+++ b/app/controllers/identities_controller.rb
@@ -153,9 +153,32 @@ class IdentitiesController < ApplicationController
scenario = OnboardingScenarios::Base.find_by_slug(params[:slug])
return scenario if scenario
end
+
+ # Check if this is an OAuth flow with a program that has a custom onboarding scenario
+ if (scenario = scenario_from_oauth_return_to)
+ return scenario
+ end
+
OnboardingScenarios::DefaultJoin
end
+ def scenario_from_oauth_return_to
+ return nil unless params[:return_to].present?
+ return nil unless params[:return_to].start_with?("/oauth/authorize")
+
+ uri = URI.parse(params[:return_to])
+ query_params = URI.decode_www_form(uri.query || "").to_h
+ client_id = query_params["client_id"]
+ return nil unless client_id
+
+ program = Program.find_by(uid: client_id)
+ return nil unless program&.onboarding_scenario.present?
+
+ OnboardingScenarios::Base.find_by_slug(program.onboarding_scenario)
+ rescue URI::InvalidURIError
+ nil
+ end
+
def scenario_prefill_attributes
return {} unless params[:identity].present?
extractor = @onboarding_scenario.extract_params_proc
diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb
index b01c2e0..1b5e840 100644
--- a/app/controllers/static_pages_controller.rb
+++ b/app/controllers/static_pages_controller.rb
@@ -3,6 +3,7 @@ class StaticPagesController < ApplicationController
def home
@sso_apps = SAMLService::Entities.service_providers.values.select { |sp| sp[:allow_idp_initiated] }
+ @special_apps = SpecialAppCards::Base.for_identity(current_identity)
end
def welcome
diff --git a/app/frontend/images/sso_apps/flavortown.png b/app/frontend/images/sso_apps/flavortown.png
new file mode 100644
index 0000000..3a43045
Binary files /dev/null and b/app/frontend/images/sso_apps/flavortown.png differ
diff --git a/app/frontend/stylesheets/snippets/sso_apps.scss b/app/frontend/stylesheets/snippets/sso_apps.scss
index 4a4ffaa..30ae69e 100644
--- a/app/frontend/stylesheets/snippets/sso_apps.scss
+++ b/app/frontend/stylesheets/snippets/sso_apps.scss
@@ -32,9 +32,13 @@
.sso-app-card-form {
margin: 0;
+ height: 100%;
}
-button.sso-app-card.secondary {
+.sso-app-card.secondary {
+ text-decoration: none;
+ height: 100%;
+
@include card($padding: 1.75rem, $radius: $radius-lg);
display: flex;
flex-direction: column;
@@ -106,6 +110,7 @@ button.sso-app-card.secondary {
.app-info {
flex: 1;
min-width: 0;
+ text-align: left;
h3 {
font-size: 1.125rem;
@@ -134,6 +139,7 @@ button.sso-app-card.secondary {
margin-top: auto;
padding-top: $space-3;
border-top: 1px solid var(--pico-card-border-color);
+ text-align: center;
.launch-text {
font-size: 0.9375rem;
diff --git a/app/models/onboarding_scenarios/base.rb b/app/models/onboarding_scenarios/base.rb
index a3de715..75d3d9f 100644
--- a/app/models/onboarding_scenarios/base.rb
+++ b/app/models/onboarding_scenarios/base.rb
@@ -8,6 +8,10 @@ module OnboardingScenarios
return nil if slug.blank?
descendants&.find { |k| k.slug && k.slug.to_s == slug.to_s }
end
+
+ def available_slugs
+ descendants&.filter_map(&:slug)&.sort || []
+ end
end
def initialize(identity)
@@ -54,5 +58,44 @@ module OnboardingScenarios
# Whether Ralsei should message users via DM instead of a channel
def use_dm_channel? = false
+
+ # Define the dialogue flow as an ordered list of steps
+ # Each step maps to a template and optionally defines the next step
+ def dialogue_flow
+ {
+ intro: { template: "tutorial/01_intro", next: :hacker_values },
+ hacker_values: { template: "tutorial/02_hacker_values", next: :welcome },
+ welcome: { template: "tutorial/03_welcome", next: nil }
+ }
+ end
+
+ # The first step in the flow
+ def first_step = :intro
+
+ # Get step config
+ def step_config(step) = dialogue_flow[step.to_sym]
+
+ # Resolve step to template path
+ def template_for(step)
+ dialogue_flow.dig(step.to_sym, :template) || "tutorial/#{step}"
+ end
+
+ # Get next step in the flow
+ def next_step(current_step)
+ dialogue_flow.dig(current_step.to_sym, :next)
+ end
+
+ # Bot persona - override to customize name/avatar
+ def bot_name = nil
+ def bot_icon_url = nil
+
+ # Custom dialogue flow hooks - override in subclasses
+ def before_first_message = nil
+ def after_promotion = nil
+
+ # Handle custom actions - return step symbol, template string, or hash
+ def handle_action(action_id) = nil
+
+ private def chans(*keys) = Rails.configuration.slack_channels.slice(*keys).values
end
end
diff --git a/app/models/onboarding_scenarios/flavortown.rb b/app/models/onboarding_scenarios/flavortown.rb
new file mode 100644
index 0000000..013b26a
--- /dev/null
+++ b/app/models/onboarding_scenarios/flavortown.rb
@@ -0,0 +1,54 @@
+module OnboardingScenarios
+ class Flavortown < Base
+ def self.slug = "flavortown"
+
+ def title = "ready to enroll in cooking school?"
+
+ def form_fields
+ [ :first_name, :last_name, :primary_email, :birthday, :country ]
+ end
+
+ def slack_user_type = :multi_channel_guest
+
+ def next_action = :home
+
+ def slack_onboarding_flow = :internal_tutorial
+
+ def slack_channels = chans(:flavortown_bulletin, :flavortown_esplanade, :flavortown_help, :identity_help)
+
+ def promotion_channels = chans(:flavortown_construction, :library, :lounge, :welcome, :happenings, :community, :neighbourhood)
+
+ def use_dm_channel? = true
+
+ def bot_name = "Flavorpheus"
+ def bot_icon_url = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/3bc2db7b9c62b15230a4c1bcefca7131a6c491d2_icon_1.png"
+
+ def first_step = :welcome
+
+ def dialogue_flow
+ {
+ welcome: "flavortown/01_welcome",
+ kitchen_code: "flavortown/02_kitchen_code",
+ taste_test: "flavortown/03_taste_test",
+ taste_wrong: "flavortown/03b_taste_wrong",
+ taste_gave_up: "flavortown/03c_taste_incredibly_wrong",
+ taste_terrible: "flavortown/03d_taste_terrible",
+ dino_nuggets: "flavortown/03e_dino_nuggets",
+ promoted: "flavortown/04_promoted"
+ }
+ end
+
+ def handle_action(action_id)
+ case action_id
+ when "flavortown_continue" then :kitchen_code
+ when "flavortown_agree" then :taste_test
+ when "flavortown_taste_correct" then { step: :promoted, promote: true }
+ when "flavortown_taste_wrong" then :taste_wrong
+ when "flavortown_try_again" then :taste_test
+ when "flavortown_taste_wrong_again" then :taste_gave_up
+ when "flavortown_taste_incredibly_wrong" then :taste_terrible
+ when "flavortown_dino_nuggets" then :dino_nuggets
+ end
+ end
+ end
+end
diff --git a/app/models/special_app_cards.rb b/app/models/special_app_cards.rb
new file mode 100644
index 0000000..ec52a51
--- /dev/null
+++ b/app/models/special_app_cards.rb
@@ -0,0 +1,2 @@
+module SpecialAppCards
+end
diff --git a/app/models/special_app_cards/base.rb b/app/models/special_app_cards/base.rb
new file mode 100644
index 0000000..88730b2
--- /dev/null
+++ b/app/models/special_app_cards/base.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module SpecialAppCards
+ class Base
+ class << self
+ def all
+ @all ||= []
+ end
+
+ def inherited(subclass)
+ super
+ all << subclass
+ end
+
+ def for_identity(identity)
+ all.filter_map { |klass| klass.new(identity) if klass.new(identity).visible? }
+ end
+ end
+
+ attr_reader :identity
+
+ def initialize(identity)
+ @identity = identity
+ end
+
+ def visible?
+ raise NotImplementedError, "Subclasses must implement #visible?"
+ end
+
+ def friendly_name
+ raise NotImplementedError, "Subclasses must implement #friendly_name"
+ end
+
+ def tagline
+ raise NotImplementedError, "Subclasses must implement #tagline"
+ end
+
+ def icon = nil
+
+ def url
+ raise NotImplementedError, "Subclasses must implement #url"
+ end
+
+ def launch_text = nil
+
+ def to_h
+ {
+ friendly_name: friendly_name,
+ tagline: tagline,
+ icon: icon,
+ url: url,
+ launch_text: launch_text,
+ special: true
+ }
+ end
+ end
+end
diff --git a/app/models/special_app_cards/flavortown.rb b/app/models/special_app_cards/flavortown.rb
new file mode 100644
index 0000000..8338bb5
--- /dev/null
+++ b/app/models/special_app_cards/flavortown.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module SpecialAppCards
+ class Flavortown < Base
+ def visible?
+ identity.ysws_eligible != false && Flipper.enabled?(:flavortown, identity)
+ end
+
+ def friendly_name = "Flavortown"
+
+ def tagline = "anyone can cook!"
+
+ def icon = "flavortown.png"
+
+ def url = "https://flavortown.hackclub.com"
+
+ def launch_text = "to the kitchen!"
+ end
+end
diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb
index d10bd58..67afd57 100644
--- a/app/policies/program_policy.rb
+++ b/app/policies/program_policy.rb
@@ -13,6 +13,8 @@ class ProgramPolicy < ApplicationPolicy
def update_scopes? = user_is_program_manager?
+ def update_onboarding_scenario? = user&.super_admin?
+
class Scope < Scope
def resolve
if user.program_manager? || user.super_admin?
diff --git a/app/services/ralsei_engine.rb b/app/services/ralsei_engine.rb
index 02db1ea..392d56e 100644
--- a/app/services/ralsei_engine.rb
+++ b/app/services/ralsei_engine.rb
@@ -2,39 +2,98 @@ module RalseiEngine
class << self
RALSEI_PFP = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/6cc8caeeff906502bfe60ba2f3db34cdf79a237d_ralsei2.png"
- def send_first_message(identity) = send_message(identity, "tutorial/01_intro")
+ def send_first_message(identity)
+ scenario = identity.onboarding_scenario_instance
+ scenario&.before_first_message
+ first_step = scenario&.first_step || :intro
+ send_step(identity, first_step)
+ end
- def send_first_message_part2(identity) = send_message(identity, "tutorial/02_hacker_values")
+ def send_first_message_part2(identity) = send_step(identity, :hacker_values)
def handle_tutorial_agree(identity)
Rails.logger.info "RalseiEngine: #{identity.public_id} agreed to tutorial"
+ scenario = identity.onboarding_scenario_instance
if identity.promote_click_count == 0
SlackService.promote_user(identity.slack_id)
- promotion_channels = identity.onboarding_scenario_instance.promotion_channels
+ promotion_channels = scenario&.promotion_channels
if promotion_channels.present?
SlackService.add_to_channels(user_id: identity.slack_id, channel_ids: promotion_channels)
end
else
Rails.logger.info "RalseiEngine: #{identity.public_id} is already a full member"
end
- send_message(identity, "tutorial/03_welcome")
+
+ scenario&.after_promotion
+ send_step(identity, :welcome)
identity.increment!(:promote_click_count, 1)
end
+ def send_step(identity, step)
+ scenario = identity.onboarding_scenario_instance
+ template = scenario&.template_for(step) || "tutorial/#{step}"
+ send_message(identity, template)
+ end
+
+ def advance_to_next(identity, current_step)
+ scenario = identity.onboarding_scenario_instance
+ next_step = scenario&.next_step(current_step)
+ send_step(identity, next_step) if next_step
+ next_step
+ end
+
+ def handle_action(identity, action_id)
+ scenario = identity.onboarding_scenario_instance
+ return false unless scenario
+
+ result = scenario.handle_action(action_id)
+ return false unless result
+
+ case result
+ when Symbol
+ send_step(identity, result)
+ when String
+ send_message(identity, result)
+ when Hash
+ promote_user(identity) if result[:promote]
+ send_step(identity, result[:step]) if result[:step]
+ send_message(identity, result[:template]) if result[:template]
+ end
+
+ true
+ end
+
+ def promote_user(identity)
+ return if identity.promote_click_count > 0
+
+ scenario = identity.onboarding_scenario_instance
+ SlackService.promote_user(identity.slack_id)
+
+ promotion_channels = scenario&.promotion_channels
+ if promotion_channels.present?
+ SlackService.add_to_channels(user_id: identity.slack_id, channel_ids: promotion_channels)
+ end
+
+ scenario&.after_promotion
+ identity.increment!(:promote_click_count, 1)
+ Rails.logger.info "RalseiEngine: promoted #{identity.public_id}"
+ end
+
def send_message(identity, template_name)
return unless identity.slack_id.present?
channel_id = resolve_channel(identity)
return unless channel_id
+ scenario = identity.onboarding_scenario_instance
payload = render_template("slack/#{template_name}", identity)
client.chat_postMessage(
channel: channel_id,
- username: "Ralsei",
- icon_url: RALSEI_PFP,
+ username: scenario&.bot_name || "Ralsei",
+ icon_url: scenario&.bot_icon_url || RALSEI_PFP,
**JSON.parse(payload, symbolize_names: true),
unfurl_links: false,
)
diff --git a/app/views/backend/programs/show.html.erb b/app/views/backend/programs/show.html.erb
index 5e25a47..ca2b6d3 100644
--- a/app/views/backend/programs/show.html.erb
+++ b/app/views/backend/programs/show.html.erb
@@ -36,6 +36,12 @@
users
<%= @identities_count %>
+ <% if @program.onboarding_scenario.present? %>
+
+ onboarding
+ <%= @program.onboarding_scenario.titleize %>
+
+ <% end %>
diff --git a/app/views/forms/backend/programs/form.rb b/app/views/forms/backend/programs/form.rb
index 32bea76..f930b24 100644
--- a/app/views/forms/backend/programs/form.rb
+++ b/app/views/forms/backend/programs/form.rb
@@ -30,6 +30,28 @@ class Backend::Programs::Form < ApplicationForm
end
end
+ super_admin_tool do
+ div style: "margin: 1rem 0;" do
+ label(class: "field-label") { "Onboarding Scenario:" }
+ select(
+ name: "program[onboarding_scenario]",
+ class: "input-field",
+ style: "width: 100%; margin-bottom: 1rem;"
+ ) do
+ option(value: "", selected: model.onboarding_scenario.blank?) { "(default)" }
+ OnboardingScenarios::Base.available_slugs.each do |slug|
+ option(
+ value: slug,
+ selected: model.onboarding_scenario == slug
+ ) { slug.titleize }
+ end
+ end
+ small(style: "display: block; color: var(--muted-color); margin-top: -0.5rem;") do
+ plain "When users sign up through this OAuth app, they'll use this onboarding flow"
+ end
+ end
+ end
+
div style: "margin: 1rem 0;" do
label(class: "field-label") { "OAuth Scopes:" }
# Hidden field to ensure empty scopes array is submitted when no checkboxes are checked
diff --git a/app/views/slack/flavortown/01_welcome.slack_message.slocks b/app/views/slack/flavortown/01_welcome.slack_message.slocks
new file mode 100644
index 0000000..90104a4
--- /dev/null
+++ b/app/views/slack/flavortown/01_welcome.slack_message.slocks
@@ -0,0 +1,25 @@
+header "🍳 Welcome to Flavortown!"
+
+section "Hello, #{@identity.first_name}! Pull up a chair — the stove's warm and there's coffee on.", markdown: true
+
+divider
+
+section <<~MSG, markdown: true
+ _Flavorpheus slides a menu across the counter toward you._
+
+ *_"The Flavortown Kitchen Handbook (3rd ed.)"_, Chef Flavorpheus*
+
+ _scrawled on a sticky note:_
+
+ > Cook with heart, share with friends.
+
+ _printed neatly below:_
+ > *The Flavortown Code*, _the short version_:
+ > • Be generous — with your time, your knowledge, and your leftovers.
+ > • Keep it clean — in the kitchen and in conversation.
+ > • Experiment boldly — but respect the fundamentals.
+MSG
+
+actions [
+ button("okay, chef!", "flavortown_continue", style: "primary")
+]
diff --git a/app/views/slack/flavortown/02_kitchen_code.slack_message.slocks b/app/views/slack/flavortown/02_kitchen_code.slack_message.slocks
new file mode 100644
index 0000000..0503a40
--- /dev/null
+++ b/app/views/slack/flavortown/02_kitchen_code.slack_message.slocks
@@ -0,0 +1,18 @@
+section <<~MSG, markdown: true
+ _Flavorpheus leans against the counter, arms crossed._
+
+ This kitchen runs on trust. If you ever have a problem with another cook — burnt feelings, crossed wires, anything — reach out to our confidential moderation team:
+ email conduct@hackclub.com or DM <@U07K4TS9HQE>.
+
+ You can read the full anytime.
+MSG
+
+context [
+ mrkdwn_text("By joining, you agreed to Slack's & .")
+]
+
+section '_Flavorpheus raises an eyebrow._ "So — you ready to cook by the code?"', markdown: true
+
+actions [
+ button("let's cook!", "flavortown_agree", style: "primary")
+]
diff --git a/app/views/slack/flavortown/03_taste_test.slack_message.slocks b/app/views/slack/flavortown/03_taste_test.slack_message.slocks
new file mode 100644
index 0000000..1ae5103
--- /dev/null
+++ b/app/views/slack/flavortown/03_taste_test.slack_message.slocks
@@ -0,0 +1,20 @@
+header "The Taste Test"
+
+section <<~MSG, markdown: true
+ _Flavorpheus sets three small bowls in front of you._
+
+ Alright, one last thing. Every cook here knows the secret ingredient. Let's see if you do too.
+
+ *What makes a dish truly great?*
+MSG
+
+require_relative "_taste_answers"
+
+wrong_answers = FLAVORTOWN_WRONG_ANSWERS.map { |label, action| button(label, action) }
+terrible_answers = FLAVORTOWN_TERRIBLE_ANSWERS.map { |label, action| button(label, action) }
+
+actions [
+ button("Love", "flavortown_taste_correct", style: "primary"),
+ *wrong_answers.sample(2),
+ *terrible_answers.sample(3)
+].shuffle
diff --git a/app/views/slack/flavortown/03b_taste_wrong.slack_message.slocks b/app/views/slack/flavortown/03b_taste_wrong.slack_message.slocks
new file mode 100644
index 0000000..b1e0c54
--- /dev/null
+++ b/app/views/slack/flavortown/03b_taste_wrong.slack_message.slocks
@@ -0,0 +1,22 @@
+header "The Taste Test (again)"
+
+section <<~MSG, markdown: true
+ _Flavorpheus winces slightly._
+
+ Mmm... not quite. That's a fine ingredient, but it's not *the* ingredient.
+
+ Think about what really makes food worth sharing. Let's try again:
+
+ *What makes a dish truly great?*
+MSG
+
+require_relative "_taste_answers"
+
+wrong_answers = FLAVORTOWN_WRONG_ANSWERS.map { |label, _| button(label, "flavortown_taste_wrong_again") }
+terrible_answers = FLAVORTOWN_TERRIBLE_ANSWERS.map { |label, _| button(label, "flavortown_taste_wrong_again") }
+
+actions [
+ button("Love", "flavortown_taste_correct", style: "primary"),
+ *wrong_answers.sample(2),
+ *terrible_answers.sample(1)
+].shuffle
diff --git a/app/views/slack/flavortown/03c_taste_incredibly_wrong.slack_message.slocks b/app/views/slack/flavortown/03c_taste_incredibly_wrong.slack_message.slocks
new file mode 100644
index 0000000..69fda0a
--- /dev/null
+++ b/app/views/slack/flavortown/03c_taste_incredibly_wrong.slack_message.slocks
@@ -0,0 +1,13 @@
+section <<~MSG, markdown: true
+ _Flavorpheus stares at you for a long moment._
+
+ ...okay, I'm starting to think you're doing this on purpose.
+
+ *sighs and pinches the bridge of her nose*
+
+ The answer is *love*, chef. It's always love. It's literally the most cliché answer possible and yet here we are.
+MSG
+
+actions [
+ button("...love?", "flavortown_taste_correct", style: "primary")
+]
diff --git a/app/views/slack/flavortown/03d_taste_terrible.slack_message.slocks b/app/views/slack/flavortown/03d_taste_terrible.slack_message.slocks
new file mode 100644
index 0000000..3e48921
--- /dev/null
+++ b/app/views/slack/flavortown/03d_taste_terrible.slack_message.slocks
@@ -0,0 +1,19 @@
+section <<~MSG, markdown: true
+ _Flavorpheus stares at you in silence._
+
+ ...
+
+ _She takes off her hat. Sets it on the counter. Breathes deeply._
+
+ I'm not mad. I'm just... disappointed.
+
+ *Why* would you say that. In a *kitchen*. To a *chef*.
+
+ _She puts the hat back on, composing herself._
+
+ Let's... try this again. Please.
+MSG
+
+actions [
+ button("I'm sorry :peefest:", "flavortown_try_again", style: "primary")
+]
diff --git a/app/views/slack/flavortown/03e_dino_nuggets.slack_message.slocks b/app/views/slack/flavortown/03e_dino_nuggets.slack_message.slocks
new file mode 100644
index 0000000..1830fb3
--- /dev/null
+++ b/app/views/slack/flavortown/03e_dino_nuggets.slack_message.slocks
@@ -0,0 +1,31 @@
+section <<~MSG, markdown: true
+ _Flavorpheus freezes._
+
+ _The kitchen goes silent. A pot stops boiling. Somewhere, a soufflé collapses._
+
+ ...Excuse me?
+
+ *EXCUSE ME?*
+
+ _She slams a spatula on the counter._
+
+ Do you have ANY idea— I am LITERALLY— that is MY FAMILY you're—
+
+ _She takes a shaky breath, gripping the counter edge._
+
+ You come into MY kitchen. You look ME in the eye. And you say *DINO NUGGETS*?!
+
+ _She's pacing now._
+
+ I KNEW someone would try this. I KNEW IT. Every single time. "Haha funny dinosaur chef, let's see how she handles the nugget question."
+
+ WELL I'LL TELL YOU HOW I HANDLE IT.
+
+ _She points a claw directly at you._
+
+ You're gonna sit there. You're gonna THINK about what you just said. And then you're gonna give me the REAL answer.
+MSG
+
+actions [
+ button("I'm so sorry", "flavortown_try_again", style: "primary")
+]
diff --git a/app/views/slack/flavortown/04_promoted.slack_message.slocks b/app/views/slack/flavortown/04_promoted.slack_message.slocks
new file mode 100644
index 0000000..f8238bb
--- /dev/null
+++ b/app/views/slack/flavortown/04_promoted.slack_message.slocks
@@ -0,0 +1,167 @@
+chan_esplanade = Rails.configuration.slack_channels.flavortown_esplanade
+chan_bulletin = Rails.configuration.slack_channels.flavortown_bulletin
+chan_help = Rails.configuration.slack_channels.flavortown_help
+chan_construction = Rails.configuration.slack_channels.flavortown_construction
+
+case @identity.promote_click_count
+when 0
+ header "🎉 Welcome to the crew!"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus grins and tosses you an apron._
+
+ That's the one, #{@identity.first_name}. Love. Gets 'em every time.
+
+ ...I also would have accepted "garlic".
+
+ You're officially part of Flavortown now. The kitchen's yours to explore — go make something great.
+ MSG
+
+ divider
+
+ section <<~MSG, markdown: true
+ *<##{chan_esplanade}>* is the main hangout — introduce yourself, swap recipes, show off what you're cooking.
+
+ *<##{chan_bulletin}>* is where announcements go up — specials, events, the good stuff.
+
+ *<##{chan_help}>* if you need a hand with anything.
+
+ Now get in there and say hi! 🍕
+ MSG
+
+when 1
+ header "✨ you're already in!"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus looks up from the grill._
+
+ Hey, you're already on the crew! How's it going — found your way around yet?
+ MSG
+
+ divider
+
+ section <<~MSG, markdown: true
+ Quick refresher:
+
+ *<##{chan_esplanade}>* — the main hangout
+ *<##{chan_bulletin}>* — announcements
+ *<##{chan_help}>* — if you need anything
+
+ Make yourself at home, chef. 🍳
+ MSG
+
+when 2
+ header "back for seconds?"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus raises an eyebrow but smiles._
+
+ You already passed the taste test! Unless... you just wanted to hang out?
+
+ That's cool. The coffee's fresh. But the real action's in <##{chan_esplanade}>.
+ MSG
+
+when 3
+ header "okay, I see you"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus flips a spatula absently._
+
+ You really like this button, huh? I respect the commitment.
+
+ But seriously — <##{chan_esplanade}> has actual people to talk to. Just saying.
+ MSG
+
+when 4
+ header "are you... testing me?"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus sets down the spatula._
+
+ Four times. You've clicked this four times. Is this a quality assurance thing? Are you from the health department?
+
+ ...I'm kidding. Mostly. Go hang out in <##{chan_esplanade}>, chef. 😅
+ MSG
+
+when 5
+ header "alright, alright"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus pours two cups of coffee and slides one over._
+
+ Look, at this point we're friends. Sit down. Tell me about yourself.
+
+ Or go to <##{chan_esplanade}> and tell *them* about yourself. Your call. ☕
+ MSG
+
+when 6
+ header "the button whisperer"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus leans back and crosses her arms._
+
+ You know what? I admire your persistence. In the kitchen, that kind of dedication makes a great chef.
+
+ Channel it into <##{chan_construction}> and build something cool. For me? 🔧
+ MSG
+
+when 7
+ header "okay chef, I get it"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus sighs, but there's warmth in it._
+
+ You're not gonna stop, are you? Fine. I'll be here. We can do this forever.
+
+ *takes a long sip of coffee*
+
+ ...but <##{chan_esplanade}> is *right there*. 💚
+ MSG
+
+else
+ case @identity.promote_click_count % 4
+ when 0
+ header "regular customer"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus waves without looking up._
+
+ The usual? One "you're already a member" with a side of "please go to <##{chan_esplanade}>"?
+
+ Coming right up. 🍳
+ MSG
+
+ when 1
+ header "ah, my favorite button-clicker"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus is now just casually reading a cookbook._
+
+ Page 47 has a great soufflé recipe. Way more exciting than this button. Just saying.
+
+ <##{chan_esplanade}> also exists. In case you forgot. 💚
+ MSG
+
+ when 2
+ header "the legend returns"
+
+ section <<~MSG, markdown: true
+ _Flavorpheus doesn't even look surprised._
+
+ #{@identity.promote_click_count + 1} clicks. That's dedication. That's *art*.
+
+ Now imagine channeling that energy into an actual recipe. <##{chan_construction}> is waiting. ✨
+ MSG
+
+ when 3
+ header "one more click..."
+
+ section <<~MSG, markdown: true
+ _Flavorpheus smiles warmly._
+
+ You know, every click you make here is a click you're not making in <##{chan_esplanade}>.
+
+ But I appreciate the company. See you next time, chef. 🍕
+ MSG
+ end
+end
diff --git a/app/views/slack/flavortown/_taste_answers.rb b/app/views/slack/flavortown/_taste_answers.rb
new file mode 100644
index 0000000..b9c89b6
--- /dev/null
+++ b/app/views/slack/flavortown/_taste_answers.rb
@@ -0,0 +1,16 @@
+FLAVORTOWN_WRONG_ANSWERS = [
+ [ "More butter", "flavortown_taste_wrong" ],
+ [ "MSG", "flavortown_taste_wrong" ],
+ [ "Salt", "flavortown_taste_wrong" ]
+].freeze
+
+FLAVORTOWN_TERRIBLE_ANSWERS = [
+ [ "Poison", "flavortown_taste_incredibly_wrong" ],
+ [ "Bone hurting juice", "flavortown_taste_incredibly_wrong" ],
+ [ "Bone apple tea", "flavortown_taste_incredibly_wrong" ],
+ [ "Ómélêttè du fròmage", "flavortown_taste_incredibly_wrong" ],
+ [ "Spite", "flavortown_taste_incredibly_wrong" ],
+ [ "Raw chicken", "flavortown_taste_incredibly_wrong" ],
+ [ "One day blinding stew", "flavortown_taste_incredibly_wrong" ],
+ [ "Dino nuggets", "flavortown_dino_nuggets" ]
+].freeze
diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb
index 5a10379..6368ea1 100644
--- a/app/views/static_pages/home.html.erb
+++ b/app/views/static_pages/home.html.erb
@@ -13,4 +13,4 @@
<%= render profile_completion %>
<% end %>
-<%= render Components::SSOAppGrid.new(apps: @sso_apps) %>
+<%= render Components::SSOAppGrid.new(apps: @sso_apps, special_apps: @special_apps) %>
diff --git a/config/slack_channels.yml b/config/slack_channels.yml
index a1fc2be..f33f2ef 100644
--- a/config/slack_channels.yml
+++ b/config/slack_channels.yml
@@ -15,4 +15,11 @@ shared:
hq_eng: C09N76HCM9P
neighbourhood: C09MU9EC9GA
lounge: C09NNJRQGP2
- waiting_room: C09N763H20Z
\ No newline at end of file
+ waiting_room: C09N763H20Z
+ identity_help: C092833JXKK
+ flavortown_bulletin: C09MATJJZ5J
+ flavortown_construction: C09KCFWQSKE
+ flavortown_esplanade: C09MPB8NE8H
+ flavortown_help: C09MATKQM8C
+ happenings: C05B6DBN802
+ community: C01D7AHKMPF
\ No newline at end of file
diff --git a/db/migrate/20251210001813_add_onboarding_scenario_to_programs.rb b/db/migrate/20251210001813_add_onboarding_scenario_to_programs.rb
new file mode 100644
index 0000000..fdfefd6
--- /dev/null
+++ b/db/migrate/20251210001813_add_onboarding_scenario_to_programs.rb
@@ -0,0 +1,5 @@
+class AddOnboardingScenarioToPrograms < ActiveRecord::Migration[8.0]
+ def change
+ add_column :oauth_applications, :onboarding_scenario, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a0c32b7..f54907c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_12_08_205405) do
+ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pgcrypto"
@@ -113,8 +113,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_205405) do
t.string "credential_id"
t.boolean "can_break_glass"
t.bigint "identity_id"
- t.index ["identity_id"], name: "index_backend_users_on_identity_id"
t.string "seen_hints", default: [], array: true
+ t.index ["identity_id"], name: "index_backend_users_on_identity_id"
end
create_table "break_glass_records", force: :cascade do |t|
@@ -473,6 +473,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_205405) do
t.boolean "active", default: true
t.integer "trust_level", default: 0, null: false
t.bigint "owner_identity_id"
+ t.string "onboarding_scenario"
t.index ["owner_identity_id"], name: "index_oauth_applications_on_owner_identity_id"
t.index ["program_key_bidx"], name: "index_oauth_applications_on_program_key_bidx", unique: true
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
@@ -492,6 +493,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_205405) do
t.index ["key"], name: "index_settings_on_key", unique: true
end
+ create_table "slack_idp_groups", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "slack_group_id"
+ t.string "slug", null: false
+ t.datetime "synced_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["slack_group_id"], name: "index_slack_idp_groups_on_slack_group_id", unique: true
+ t.index ["slug"], name: "index_slack_idp_groups_on_slug", unique: true
+ end
+
create_table "verifications", force: :cascade do |t|
t.bigint "identity_id", null: false
t.bigint "identity_document_id"
diff --git a/spec/models/onboarding_scenarios/flavortown_spec.rb b/spec/models/onboarding_scenarios/flavortown_spec.rb
new file mode 100644
index 0000000..b9ea3ad
--- /dev/null
+++ b/spec/models/onboarding_scenarios/flavortown_spec.rb
@@ -0,0 +1,186 @@
+require "rails_helper"
+
+RSpec.describe OnboardingScenarios::Flavortown do
+ let(:identity) { create(:identity) }
+ let(:scenario) { described_class.new(identity) }
+
+ describe ".slug" do
+ it "returns 'flavortown'" do
+ expect(described_class.slug).to eq("flavortown")
+ end
+ end
+
+ describe "#dialogue_flow" do
+ it "defines all expected steps" do
+ expect(scenario.dialogue_flow.keys).to contain_exactly(
+ :welcome,
+ :kitchen_code,
+ :taste_test,
+ :taste_wrong,
+ :taste_gave_up,
+ :taste_terrible,
+ :dino_nuggets,
+ :promoted
+ )
+ end
+
+ it "maps steps to correct templates" do
+ expect(scenario.dialogue_flow[:welcome]).to eq("flavortown/01_welcome")
+ expect(scenario.dialogue_flow[:kitchen_code]).to eq("flavortown/02_kitchen_code")
+ expect(scenario.dialogue_flow[:taste_test]).to eq("flavortown/03_taste_test")
+ expect(scenario.dialogue_flow[:taste_wrong]).to eq("flavortown/03b_taste_wrong")
+ expect(scenario.dialogue_flow[:taste_gave_up]).to eq("flavortown/03c_taste_incredibly_wrong")
+ expect(scenario.dialogue_flow[:taste_terrible]).to eq("flavortown/03d_taste_terrible")
+ expect(scenario.dialogue_flow[:dino_nuggets]).to eq("flavortown/03e_dino_nuggets")
+ expect(scenario.dialogue_flow[:promoted]).to eq("flavortown/04_promoted")
+ end
+ end
+
+ describe "#first_step" do
+ it "starts at :welcome" do
+ expect(scenario.first_step).to eq(:welcome)
+ end
+ end
+
+ describe "#handle_action" do
+ context "happy path" do
+ it "advances from welcome to kitchen_code" do
+ expect(scenario.handle_action("flavortown_continue")).to eq(:kitchen_code)
+ end
+
+ it "advances from kitchen_code to taste_test" do
+ expect(scenario.handle_action("flavortown_agree")).to eq(:taste_test)
+ end
+
+ it "promotes on correct answer" do
+ result = scenario.handle_action("flavortown_taste_correct")
+ expect(result).to eq({ step: :promoted, promote: true })
+ end
+ end
+
+ context "wrong answers" do
+ it "goes to taste_wrong on first wrong answer" do
+ expect(scenario.handle_action("flavortown_taste_wrong")).to eq(:taste_wrong)
+ end
+
+ it "allows retry from taste_wrong" do
+ expect(scenario.handle_action("flavortown_try_again")).to eq(:taste_test)
+ end
+
+ it "gives up after second wrong answer" do
+ expect(scenario.handle_action("flavortown_taste_wrong_again")).to eq(:taste_gave_up)
+ end
+ end
+
+ context "terrible answers" do
+ it "goes to taste_terrible on incredibly wrong answer" do
+ expect(scenario.handle_action("flavortown_taste_incredibly_wrong")).to eq(:taste_terrible)
+ end
+
+ it "goes to dino_nuggets on dino nuggets answer" do
+ expect(scenario.handle_action("flavortown_dino_nuggets")).to eq(:dino_nuggets)
+ end
+ end
+
+ context "unknown action" do
+ it "returns nil for unknown actions" do
+ expect(scenario.handle_action("unknown_action")).to be_nil
+ end
+ end
+ end
+
+ describe "bot persona" do
+ it "has custom bot name" do
+ expect(scenario.bot_name).to eq("Flavorpheus")
+ end
+
+ it "has custom bot icon" do
+ expect(scenario.bot_icon_url).to be_present
+ end
+ end
+
+ describe "slack configuration" do
+ it "uses multi-channel guest type" do
+ expect(scenario.slack_user_type).to eq(:multi_channel_guest)
+ end
+
+ it "uses DM channel" do
+ expect(scenario.use_dm_channel?).to be true
+ end
+
+ it "has initial slack channels" do
+ expect(scenario.slack_channels).to be_an(Array)
+ expect(scenario.slack_channels).not_to be_empty
+ end
+
+ it "has promotion channels" do
+ expect(scenario.promotion_channels).to be_an(Array)
+ expect(scenario.promotion_channels).not_to be_empty
+ end
+ end
+
+ describe "flow integration" do
+ it "can complete the happy path: welcome -> kitchen_code -> taste_test -> promoted" do
+ step = scenario.first_step
+ expect(step).to eq(:welcome)
+
+ step = scenario.handle_action("flavortown_continue")
+ expect(step).to eq(:kitchen_code)
+
+ step = scenario.handle_action("flavortown_agree")
+ expect(step).to eq(:taste_test)
+
+ result = scenario.handle_action("flavortown_taste_correct")
+ expect(result).to eq({ step: :promoted, promote: true })
+ end
+
+ it "can complete the wrong-then-correct path" do
+ step = scenario.first_step
+ step = scenario.handle_action("flavortown_continue")
+ step = scenario.handle_action("flavortown_agree")
+ expect(step).to eq(:taste_test)
+
+ step = scenario.handle_action("flavortown_taste_wrong")
+ expect(step).to eq(:taste_wrong)
+
+ step = scenario.handle_action("flavortown_try_again")
+ expect(step).to eq(:taste_test)
+
+ result = scenario.handle_action("flavortown_taste_correct")
+ expect(result).to eq({ step: :promoted, promote: true })
+ end
+
+ it "can complete the gave-up path (wrong twice)" do
+ scenario.handle_action("flavortown_continue")
+ scenario.handle_action("flavortown_agree")
+
+ step = scenario.handle_action("flavortown_taste_wrong")
+ expect(step).to eq(:taste_wrong)
+
+ step = scenario.handle_action("flavortown_taste_wrong_again")
+ expect(step).to eq(:taste_gave_up)
+ end
+
+ it "can complete the dino nuggets path" do
+ scenario.handle_action("flavortown_continue")
+ scenario.handle_action("flavortown_agree")
+
+ step = scenario.handle_action("flavortown_dino_nuggets")
+ expect(step).to eq(:dino_nuggets)
+
+ step = scenario.handle_action("flavortown_try_again")
+ expect(step).to eq(:taste_test)
+ end
+
+ it "can complete the terrible answer path" do
+ scenario.handle_action("flavortown_continue")
+ scenario.handle_action("flavortown_agree")
+
+ step = scenario.handle_action("flavortown_taste_incredibly_wrong")
+ expect(step).to eq(:taste_terrible)
+
+ step = scenario.handle_action("flavortown_try_again")
+ expect(step).to eq(:taste_test)
+ end
+ end
+end