diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index 599ec82..f86e66e 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -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 diff --git a/backend/internal/email/email.go b/backend/internal/email/email.go index d3f8d1d..725f24b 100644 --- a/backend/internal/email/email.go +++ b/backend/internal/email/email.go @@ -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) } @@ -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 { diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index fd64a0e..45caee9 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -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") + } + + 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 diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index c5c4eee..6f85ac7 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -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) @@ -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 } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1be01b0..1c8356f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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) diff --git a/backend/web/emails/hopp-team-removed.html b/backend/web/emails/hopp-team-removed.html new file mode 100644 index 0000000..c4bd305 --- /dev/null +++ b/backend/web/emails/hopp-team-removed.html @@ -0,0 +1,288 @@ + + +
+ + + + + + + +
+
+ Team Update ++
+ Hi + {first_name}, + ++ You've been removed from {old_team_name}. + ++ Don't worry! You've been automatically assigned to your new personal team + {new_team_name} as an admin. + +
+
|
+