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
48 changes: 48 additions & 0 deletions backend/api-files/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,54 @@ paths:
schema:
$ref: "#/components/schemas/Error"

/api/auth/teammates/{userId}:
delete:
summary: Remove a teammate from your team
description: Removes a user from the team and automatically creates a new solo team for them. The removed user becomes an admin of their new team and can continue using Hopp. Admins cannot remove themselves.
security:
- BearerAuth: []
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the user to remove from the team
responses:
"204":
description: Teammate removed successfully
"400":
description: Bad request - missing userId, cannot remove yourself, or cannot remove last admin
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Forbidden - user is not an admin or target user is not in your team
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/api/auth/billing/subscription:
get:
summary: Get current subscription status
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type EmailClient interface {
SendAsync(toEmail, subject, htmlBody string)
SendWelcomeEmail(user *models.User)
SendTeamInvitationEmail(inviterName, teamName, inviteLink, toEmail string)
SendTeamRemovalEmail(user *models.User, oldTeamName, newTeamName string)
SendSubscriptionConfirmationEmail(user *models.User)
SendSubscriptionCancellationEmail(user *models.User)
}
Expand Down Expand Up @@ -110,6 +111,32 @@ func (c *ResendEmailClient) SendTeamInvitationEmail(inviterName, teamName, invit
c.SendAsync(toEmail, subject, htmlBody)
}

// SendTeamRemovalEmail sends an email to a user who has been removed from a team
func (c *ResendEmailClient) SendTeamRemovalEmail(user *models.User, oldTeamName, newTeamName string) {
if user == nil {
c.logger.Error("Cannot send team removal email to nil user")
return
}

// Read the template file
templateBytes, err := os.ReadFile("web/emails/hopp-team-removed.html")
if err != nil {
c.logger.Errorf("Failed to read team removal email template: %v", err)
return
}

replacer := strings.NewReplacer(
"{first_name}", user.FirstName,
"{old_team_name}", oldTeamName,
"{new_team_name}", newTeamName,
)
htmlBody := replacer.Replace(string(templateBytes))

subject := fmt.Sprintf("Hopp: You've been removed from %s", oldTeamName)

c.SendAsync(user.Email, subject, htmlBody)
}

// SendSubscriptionConfirmationEmail sends a subscription confirmation email
func (c *ResendEmailClient) SendSubscriptionConfirmationEmail(user *models.User) {
if user == nil {
Expand Down
90 changes: 90 additions & 0 deletions backend/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,96 @@ func (h *AuthHandler) ChangeTeam(c echo.Context) error {
})
}

// RemoveTeammate removes a user from a team and creates a new solo team for them
// removed user will also receive an email notification
func (h *AuthHandler) RemoveTeammate(c echo.Context) error {
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
if !isAuthenticated {
return c.String(http.StatusUnauthorized, "Unauthorized request")
}

if user.TeamID == nil {
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 {
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")
}

teammateID := c.Param("userId")
if teammateID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "userId is required")
}

if user.ID == teammateID {
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove yourself")
}

if !user.IsAdmin {
return echo.NewHTTPError(http.StatusForbidden, "admin required")
}
Comment on lines +894 to +896
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: those checks are preferable to be highest priority, as you avoid doing any roundtrips to the DB. Fail fast type of thing.


var teammate models.User
if err := h.DB.Select("id, team_id, is_admin, first_name, last_name, email").Where("id = ?", teammateID).First(&teammate).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load user")
}

if teammate.TeamID == nil || *teammate.TeamID != *user.TeamID {
return echo.NewHTTPError(http.StatusForbidden, "user not in your team")
}

oldTeamName := user.Team.Name
var newTeamName string

if err := h.DB.Transaction(func(tx *gorm.DB) error {

// Create new team
newTeamName := fmt.Sprintf("team-%s", uuid.NewString()[:8])
newTeam := models.Team{
Name: newTeamName,
}
if err := tx.Create(&newTeam).Error; err != nil {
return err
}

// assign new team to removed user
if err := tx.Model(&models.User{}).
Where("id = ?", teammate.ID).
Updates(map[string]any{
"team_id": newTeam.ID,
"is_admin": true,
}).Error; err != nil {
return err
}

// Update subscription quantity if there is a subscription for the old team
if err := models.UpdateSubscriptionQuantity(tx, *user.TeamID); err != nil {
return err
}

return nil
}); err != nil {
c.Logger().Error("RemoveTeammate error:", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to remove teammate")
}

// Send email to removed user
if h.EmailClient != nil {
h.EmailClient.SendTeamRemovalEmail(&teammate, oldTeamName, newTeamName)
}

return c.NoContent(http.StatusNoContent)
}

// UnsubscribeUser handles both GET and POST requests for unsubscribing users.
// Follows instructions from:
// https://resend.com/docs/dashboard/emails/add-unsubscribe-to-transactional-emails
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (u *User) UnsubscribeFromAllEmails(db *gorm.DB) error {
return db.Save(u).Error
}

func updateSubscriptionQuantity(tx *gorm.DB, teamID uint) error {
func UpdateSubscriptionQuantity(tx *gorm.DB, teamID uint) error {
teamMembers, err := GetTeamMembersByTeamID(tx, teamID)
if err != nil {
return fmt.Errorf("failed to get team members: %w", err)
Expand Down Expand Up @@ -221,12 +221,12 @@ func updateSubscriptionQuantity(tx *gorm.DB, teamID uint) error {
}

func (u *User) AfterCreate(tx *gorm.DB) (err error) {
_ = updateSubscriptionQuantity(tx, *u.TeamID)
_ = UpdateSubscriptionQuantity(tx, *u.TeamID)
return nil
}

func (u *User) AfterDelete(tx *gorm.DB) (err error) {
_ = updateSubscriptionQuantity(tx, *u.TeamID)
_ = UpdateSubscriptionQuantity(tx, *u.TeamID)
return nil
}

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ func (s *Server) setupRoutes() {
protectedAPI.GET("/user", auth.User)
protectedAPI.PUT("/update-user-name", auth.UpdateName)
protectedAPI.GET("/teammates", auth.Teammates)
protectedAPI.DELETE("/teammates/:userId", auth.RemoveTeammate)

protectedAPI.GET("/websocket", handlers.CreateWSHandler(&s.ServerState))
protectedAPI.GET("/get-invite-uuid", auth.GetInviteUUID)
protectedAPI.POST("/change-team/:uuid", auth.ChangeTeam)
Expand Down
Loading