diff --git a/core/model/user.go b/core/model/user.go index 12a7ebb..9ee9eed 100644 --- a/core/model/user.go +++ b/core/model/user.go @@ -23,6 +23,8 @@ type User struct { ShirtSize string `json:"shirt_size"` JacketSize string `json:"jacket_size"` SAERegistrationNumber string `json:"sae_registration_number"` + OccupationTitle string `json:"occupation_title"` + OccupationCompany string `json:"occupation_company"` AvatarURL string `json:"avatar_url"` InitialRole string `json:"initial_role"` Groups []string `json:"groups" gorm:"-"` diff --git a/discord/api/onboarding_token.go b/discord/api/onboarding_token.go index 6fcde4d..96152e9 100644 --- a/discord/api/onboarding_token.go +++ b/discord/api/onboarding_token.go @@ -58,16 +58,15 @@ type consumeRequest struct { ShirtSize string `json:"shirt_size" binding:"required"` JacketSize string `json:"jacket_size" binding:"required"` SAERegistrationNumber string `json:"sae_registration_number"` + OccupationTitle string `json:"occupation_title"` + OccupationCompany string `json:"occupation_company"` InitialRole string `json:"initial_role" binding:"required"` } var validInitialRoles = map[string]bool{ - "member": true, - "alumni": true, - "mentor": true, - "sponsor": true, - "other": true, - "guest": true, + "member": true, + "alumni": true, + "guest": true, } // isUCSBEmail reports whether the email's domain is ucsb.edu (case-insensitive). @@ -91,15 +90,27 @@ func ConsumeOnboardingToken(c *gin.Context) { return } - if req.GraduationYear > 0 && req.GraduationYear < time.Now().Year() && isUCSBEmail(req.Email) { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "UCSB emails expire after graduation. Update your graduation year or use a personal email.", - }) - return - } - - if req.InitialRole == "member" && !isUCSBEmail(req.Email) { - req.InitialRole = "guest" + switch req.InitialRole { + case "member": + if !isUCSBEmail(req.Email) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "current members must sign up with their @ucsb.edu email", + }) + return + } + if req.GraduationYear > 0 && req.GraduationYear < time.Now().Year() { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "current members can't have a graduation year in the past", + }) + return + } + case "alumni": + if isUCSBEmail(req.Email) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "alumni must sign up with a personal email since @ucsb.edu addresses expire after graduation", + }) + return + } } entityID, err := service.ConsumeOnboardingToken(id, service.OnboardingConsumePayload{ @@ -117,6 +128,8 @@ func ConsumeOnboardingToken(c *gin.Context) { ShirtSize: req.ShirtSize, JacketSize: req.JacketSize, SAERegistrationNumber: req.SAERegistrationNumber, + OccupationTitle: req.OccupationTitle, + OccupationCompany: req.OccupationCompany, InitialRole: req.InitialRole, }) @@ -127,6 +140,9 @@ func ConsumeOnboardingToken(c *gin.Context) { case errors.Is(err, service.ErrOnboardingTokenInvalid): c.JSON(http.StatusGone, gin.H{"error": err.Error()}) return + case errors.Is(err, service.ErrUsernameTaken): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return case err != nil: logger.SugarLogger.Errorf("Failed to consume onboarding token %s: %v", id, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/discord/service/guild.go b/discord/service/guild.go index 9552f4c..15885d6 100644 --- a/discord/service/guild.go +++ b/discord/service/guild.go @@ -51,7 +51,7 @@ func DiscordRolesForInitialRole(initialRole string) []string { return []string{config.MembersDiscordRoleID} case "alumni": return []string{config.AlumniDiscordRoleID} - case "mentor", "sponsor", "other", "guest": + case "guest": return []string{config.GuestDiscordRoleID} default: return nil diff --git a/discord/service/onboarding_token.go b/discord/service/onboarding_token.go index 2edb195..6fd5247 100644 --- a/discord/service/onboarding_token.go +++ b/discord/service/onboarding_token.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "net/url" "time" "github.com/gaucho-racing/sentinel/discord/config" @@ -17,6 +18,7 @@ import ( var ( ErrOnboardingTokenNotFound = errors.New("onboarding token not found") ErrOnboardingTokenInvalid = errors.New("onboarding token expired or already used") + ErrUsernameTaken = errors.New("username is already taken") ) // CreateOnboardingTokenForDiscordUser invalidates any unused tokens for the @@ -78,6 +80,8 @@ type OnboardingConsumePayload struct { ShirtSize string JacketSize string SAERegistrationNumber string + OccupationTitle string + OccupationCompany string InitialRole string } @@ -96,6 +100,24 @@ func ConsumeOnboardingToken(id string, p OnboardingConsumePayload) (string, erro return "", fmt.Errorf("invalid birthday format (want YYYY-MM-DD): %w", err) } + // Final username availability check before we create the entity. + // Narrows the race window between the live check on the form and + // the DB unique constraint on `user.username` — the constraint + // remains the authoritative safeguard, but losing the race after + // the entity exists would orphan the entity. + var usernameCheck struct { + Available bool `json:"available"` + } + if err := sentinel.Get( + fmt.Sprintf("/api/users/check-username?username=%s", url.QueryEscape(p.Username)), + &usernameCheck, + ); err != nil { + return "", fmt.Errorf("check username: %w", err) + } + if !usernameCheck.Available { + return "", ErrUsernameTaken + } + var entityResp struct { ID string `json:"id"` } @@ -118,6 +140,8 @@ func ConsumeOnboardingToken(id string, p OnboardingConsumePayload) (string, erro "shirt_size": p.ShirtSize, "jacket_size": p.JacketSize, "sae_registration_number": p.SAERegistrationNumber, + "occupation_title": p.OccupationTitle, + "occupation_company": p.OccupationCompany, "avatar_url": token.DiscordAvatarURL, "initial_role": p.InitialRole, } diff --git a/web/src/pages/onboarding/OnboardingPage.tsx b/web/src/pages/onboarding/OnboardingPage.tsx index 0bf12c0..01f7bd8 100644 --- a/web/src/pages/onboarding/OnboardingPage.tsx +++ b/web/src/pages/onboarding/OnboardingPage.tsx @@ -21,6 +21,7 @@ import { OnboardingProgress } from "@/pages/onboarding/OnboardingProgress" import { AcademicStep } from "@/pages/onboarding/steps/AcademicStep" import { CredentialsStep } from "@/pages/onboarding/steps/CredentialsStep" import { IdentityStep, type UsernameAvailability } from "@/pages/onboarding/steps/IdentityStep" +import { OccupationStep } from "@/pages/onboarding/steps/OccupationStep" import { PersonalStep } from "@/pages/onboarding/steps/PersonalStep" import { ReviewStep } from "@/pages/onboarding/steps/ReviewStep" import { TeamStep } from "@/pages/onboarding/steps/TeamStep" @@ -38,7 +39,6 @@ const HOLD_MS = 250 const STEP_SLIDE_PX = 24 const STUDENT_DOMAIN = "ucsb.edu" -const NON_STUDENT_ROLES = ["Alumni", "Mentor", "Sponsor", "Other"] const stepVariants: Variants = { enter: (dir: "forward" | "back") => ({ @@ -66,22 +66,28 @@ type TokenInfoResponse = { discord_avatar_url: string } -type StepId = "welcome" | "credentials" | "identity" | "personal" | "academic" | "team" | "review" - -const STEPS: StepId[] = [ - "welcome", - "credentials", - "identity", - "personal", - "academic", - "team", - "review", -] +type StepId = + | "welcome" + | "credentials" + | "identity" + | "personal" + | "academic" + | "occupation" + | "team" + | "review" + +function stepsForRole(role: OnboardingData["role"]): StepId[] { + const steps: StepId[] = ["welcome", "credentials", "identity", "personal"] + if (role !== "guest") steps.push("academic") + if (role === "alumni" || role === "guest") steps.push("occupation") + steps.push("team", "review") + return steps +} function isStepValid(step: StepId, data: OnboardingData): boolean { switch (step) { case "welcome": - return true + return data.role.length > 0 case "credentials": return ( data.email.trim().length > 0 && @@ -105,6 +111,10 @@ function isStepValid(step: StepId, data: OnboardingData): boolean { data.graduationYear.length > 0 && data.major.trim().length > 0 ) + case "occupation": + return ( + data.occupationTitle.trim().length > 0 && data.occupationCompany.trim().length > 0 + ) case "team": return data.shirtSize.length > 0 && data.jacketSize.length > 0 case "review": @@ -122,11 +132,7 @@ export default function OnboardingPage() { const [data, setData] = useState(EMPTY_ONBOARDING_DATA) const [submitting, setSubmitting] = useState(false) const [transitioning, setTransitioning] = useState(false) - const [confirmedNonStudentDomain, setConfirmedNonStudentDomain] = useState< - string | null - >(null) - const [nonStudentRole, setNonStudentRole] = useState(null) - const [studentDialogOpen, setStudentDialogOpen] = useState(false) + const [emailRoleDialogOpen, setEmailRoleDialogOpen] = useState(false) const [tokenState, setTokenState] = useState({ status: "loading" }) const [usernameAvailability, setUsernameAvailability] = useState("idle") @@ -158,17 +164,20 @@ export default function OnboardingPage() { } }, [token]) - const currentStep = STEPS[stepIndex] - const isLast = stepIndex === STEPS.length - 1 + const steps = useMemo(() => stepsForRole(data.role), [data.role]) + const currentStep = steps[Math.min(stepIndex, steps.length - 1)] + const isLast = stepIndex === steps.length - 1 const isFirst = stepIndex === 0 const canProceed = useMemo(() => isStepValid(currentStep, data), [currentStep, data]) const emailDomain = data.email.split("@")[1]?.toLowerCase() ?? "" - const needsStudentConfirm = - currentStep === "credentials" && - emailDomain.includes(".") && - emailDomain !== STUDENT_DOMAIN && - confirmedNonStudentDomain !== emailDomain + const hasFullDomain = emailDomain.includes(".") + const memberNeedsUcsbEmail = + data.role === "member" && hasFullDomain && emailDomain !== STUDENT_DOMAIN + const alumniRejectsUcsbEmail = + data.role === "alumni" && emailDomain === STUDENT_DOMAIN + const emailRoleMismatch = + currentStep === "credentials" && (memberNeedsUcsbEmail || alumniRejectsUcsbEmail) function update(patch: Partial) { setData((prev) => ({ ...prev, ...patch })) @@ -179,13 +188,6 @@ export default function OnboardingPage() { setStepIndex((i) => i + 1) } - function handleConfirmNonStudent(role: string) { - setNonStudentRole(role) - setConfirmedNonStudentDomain(emailDomain) - setStudentDialogOpen(false) - advance() - } - async function handleNext() { if (!canProceed || submitting) return @@ -202,24 +204,19 @@ export default function OnboardingPage() { return } - if (currentStep === "academic") { + if (currentStep === "academic" && data.role === "member") { const gradYear = parseInt(data.graduationYear, 10) const currentYear = new Date().getFullYear() - if ( - Number.isFinite(gradYear) && - gradYear > 0 && - gradYear < currentYear && - emailDomain === STUDENT_DOMAIN - ) { + if (Number.isFinite(gradYear) && gradYear > 0 && gradYear < currentYear) { toast.error( - "UCSB emails expire after graduation. Update your graduation year or use a personal email.", + "Current members can't have a graduation year in the past. Update your graduation year or pick Alumni on the first step.", ) return } } - if (needsStudentConfirm) { - setStudentDialogOpen(true) + if (emailRoleMismatch) { + setEmailRoleDialogOpen(true) return } @@ -228,10 +225,7 @@ export default function OnboardingPage() { return } - const initialRole = - confirmedNonStudentDomain === emailDomain && nonStudentRole - ? nonStudentRole.toLowerCase() - : "member" + const initialRole = data.role || "member" const payload = { email: data.email, @@ -242,12 +236,19 @@ export default function OnboardingPage() { gender: data.gender, birthday: data.birthday, phone_number: data.phoneNumber, - graduate_level: data.graduateLevel, - graduation_year: data.graduationYear ? parseInt(data.graduationYear, 10) : 0, - major: data.major, + graduate_level: data.role === "guest" ? "none" : data.graduateLevel, + graduation_year: + data.role === "guest" + ? 0 + : data.graduationYear + ? parseInt(data.graduationYear, 10) + : 0, + major: data.role === "guest" ? "" : data.major, shirt_size: data.shirtSize, jacket_size: data.jacketSize, - sae_registration_number: data.saeRegistrationNumber, + sae_registration_number: data.role === "member" ? data.saeRegistrationNumber : "", + occupation_title: data.role === "member" ? "" : data.occupationTitle, + occupation_company: data.role === "member" ? "" : data.occupationCompany, initial_role: initialRole, } @@ -306,12 +307,12 @@ export default function OnboardingPage() {

Set up your account

- Step {stepIndex + 1} of {STEPS.length} + Step {stepIndex + 1} of {steps.length}

- + - {currentStep === "welcome" && } + {currentStep === "welcome" && ( + + )} {currentStep === "credentials" && ( )} @@ -339,17 +342,15 @@ export default function OnboardingPage() { + )} + {currentStep === "occupation" && ( + )} {currentStep === "team" && } @@ -388,46 +389,42 @@ export default function OnboardingPage() { )} - +
- Are you a current student? - - You're using{" "} - @{emailDomain}{" "} - instead of @ucsb.edu. Current students should use their UCSB email so we - can verify enrollment. - + {data.role === "alumni" ? ( + <> + Use a personal email + + UCSB emails expire after graduation. Sign up with a personal email + so you keep access after your{" "} + @ucsb.edu{" "} + account is deactivated. + + + ) : ( + <> + UCSB email required + + Current members must sign up with their{" "} + @ucsb.edu email + so we can verify enrollment. You're using{" "} + @{emailDomain}. + + + )}
setStudentDialogOpen(false)} + onClick={() => setEmailRoleDialogOpen(false)} > - I'll use my UCSB email + {data.role === "alumni" ? "Use a different email" : "Use my UCSB email"} - -
-

- Or, if you're not a student, I'm a/an: -

-
- {NON_STUDENT_ROLES.map((role) => ( - - ))} -
-
diff --git a/web/src/pages/onboarding/steps/OccupationStep.tsx b/web/src/pages/onboarding/steps/OccupationStep.tsx new file mode 100644 index 0000000..edc64cc --- /dev/null +++ b/web/src/pages/onboarding/steps/OccupationStep.tsx @@ -0,0 +1,46 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import type { StepProps } from "@/pages/onboarding/types" + +type Props = StepProps & { + isAlumni?: boolean +} + +export function OccupationStep({ data, update, isAlumni }: Props) { + return ( +
+
+

Current occupation

+

+ {isAlumni + ? "Tell us what you're up to now — we love seeing where alumni land." + : "Helps the team understand who's in our network."} +

+
+ +
+
+ + update({ occupationTitle: e.target.value })} + required + /> +
+ +
+ + update({ occupationCompany: e.target.value })} + required + /> +
+
+
+ ) +} diff --git a/web/src/pages/onboarding/steps/ReviewStep.tsx b/web/src/pages/onboarding/steps/ReviewStep.tsx index 45ce849..5e5f431 100644 --- a/web/src/pages/onboarding/steps/ReviewStep.tsx +++ b/web/src/pages/onboarding/steps/ReviewStep.tsx @@ -57,16 +57,27 @@ export function ReviewStep({ data }: { data: OnboardingData }) { -
- - - -
+ {data.role !== "guest" && ( +
+ + + +
+ )} + + {data.role !== "member" && ( +
+ + +
+ )}
- + {data.role === "member" && ( + + )}
diff --git a/web/src/pages/onboarding/steps/TeamStep.tsx b/web/src/pages/onboarding/steps/TeamStep.tsx index d126151..06b9000 100644 --- a/web/src/pages/onboarding/steps/TeamStep.tsx +++ b/web/src/pages/onboarding/steps/TeamStep.tsx @@ -48,12 +48,15 @@ function SizePicker({ } export function TeamStep({ data, update }: StepProps) { + const showSae = data.role === "member" return (

Team gear

- Sizes for team apparel. SAE registration is optional and can be added later. + {showSae + ? "Sizes for team apparel. SAE registration is optional and can be added later." + : "Sizes for team apparel."}

@@ -72,15 +75,17 @@ export function TeamStep({ data, update }: StepProps) { onChange={update} /> -
- - update({ saeRegistrationNumber: e.target.value })} - /> -
+ {showSae && ( +
+ + update({ saeRegistrationNumber: e.target.value })} + /> +
+ )}
) diff --git a/web/src/pages/onboarding/steps/WelcomeStep.tsx b/web/src/pages/onboarding/steps/WelcomeStep.tsx index ee24da1..94271e9 100644 --- a/web/src/pages/onboarding/steps/WelcomeStep.tsx +++ b/web/src/pages/onboarding/steps/WelcomeStep.tsx @@ -1,12 +1,39 @@ -import { ShieldCheck } from "lucide-react" +import { GraduationCap, ShieldCheck, UserPlus, Users } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import type { DiscordIdentity } from "@/pages/onboarding/types" +import { cn } from "@/lib/utils" +import type { DiscordIdentity, OnboardingRole, StepProps } from "@/pages/onboarding/types" -type WelcomeStepProps = { +type WelcomeStepProps = StepProps & { identity: DiscordIdentity } +const ROLE_OPTIONS: { + value: OnboardingRole + label: string + description: string + Icon: typeof Users +}[] = [ + { + value: "member", + label: "Current member", + description: "Active student on the team", + Icon: Users, + }, + { + value: "alumni", + label: "Alumni", + description: "Graduated from the team", + Icon: GraduationCap, + }, + { + value: "guest", + label: "Guest", + description: "Mentor, sponsor, or other", + Icon: UserPlus, + }, +] + function initials(name: string) { return name .split(" ") @@ -17,14 +44,14 @@ function initials(name: string) { .toUpperCase() } -export function WelcomeStep({ identity }: WelcomeStepProps) { +export function WelcomeStep({ identity, data, update }: WelcomeStepProps) { return (

Welcome to Gaucho Racing

- You verified through Discord. We'll set up your Sentinel account in a few short - steps so you can sign in to all the team's tools. + You verified through Discord. Tell us how you'll be joining the team so we can + tailor the rest of setup.

@@ -42,6 +69,44 @@ export function WelcomeStep({ identity }: WelcomeStepProps) { Verified
+ +
+

I'm joining as a…

+
+ {ROLE_OPTIONS.map(({ value, label, description, Icon }) => { + const selected = data.role === value + return ( + + ) + })} +
+
) } diff --git a/web/src/pages/onboarding/types.ts b/web/src/pages/onboarding/types.ts index 1bf73bf..c2f765a 100644 --- a/web/src/pages/onboarding/types.ts +++ b/web/src/pages/onboarding/types.ts @@ -1,4 +1,7 @@ +export type OnboardingRole = "member" | "alumni" | "guest" + export type OnboardingData = { + role: OnboardingRole | "" email: string password: string passwordConfirm: string @@ -14,6 +17,8 @@ export type OnboardingData = { shirtSize: string jacketSize: string saeRegistrationNumber: string + occupationTitle: string + occupationCompany: string } export type DiscordIdentity = { @@ -23,6 +28,7 @@ export type DiscordIdentity = { } export const EMPTY_ONBOARDING_DATA: OnboardingData = { + role: "", email: "", password: "", passwordConfirm: "", @@ -38,6 +44,8 @@ export const EMPTY_ONBOARDING_DATA: OnboardingData = { shirtSize: "", jacketSize: "", saeRegistrationNumber: "", + occupationTitle: "", + occupationCompany: "", } export type StepProps = {