mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 18:35:13 +00:00
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:
parent
d44b3106bd
commit
0bd3d609bb
30 changed files with 914 additions and 15 deletions
57
app/components/app_card.rb
Normal file
57
app/components/app_card.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
app/frontend/images/sso_apps/flavortown.png
Normal file
BIN
app/frontend/images/sso_apps/flavortown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
app/models/onboarding_scenarios/flavortown.rb
Normal file
54
app/models/onboarding_scenarios/flavortown.rb
Normal 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
|
||||
2
app/models/special_app_cards.rb
Normal file
2
app/models/special_app_cards.rb
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
module SpecialAppCards
|
||||
end
|
||||
57
app/models/special_app_cards/base.rb
Normal file
57
app/models/special_app_cards/base.rb
Normal 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
|
||||
19
app/models/special_app_cards/flavortown.rb
Normal file
19
app/models/special_app_cards/flavortown.rb
Normal 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
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@
|
|||
<span class="detail-label">users</span>
|
||||
<span class="detail-value"><%= @identities_count %></span>
|
||||
</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>
|
||||
<hr>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
app/views/slack/flavortown/01_welcome.slack_message.slocks
Normal file
25
app/views/slack/flavortown/01_welcome.slack_message.slocks
Normal 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")
|
||||
]
|
||||
|
|
@ -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")
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
]
|
||||
|
|
@ -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")
|
||||
]
|
||||
|
|
@ -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")
|
||||
]
|
||||
167
app/views/slack/flavortown/04_promoted.slack_message.slocks
Normal file
167
app/views/slack/flavortown/04_promoted.slack_message.slocks
Normal 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
|
||||
16
app/views/slack/flavortown/_taste_answers.rb
Normal file
16
app/views/slack/flavortown/_taste_answers.rb
Normal 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
|
||||
|
|
@ -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) %>
|
||||
|
|
|
|||
|
|
@ -15,4 +15,11 @@ shared:
|
|||
hq_eng: C09N76HCM9P
|
||||
neighbourhood: C09MU9EC9GA
|
||||
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
|
||||
|
|
@ -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
16
db/schema.rb
generated
|
|
@ -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"
|
||||
|
|
|
|||
186
spec/models/onboarding_scenarios/flavortown_spec.rb
Normal file
186
spec/models/onboarding_scenarios/flavortown_spec.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue