Yimaru-BackEnd/internal/services/team/invite.go
Yared Yemane 31bd1e3814 Add team member email invitations for admin panel onboarding
Introduces invite, verify, accept, resend, and revoke flows using team_members and invitation tokens, sends the branded invitation template, and requires account activation before team login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 03:43:00 -07:00

328 lines
9.8 KiB
Go

package team
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/helpers"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMemberReq, invitedBy *int64) (domain.InviteTeamMemberRes, error) {
if s.inviteBaseURL == "" {
return domain.InviteTeamMemberRes{}, domain.ErrTeamInviteBaseURLNotConfigured
}
if !domain.TeamRole(req.TeamRole).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidTeamRole
}
if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidEmploymentType
}
email := strings.TrimSpace(strings.ToLower(req.Email))
exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
if exists {
return domain.InviteTeamMemberRes{}, domain.ErrTeamMemberEmailExists
}
placeholderPassword, err := randomPlaceholderPassword()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(placeholderPassword), bcryptCost)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
var hireDate *time.Time
if req.HireDate != "" {
parsed, err := time.Parse("2006-01-02", req.HireDate)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
hireDate = &parsed
}
member := domain.TeamMember{
FirstName: strings.TrimSpace(req.FirstName),
LastName: strings.TrimSpace(req.LastName),
Email: email,
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
Password: hashedPassword,
TeamRole: domain.TeamRole(req.TeamRole),
Department: strings.TrimSpace(req.Department),
JobTitle: strings.TrimSpace(req.JobTitle),
EmploymentType: domain.EmploymentType(req.EmploymentType),
HireDate: hireDate,
Status: domain.TeamMemberStatusInactive,
EmailVerified: false,
Permissions: req.Permissions,
CreatedBy: invitedBy,
}
created, err := s.teamStore.CreateTeamMember(ctx, member)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
token, err := helpers.GenerateInviteToken()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
expiresAt := time.Now().Add(s.inviteExpiry)
invitation, err := s.teamStore.CreateTeamInvitation(ctx, domain.TeamInvitation{
TeamMemberID: created.ID,
Token: token,
Status: domain.TeamInvitationStatusPending,
ExpiresAt: expiresAt,
InvitedBy: invitedBy,
})
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
inviterName := s.resolveInviterName(ctx, invitedBy)
inviteLink := buildInviteLink(s.inviteBaseURL, token)
if err := s.sendInvitationEmail(ctx, created, inviterName, inviteLink); err != nil {
return domain.InviteTeamMemberRes{}, err
}
return domain.InviteTeamMemberRes{
InvitationID: invitation.ID,
TeamMemberID: created.ID,
Email: created.Email,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *Service) ResendTeamInvitation(ctx context.Context, memberID int64, invitedBy *int64) (domain.InviteTeamMemberRes, error) {
if s.inviteBaseURL == "" {
return domain.InviteTeamMemberRes{}, domain.ErrTeamInviteBaseURLNotConfigured
}
member, err := s.teamStore.GetTeamMemberByID(ctx, memberID)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
if member.Status != domain.TeamMemberStatusInactive || member.EmailVerified {
return domain.InviteTeamMemberRes{}, fmt.Errorf("team member is not awaiting invitation acceptance")
}
if err := s.teamStore.RevokePendingTeamInvitationsForMember(ctx, memberID); err != nil {
return domain.InviteTeamMemberRes{}, err
}
token, err := helpers.GenerateInviteToken()
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
expiresAt := time.Now().Add(s.inviteExpiry)
invitation, err := s.teamStore.CreateTeamInvitation(ctx, domain.TeamInvitation{
TeamMemberID: memberID,
Token: token,
Status: domain.TeamInvitationStatusPending,
ExpiresAt: expiresAt,
InvitedBy: invitedBy,
})
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
inviterName := s.resolveInviterName(ctx, invitedBy)
inviteLink := buildInviteLink(s.inviteBaseURL, token)
if err := s.sendInvitationEmail(ctx, member, inviterName, inviteLink); err != nil {
return domain.InviteTeamMemberRes{}, err
}
return domain.InviteTeamMemberRes{
InvitationID: invitation.ID,
TeamMemberID: member.ID,
Email: member.Email,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
func (s *Service) VerifyTeamInvitation(ctx context.Context, token string) (domain.VerifyTeamInvitationRes, error) {
inv, member, err := s.loadInvitationForToken(ctx, token)
if err != nil {
return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil
}
return domain.VerifyTeamInvitationRes{
Valid: true,
Email: member.Email,
FirstName: member.FirstName,
LastName: member.LastName,
TeamRole: string(member.TeamRole),
ExpiresAt: inv.ExpiresAt,
Status: string(inv.Status),
}, nil
}
func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTeamInvitationReq) (domain.TeamMember, error) {
inv, member, err := s.loadInvitationForToken(ctx, req.Token)
if err != nil {
return domain.TeamMember{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberPassword(ctx, member.ID, string(hashedPassword)); err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberStatus(ctx, domain.UpdateTeamMemberStatusReq{
TeamMemberID: member.ID,
Status: string(domain.TeamMemberStatusActive),
UpdatedBy: member.ID,
}); err != nil {
return domain.TeamMember{}, err
}
if err := s.teamStore.UpdateTeamMemberEmailVerified(ctx, member.ID, true); err != nil {
return domain.TeamMember{}, err
}
if _, err := s.teamStore.AcceptTeamInvitation(ctx, inv.ID); err != nil {
return domain.TeamMember{}, err
}
return s.teamStore.GetTeamMemberByID(ctx, member.ID)
}
func (s *Service) RevokeTeamInvitation(ctx context.Context, invitationID int64) error {
inv, err := s.teamStore.GetTeamInvitationByID(ctx, invitationID)
if err != nil {
return err
}
if inv.Status != domain.TeamInvitationStatusPending {
return fmt.Errorf("only pending invitations can be revoked")
}
if _, err := s.teamStore.RevokeTeamInvitation(ctx, invitationID); err != nil {
return err
}
member, err := s.teamStore.GetTeamMemberByID(ctx, inv.TeamMemberID)
if err != nil {
return err
}
if member.Status == domain.TeamMemberStatusInactive && !member.EmailVerified {
return s.teamStore.DeleteTeamMember(ctx, inv.TeamMemberID)
}
return nil
}
func (s *Service) ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.teamStore.ListTeamInvitations(ctx, status, limit, offset)
}
func (s *Service) loadInvitationForToken(ctx context.Context, token string) (domain.TeamInvitation, domain.TeamMember, error) {
inv, err := s.teamStore.GetTeamInvitationByToken(ctx, strings.TrimSpace(token))
if err != nil {
return domain.TeamInvitation{}, domain.TeamMember{}, err
}
now := time.Now()
if inv.Status == domain.TeamInvitationStatusPending && now.After(inv.ExpiresAt) {
_ = s.teamStore.ExpireTeamInvitation(ctx, inv.ID)
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationExpired
}
switch inv.Status {
case domain.TeamInvitationStatusAccepted:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationAlreadyUsed
case domain.TeamInvitationStatusRevoked:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationRevoked
case domain.TeamInvitationStatusExpired:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationExpired
case domain.TeamInvitationStatusPending:
// continue
default:
return domain.TeamInvitation{}, domain.TeamMember{}, domain.ErrTeamInvitationNotFound
}
member, err := s.teamStore.GetTeamMemberByID(ctx, inv.TeamMemberID)
if err != nil {
return domain.TeamInvitation{}, domain.TeamMember{}, err
}
return inv, member, nil
}
func (s *Service) sendInvitationEmail(ctx context.Context, member domain.TeamMember, inviterName, inviteLink string) error {
if s.emailTemplateSvc == nil || s.messengerSvc == nil {
return fmt.Errorf("email services are not configured")
}
rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{
"FirstName": member.FirstName,
"InviterName": inviterName,
"InviteLink": inviteLink,
})
if err != nil {
return err
}
return s.messengerSvc.SendEmail(ctx, member.Email, rendered.Text, rendered.HTML, rendered.Subject)
}
func (s *Service) resolveInviterName(ctx context.Context, invitedBy *int64) string {
if invitedBy == nil {
return "Yimaru Academy"
}
inviter, err := s.teamStore.GetTeamMemberByID(ctx, *invitedBy)
if err != nil {
return "Yimaru Academy"
}
name := strings.TrimSpace(inviter.FirstName + " " + inviter.LastName)
if name == "" {
return "Yimaru Academy"
}
return name
}
func buildInviteLink(baseURL, token string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
u, err := url.Parse(base)
if err != nil {
return base + "?token=" + url.QueryEscape(token)
}
q := u.Query()
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String()
}
func randomPlaceholderPassword() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}