Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
46 changes: 31 additions & 15 deletions discord/api/onboarding_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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{
Expand All @@ -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,
})

Expand All @@ -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()})
Expand Down
2 changes: 1 addition & 1 deletion discord/service/guild.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions discord/service/onboarding_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"net/url"
"time"

"github.com/gaucho-racing/sentinel/discord/config"
Expand All @@ -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
Expand Down Expand Up @@ -78,6 +80,8 @@ type OnboardingConsumePayload struct {
ShirtSize string
JacketSize string
SAERegistrationNumber string
OccupationTitle string
OccupationCompany string
InitialRole string
}

Expand All @@ -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"`
}
Expand All @@ -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,
}
Expand Down
Loading
Loading