mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 20:55:11 +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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 {
|
.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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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_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?
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 %>
|
<%= render profile_completion %>
|
||||||
<% end %>
|
<% 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
|
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
|
||||||
|
|
@ -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.
|
# 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"
|
||||||
|
|
|
||||||
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