Flavortown onboarding (#97)

* add flavortown scenario and channels

* add special app support for SSO grid

* add flavortown app card

* downcase

* add onboarding scenario to program

* cooked

* RALSEI ENGINE IS A REAL ENGINE NOW
This commit is contained in:
nora 2025-12-09 20:13:24 -05:00 committed by GitHub
parent d44b3106bd
commit 0bd3d609bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 914 additions and 15 deletions

View file

@ -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

View file

@ -1,18 +1,21 @@
# frozen_string_literal: true # frozen_string_literal: true
class Components::SSOAppGrid < Components::Base class Components::SSOAppGrid < Components::Base
def initialize(apps:) def initialize(apps:, special_apps: [])
@apps = apps @apps = apps
@special_apps = special_apps
end end
def view_template def view_template
div(class: "sso-app-grid") do div(class: "sso-app-grid") do
h2 { t "home.apps.heading" } h2 { t "home.apps.heading" }
if @apps.any? all_apps = @apps + @special_apps.map(&:to_h)
if all_apps.any?
div(class: "grid") do div(class: "grid") do
@apps.each do |app| all_apps.each do |app|
render Components::SSOAppCard.new(app: app) render Components::AppCard.new(app: app)
end end
end end
else else

View file

@ -78,6 +78,10 @@ class Backend::ProgramsController < Backend::ApplicationController
permitted_params += [ :description, :active, :trust_level, scopes_array: [] ] permitted_params += [ :description, :active, :trust_level, scopes_array: [] ]
end end
if policy(@program).update_onboarding_scenario?
permitted_params << :onboarding_scenario
end
params.require(:program).permit(permitted_params) params.require(:program).permit(permitted_params)
end end
end end

View file

@ -153,9 +153,32 @@ class IdentitiesController < ApplicationController
scenario = OnboardingScenarios::Base.find_by_slug(params[:slug]) scenario = OnboardingScenarios::Base.find_by_slug(params[:slug])
return scenario if scenario return scenario if scenario
end 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 OnboardingScenarios::DefaultJoin
end 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 def scenario_prefill_attributes
return {} unless params[:identity].present? return {} unless params[:identity].present?
extractor = @onboarding_scenario.extract_params_proc extractor = @onboarding_scenario.extract_params_proc

View file

@ -3,6 +3,7 @@ class StaticPagesController < ApplicationController
def home def home
@sso_apps = SAMLService::Entities.service_providers.values.select { |sp| sp[:allow_idp_initiated] } @sso_apps = SAMLService::Entities.service_providers.values.select { |sp| sp[:allow_idp_initiated] }
@special_apps = SpecialAppCards::Base.for_identity(current_identity)
end end
def welcome def welcome

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

View file

@ -32,9 +32,13 @@
.sso-app-card-form { .sso-app-card-form {
margin: 0; 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); @include card($padding: 1.75rem, $radius: $radius-lg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -106,6 +110,7 @@ button.sso-app-card.secondary {
.app-info { .app-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
text-align: left;
h3 { h3 {
font-size: 1.125rem; font-size: 1.125rem;
@ -134,6 +139,7 @@ button.sso-app-card.secondary {
margin-top: auto; margin-top: auto;
padding-top: $space-3; padding-top: $space-3;
border-top: 1px solid var(--pico-card-border-color); border-top: 1px solid var(--pico-card-border-color);
text-align: center;
.launch-text { .launch-text {
font-size: 0.9375rem; font-size: 0.9375rem;

View file

@ -8,6 +8,10 @@ module OnboardingScenarios
return nil if slug.blank? return nil if slug.blank?
descendants&.find { |k| k.slug && k.slug.to_s == slug.to_s } descendants&.find { |k| k.slug && k.slug.to_s == slug.to_s }
end end
def available_slugs
descendants&.filter_map(&:slug)&.sort || []
end
end end
def initialize(identity) def initialize(identity)
@ -54,5 +58,44 @@ module OnboardingScenarios
# Whether Ralsei should message users via DM instead of a channel # Whether Ralsei should message users via DM instead of a channel
def use_dm_channel? = false 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
end end

View file

@ -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

View file

@ -0,0 +1,2 @@
module SpecialAppCards
end

View file

@ -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

View file

@ -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

View file

@ -13,6 +13,8 @@ class ProgramPolicy < ApplicationPolicy
def update_scopes? = user_is_program_manager? def update_scopes? = user_is_program_manager?
def update_onboarding_scenario? = user&.super_admin?
class Scope < Scope class Scope < Scope
def resolve def resolve
if user.program_manager? || user.super_admin? if user.program_manager? || user.super_admin?

View file

@ -2,39 +2,98 @@ module RalseiEngine
class << self class << self
RALSEI_PFP = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/6cc8caeeff906502bfe60ba2f3db34cdf79a237d_ralsei2.png" 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) def handle_tutorial_agree(identity)
Rails.logger.info "RalseiEngine: #{identity.public_id} agreed to tutorial" Rails.logger.info "RalseiEngine: #{identity.public_id} agreed to tutorial"
scenario = identity.onboarding_scenario_instance
if identity.promote_click_count == 0 if identity.promote_click_count == 0
SlackService.promote_user(identity.slack_id) SlackService.promote_user(identity.slack_id)
promotion_channels = identity.onboarding_scenario_instance.promotion_channels promotion_channels = scenario&.promotion_channels
if promotion_channels.present? if promotion_channels.present?
SlackService.add_to_channels(user_id: identity.slack_id, channel_ids: promotion_channels) SlackService.add_to_channels(user_id: identity.slack_id, channel_ids: promotion_channels)
end end
else else
Rails.logger.info "RalseiEngine: #{identity.public_id} is already a full member" Rails.logger.info "RalseiEngine: #{identity.public_id} is already a full member"
end end
send_message(identity, "tutorial/03_welcome")
scenario&.after_promotion
send_step(identity, :welcome)
identity.increment!(:promote_click_count, 1) identity.increment!(:promote_click_count, 1)
end 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) def send_message(identity, template_name)
return unless identity.slack_id.present? return unless identity.slack_id.present?
channel_id = resolve_channel(identity) channel_id = resolve_channel(identity)
return unless channel_id return unless channel_id
scenario = identity.onboarding_scenario_instance
payload = render_template("slack/#{template_name}", identity) payload = render_template("slack/#{template_name}", identity)
client.chat_postMessage( client.chat_postMessage(
channel: channel_id, channel: channel_id,
username: "Ralsei", username: scenario&.bot_name || "Ralsei",
icon_url: RALSEI_PFP, icon_url: scenario&.bot_icon_url || RALSEI_PFP,
**JSON.parse(payload, symbolize_names: true), **JSON.parse(payload, symbolize_names: true),
unfurl_links: false, unfurl_links: false,
) )

View file

@ -36,6 +36,12 @@
<span class="detail-label">users</span> <span class="detail-label">users</span>
<span class="detail-value"><%= @identities_count %></span> <span class="detail-value"><%= @identities_count %></span>
</div> </div>
<% if @program.onboarding_scenario.present? %>
<div class="detail-row">
<span class="detail-label">onboarding</span>
<span class="detail-value"><%= @program.onboarding_scenario.titleize %></span>
</div>
<% end %>
</div> </div>
</div> </div>
<hr> <hr>

View file

@ -30,6 +30,28 @@ class Backend::Programs::Form < ApplicationForm
end end
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 div style: "margin: 1rem 0;" do
label(class: "field-label") { "OAuth Scopes:" } label(class: "field-label") { "OAuth Scopes:" }
# Hidden field to ensure empty scopes array is submitted when no checkboxes are checked # Hidden field to ensure empty scopes array is submitted when no checkboxes are checked

View file

@ -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")
]

View file

@ -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 <https://hackclub.com/conduct/|Code of Conduct> anytime.
MSG
context [
mrkdwn_text("By joining, you agreed to Slack's <https://slack.com/main-services-agreement|Terms of Service> & <https://slack.com/trust/privacy/privacy-policy|Privacy Policy>.")
]
section '_Flavorpheus raises an eyebrow._ "So — you ready to cook by the code?"', markdown: true
actions [
button("let's cook!", "flavortown_agree", style: "primary")
]

View file

@ -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

View file

@ -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

View file

@ -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")
]

View file

@ -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")
]

View file

@ -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")
]

View file

@ -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

View file

@ -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

View file

@ -13,4 +13,4 @@
<%= render profile_completion %> <%= render profile_completion %>
<% end %> <% end %>
<%= render Components::SSOAppGrid.new(apps: @sso_apps) %> <%= render Components::SSOAppGrid.new(apps: @sso_apps, special_apps: @special_apps) %>

View file

@ -15,4 +15,11 @@ shared:
hq_eng: C09N76HCM9P hq_eng: C09N76HCM9P
neighbourhood: C09MU9EC9GA neighbourhood: C09MU9EC9GA
lounge: C09NNJRQGP2 lounge: C09NNJRQGP2
waiting_room: C09N763H20Z waiting_room: C09N763H20Z
identity_help: C092833JXKK
flavortown_bulletin: C09MATJJZ5J
flavortown_construction: C09KCFWQSKE
flavortown_esplanade: C09MPB8NE8H
flavortown_help: C09MATKQM8C
happenings: C05B6DBN802
community: C01D7AHKMPF

View file

@ -0,0 +1,5 @@
class AddOnboardingScenarioToPrograms < ActiveRecord::Migration[8.0]
def change
add_column :oauth_applications, :onboarding_scenario, :string
end
end

16
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "pgcrypto" enable_extension "pgcrypto"
@ -113,8 +113,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_205405) do
t.string "credential_id" t.string "credential_id"
t.boolean "can_break_glass" t.boolean "can_break_glass"
t.bigint "identity_id" t.bigint "identity_id"
t.index ["identity_id"], name: "index_backend_users_on_identity_id"
t.string "seen_hints", default: [], array: true t.string "seen_hints", default: [], array: true
t.index ["identity_id"], name: "index_backend_users_on_identity_id"
end end
create_table "break_glass_records", force: :cascade do |t| 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.boolean "active", default: true
t.integer "trust_level", default: 0, null: false t.integer "trust_level", default: 0, null: false
t.bigint "owner_identity_id" t.bigint "owner_identity_id"
t.string "onboarding_scenario"
t.index ["owner_identity_id"], name: "index_oauth_applications_on_owner_identity_id" 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 ["program_key_bidx"], name: "index_oauth_applications_on_program_key_bidx", unique: true
t.index ["uid"], name: "index_oauth_applications_on_uid", 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 t.index ["key"], name: "index_settings_on_key", unique: true
end 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| create_table "verifications", force: :cascade do |t|
t.bigint "identity_id", null: false t.bigint "identity_id", null: false
t.bigint "identity_document_id" t.bigint "identity_document_id"

View file

@ -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