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 } 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 } member := domain.TeamMember{ FirstName: domain.TeamInvitePlaceholderFirstName, LastName: domain.TeamInvitePlaceholderLastName, Email: email, Password: hashedPassword, TeamRole: domain.TeamRole(req.TeamRole), Status: domain.TeamMemberStatusInactive, EmailVerified: false, 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 } res := domain.VerifyTeamInvitationRes{ Valid: true, Email: member.Email, TeamRole: string(member.TeamRole), ExpiresAt: inv.ExpiresAt, Status: string(inv.Status), } if domain.IsTeamInvitePlaceholderProfile(member.FirstName, member.LastName) { res.NeedsProfileSetup = true } return res, 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 } if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() { return domain.TeamMember{}, domain.ErrInvalidEmploymentType } updateReq := domain.UpdateTeamMemberReq{ TeamMemberID: member.ID, UpdatedBy: member.ID, FirstName: strings.TrimSpace(req.FirstName), LastName: strings.TrimSpace(req.LastName), PhoneNumber: strings.TrimSpace(req.PhoneNumber), Department: strings.TrimSpace(req.Department), JobTitle: strings.TrimSpace(req.JobTitle), EmploymentType: strings.TrimSpace(req.EmploymentType), HireDate: strings.TrimSpace(req.HireDate), ProfilePictureURL: strings.TrimSpace(req.ProfilePictureURL), Bio: strings.TrimSpace(req.Bio), WorkPhone: strings.TrimSpace(req.WorkPhone), EmergencyContact: strings.TrimSpace(req.EmergencyContact), } if err := s.UpdateTeamMember(ctx, updateReq); 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") } var firstName string if !domain.IsTeamInvitePlaceholderProfile(member.FirstName, member.LastName) { firstName = member.FirstName } rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{ "FirstName": 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 }