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
78 changes: 46 additions & 32 deletions backend/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,14 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
}

u = models.User{
FirstName: user.FirstName,
LastName: user.LastName,
Email: user.Email,
AvatarURL: user.AvatarURL,
TeamID: assignedTeamID,
IsAdmin: isAdmin,
UserProfile: models.UserProfile{
FirstName: user.FirstName,
LastName: user.LastName,
Email: user.Email,
AvatarURL: user.AvatarURL,
TeamID: assignedTeamID,
IsAdmin: isAdmin,
},
}
if err := tx.Create(&u).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
Expand Down Expand Up @@ -390,10 +392,12 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error {
}

u := &models.User{
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
Password: req.Password,
UserProfile: models.UserProfile{
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
},
Password: req.Password,
}

if burner.IsBurnerEmail(u.Email) {
Expand Down Expand Up @@ -679,7 +683,9 @@ func (h *AuthHandler) AuthenticateApp(c echo.Context) error {
}

func (h *AuthHandler) User(c echo.Context) error {
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
// Full row: the /user response includes metadata, email_subscriptions and
// unsubscribe_id, which are not part of the slim UserProfile.
user, isAuthenticated := h.getAuthenticatedFullUserFromJWT(c)
if !isAuthenticated {
return c.String(http.StatusUnauthorized, "Unauthorized here")
}
Expand Down Expand Up @@ -729,7 +735,7 @@ func (h *AuthHandler) GenerateDebugCallToken(c echo.Context) error {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return c.String(http.StatusNotFound, "User not found")
}
tokens, err := generateLiveKitTokens(&h.ServerState, "random-name-for-now", &user)
tokens, err := generateLiveKitTokens(&h.ServerState, "random-name-for-now", &user.UserProfile)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to generate callee tokens")
}
Expand All @@ -756,14 +762,19 @@ func (h *AuthHandler) UpdateName(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}

user.FirstName = req.FirstName
user.LastName = req.LastName

if err := h.DB.Save(user).Error; err != nil {
// Targeted column update so the slim profile never overwrites the wide
// columns (hashed_password, metadata, ...) that it does not carry.
if err := h.DB.Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]interface{}{
"first_name": req.FirstName,
"last_name": req.LastName,
}).Error; err != nil {
c.Logger().Error("Failed to save to db:", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update user")
}

user.FirstName = req.FirstName
user.LastName = req.LastName

return c.JSON(http.StatusOK, user)
}

Expand Down Expand Up @@ -985,7 +996,9 @@ func (h *AuthHandler) SendTeamInvites(c echo.Context) error {

// UpdateOnboardingFormStatus updates the user's metadata to mark the onboarding form as completed
func (h *AuthHandler) UpdateOnboardingFormStatus(c echo.Context) error {
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
// Full row: this handler merges into the JSON `metadata` column, which is
// not part of the slim UserProfile.
user, isAuthenticated := h.getAuthenticatedFullUserFromJWT(c)
if !isAuthenticated {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
Expand Down Expand Up @@ -1059,7 +1072,6 @@ func (h *AuthHandler) CreateRoom(c echo.Context) error {
room := models.Room{
Name: req.Name,
UserID: user.ID,
Team: user.Team,
TeamID: user.TeamID,
}

Expand Down Expand Up @@ -1290,7 +1302,9 @@ func (h *AuthHandler) GetLivekitServerURL(c echo.Context) error {
// SubscribeToLinuxWaitingList subscribes the user to the Linux waiting list
// and unsubscribes from marketing emails
func (h *AuthHandler) SubscribeToLinuxWaitingList(c echo.Context) error {
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
// Full row: this handler mutates the JSON `email_subscriptions` column, which
// is not part of the slim UserProfile.
user, isAuthenticated := h.getAuthenticatedFullUserFromJWT(c)
if !isAuthenticated {
return c.String(http.StatusUnauthorized, "Unauthorized request")
}
Expand Down Expand Up @@ -1352,14 +1366,18 @@ func (h *AuthHandler) ChangeTeam(c echo.Context) error {
c.Logger().Infof("Changing user %s team to %d", user.ID, invitation.TeamID)

teamID := uint(invitation.TeamID)
user.TeamID = &teamID
user.Team = &invitation.Team
user.IsAdmin = false

if err := h.DB.Save(&user).Error; err != nil {
// Targeted column update so the slim profile never overwrites the wide
// columns it does not carry.
if err := h.DB.Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]interface{}{
"team_id": teamID,
"is_admin": false,
}).Error; err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update user team")
}

user.TeamID = &teamID
user.IsAdmin = false

return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Successfully changed team",
"team_name": invitation.Team.Name,
Expand All @@ -1379,14 +1397,10 @@ func (h *AuthHandler) RemoveTeammate(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "User is not part of any team")
}

// Preload team to avoid extra query for email
if err := h.DB.Preload("Team").Where("id = ?", user.ID).First(user).Error; err != nil {
team, err := models.GetTeamByID(h.DB, strconv.Itoa(int(*user.TeamID)))
if err != nil {
c.Logger().Error("Failed to load user team:", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load user")
}

if user.Team == nil {
return echo.NewHTTPError(http.StatusBadRequest, "User team not found")
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load user team")
}

teammateID := c.Param("userId")
Expand Down Expand Up @@ -1414,7 +1428,7 @@ func (h *AuthHandler) RemoveTeammate(c echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "user not in your team")
}

oldTeamName := user.Team.Name
oldTeamName := team.Name
var newTeamName string

if err := h.DB.Transaction(func(tx *gorm.DB) error {
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/handlers/slackHandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ func lookupSlackUserByEmail(botToken, email string) (string, error) {
// It first tries to look up the user's Slack ID by email. If found, uses the native
// Slack user format (which enables automatic status updates). Falls back to external
// user format if the email lookup fails.
func addParticipantToSlackCall(botToken, callID string, user *models.User) error {
func addParticipantToSlackCall(botToken, callID string, user *models.UserProfile) error {
api := newSlackClient(botToken)

var participant slack.CallParticipant
Expand Down Expand Up @@ -722,7 +722,7 @@ func (h *SlackHandler) createSlackCall(botToken, externalID, createdBySlackUserI

// removeParticipantFromSlackCall removes a user from a Slack call.
// It uses the user's email to look up their Slack ID, falling back to external_id.
func removeParticipantFromSlackCall(botToken, callID string, user *models.User) error {
func removeParticipantFromSlackCall(botToken, callID string, user *models.UserProfile) error {
api := newSlackClient(botToken)

var participant slack.CallParticipant
Expand Down Expand Up @@ -793,7 +793,7 @@ func (h *SlackHandler) LeaveRoom(c echo.Context) error {
}
}()
} else {
h.logger.Warnf("Failed to get Slack metadata for participant removal for team %s", user.Team)
h.logger.Warnf("Failed to get Slack metadata for participant removal for team %v", user.TeamID)
}
}

Expand Down
53 changes: 38 additions & 15 deletions backend/internal/handlers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func getTeamMembersRawJSON(accessToken string) ([]byte, error) {
return body, nil
}

func generateLiveKitTokens(s *common.ServerState, roomName string, participant *models.User) (livekitutil.LivekitTokenSet, error) {
func generateLiveKitTokens(s *common.ServerState, roomName string, participant *models.UserProfile) (livekitutil.LivekitTokenSet, error) {
// Create an access token (make sure these are loaded from your config)
videoID := fmt.Sprintf("room:%s:%s:video", roomName, participant.ID)
audioID := fmt.Sprintf("room:%s:%s:audio", roomName, participant.ID)
Expand Down Expand Up @@ -137,15 +137,39 @@ func generateLiveKitTokens(s *common.ServerState, roomName string, participant *
}, nil
}

// GetAuthenticatedUser returns the authenticated user from the session
// Returns nil and false if the user is not authenticated or not found
func getAuthenticatedUserFromJWTCommon(c echo.Context, jwtIssuer common.JWTIssuer, db *gorm.DB) (*models.User, bool) {
// getAuthenticatedUserFromJWTCommon returns the authenticated user's slim
// profile from the JWT. It fetches only the UserProfile columns, which is all
// the read-only request paths need, and keeps the wide `users` row off the wire
// on hot endpoints. Returns nil and false if the user is not authenticated or
// not found.
//
// The returned *models.UserProfile is a partial record: never pass it to
// db.Save. Handlers that mutate JSON columns or need the full row must use
// getAuthenticatedFullUserFromJWT instead.
func getAuthenticatedUserFromJWTCommon(c echo.Context, jwtIssuer common.JWTIssuer, db *gorm.DB) (*models.UserProfile, bool) {
email, err := jwtIssuer.GetUserEmail(c)
if err != nil {
return nil, false
}

// Fetch user profile from database
user, err := models.GetUserProfileByEmail(db, email)
if err != nil {
return nil, false
}

return user, true
}

// getAuthenticatedFullUserFromJWTCommon returns the full authenticated user row.
// Use this only on paths that read or write fields outside UserProfile
// (metadata, email_subscriptions, hashed_password, ...).
func getAuthenticatedFullUserFromJWTCommon(c echo.Context, jwtIssuer common.JWTIssuer, db *gorm.DB) (*models.User, bool) {
email, err := jwtIssuer.GetUserEmail(c)
if err != nil {
return nil, false
}

// Fetch user from database
user, err := models.GetUserByEmail(db, email)
if err != nil {
return nil, false
Expand All @@ -154,11 +178,15 @@ func getAuthenticatedUserFromJWTCommon(c echo.Context, jwtIssuer common.JWTIssue
return user, true
}

func (h *AuthHandler) getAuthenticatedUserFromJWT(c echo.Context) (*models.User, bool) {
func (h *AuthHandler) getAuthenticatedUserFromJWT(c echo.Context) (*models.UserProfile, bool) {
return getAuthenticatedUserFromJWTCommon(c, h.JwtIssuer, h.DB)
}

func (bh *BillingHandler) getAuthenticatedUserFromJWT(c echo.Context) (*models.User, bool) {
func (h *AuthHandler) getAuthenticatedFullUserFromJWT(c echo.Context) (*models.User, bool) {
return getAuthenticatedFullUserFromJWTCommon(c, h.JwtIssuer, h.DB)
}

func (bh *BillingHandler) getAuthenticatedUserFromJWT(c echo.Context) (*models.UserProfile, bool) {
return getAuthenticatedUserFromJWTCommon(c, bh.JwtIssuer, bh.DB)
}

Expand All @@ -172,21 +200,16 @@ func sameTeam(a, b *uint) bool {
// checkUserHasAccess checks if a user has an active subscription or trial.
// Returns true if the user is a Pro subscriber or has an active trial, false otherwise.
// Returns an error if the subscription check fails.
func checkUserHasAccess(db *gorm.DB, user *models.User, stripeEnabled bool) (bool, error) {
func checkUserHasAccess(db *gorm.DB, user *models.UserProfile, stripeEnabled bool) (bool, error) {
// If user has no team, they have no subscription or trial access
if user.TeamID == nil {
return false, nil
}

userWithSub, err := models.GetUserWithSubscription(db, user, stripeEnabled)
access, err := models.GetTeamAccess(db, *user.TeamID, stripeEnabled)
if err != nil {
return false, fmt.Errorf("failed to get user subscription: %w", err)
}

hasAccess := userWithSub.IsPro
if !hasAccess && userWithSub.IsTrial && userWithSub.TrialEndsAt != nil {
hasAccess = userWithSub.TrialEndsAt.After(time.Now())
}

return hasAccess, nil
return access.Active(time.Now()), nil
}
8 changes: 4 additions & 4 deletions backend/internal/handlers/websocketHandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc {
return err
}

user, err := models.GetUserByEmail(server.DB, email)
user, err := models.GetUserProfileByEmail(server.DB, email)
if err != nil {
return err
}
Expand Down Expand Up @@ -351,7 +351,7 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r
}

// Check if caller has access (paid or active trial)
hasAccess, err := checkUserHasAccess(s.DB, caller, s.Config.IsStripeEnabled())
hasAccess, err := checkUserHasAccess(s.DB, &caller.UserProfile, s.Config.IsStripeEnabled())
if err != nil {
ctx.Logger().Error("Error getting caller subscription: ", err)
s.Redis.Del(rdbCtx, dedupeKey)
Expand Down Expand Up @@ -462,14 +462,14 @@ func acceptCall(ctx echo.Context, s *common.ServerState, calleeID string, messag
roomName := uuid.New().String()
ctx.Logger().Info("Creating room: ", roomName, " for users ", callerID, " ", calleeID)

calleeTokens, err := generateLiveKitTokens(s, roomName, callee)
calleeTokens, err := generateLiveKitTokens(s, roomName, &callee.UserProfile)
if err != nil {
ctx.Logger().Error(err)
sendCommonErrorMessage(s, "Failed to generate callee tokens", callerID, calleeID)
return
}

callerTokens, err := generateLiveKitTokens(s, roomName, caller)
callerTokens, err := generateLiveKitTokens(s, roomName, &caller.UserProfile)
if err != nil {
ctx.Logger().Error(err)
sendCommonErrorMessage(s, "Failed to generate caller tokens", callerID, calleeID)
Expand Down
Loading
Loading