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>
328 lines
9.8 KiB
Go
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
|
|
}
|