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