-
Notifications
You must be signed in to change notification settings - Fork 25
feat: remove team member #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
adfe407
fd44b9f
5db66ad
1bae1f9
ad6873f
eff392d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,30 @@ 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 | ||
} | ||
|
||
htmlBody := string(templateBytes) | ||
htmlBody = strings.Replace(htmlBody, "{first_name}", user.FirstName, -1) | ||
htmlBody = strings.Replace(htmlBody, "{old_team_name}", oldTeamName, -1) | ||
htmlBody = strings.Replace(htmlBody, "{new_team_name}", newTeamName, -1) | ||
|
||
subject := fmt.Sprintf("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 { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -860,6 +860,91 @@ 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
|
||
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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Recently I learned this is a good use case for strings.Replacer