Simplify team invite to email and role; collect profile on accept

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-22 06:49:28 -07:00
parent 0ad7f094cf
commit 215a4bd1dc
3 changed files with 84 additions and 53 deletions

View File

@ -43,30 +43,45 @@ type TeamInvitationWithMember struct {
TeamRole TeamRole TeamRole TeamRole
} }
const (
TeamInvitePlaceholderFirstName = "Pending"
TeamInvitePlaceholderLastName = "Invite"
)
func IsTeamInvitePlaceholderProfile(firstName, lastName string) bool {
return firstName == TeamInvitePlaceholderFirstName && lastName == TeamInvitePlaceholderLastName
}
// InviteTeamMemberReq only requires email and role; profile fields are collected on accept.
type InviteTeamMemberReq struct { type InviteTeamMemberReq struct {
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
PhoneNumber string `json:"phone_number"`
TeamRole string `json:"team_role" validate:"required"` TeamRole string `json:"team_role" validate:"required"`
Department string `json:"department"`
JobTitle string `json:"job_title"`
EmploymentType string `json:"employment_type"`
HireDate string `json:"hire_date"`
Permissions []string `json:"permissions"`
} }
type AcceptTeamInvitationReq struct { type AcceptTeamInvitationReq struct {
Token string `json:"token" validate:"required"` Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8"` Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
PhoneNumber string `json:"phone_number"`
Department string `json:"department"`
JobTitle string `json:"job_title"`
EmploymentType string `json:"employment_type"`
HireDate string `json:"hire_date"` // YYYY-MM-DD
ProfilePictureURL string `json:"profile_picture_url"`
Bio string `json:"bio"`
WorkPhone string `json:"work_phone"`
EmergencyContact string `json:"emergency_contact"`
} }
type VerifyTeamInvitationRes struct { type VerifyTeamInvitationRes struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
TeamRole string `json:"team_role,omitempty"` TeamRole string `json:"team_role,omitempty"`
NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
} }

View File

@ -21,9 +21,6 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem
if !domain.TeamRole(req.TeamRole).IsValid() { if !domain.TeamRole(req.TeamRole).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidTeamRole 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)) email := strings.TrimSpace(strings.ToLower(req.Email))
exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email) exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email)
@ -43,29 +40,14 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem
return domain.InviteTeamMemberRes{}, err 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{ member := domain.TeamMember{
FirstName: strings.TrimSpace(req.FirstName), FirstName: domain.TeamInvitePlaceholderFirstName,
LastName: strings.TrimSpace(req.LastName), LastName: domain.TeamInvitePlaceholderLastName,
Email: email, Email: email,
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
Password: hashedPassword, Password: hashedPassword,
TeamRole: domain.TeamRole(req.TeamRole), 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, Status: domain.TeamMemberStatusInactive,
EmailVerified: false, EmailVerified: false,
Permissions: req.Permissions,
CreatedBy: invitedBy, CreatedBy: invitedBy,
} }
@ -160,15 +142,17 @@ func (s *Service) VerifyTeamInvitation(ctx context.Context, token string) (domai
return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil
} }
return domain.VerifyTeamInvitationRes{ res := domain.VerifyTeamInvitationRes{
Valid: true, Valid: true,
Email: member.Email, Email: member.Email,
FirstName: member.FirstName,
LastName: member.LastName,
TeamRole: string(member.TeamRole), TeamRole: string(member.TeamRole),
ExpiresAt: inv.ExpiresAt, ExpiresAt: inv.ExpiresAt,
Status: string(inv.Status), Status: string(inv.Status),
}, nil }
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) { func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTeamInvitationReq) (domain.TeamMember, error) {
@ -177,6 +161,29 @@ func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTea
return domain.TeamMember{}, err 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) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil { if err != nil {
return domain.TeamMember{}, err return domain.TeamMember{}, err
@ -279,8 +286,13 @@ func (s *Service) sendInvitationEmail(ctx context.Context, member domain.TeamMem
return fmt.Errorf("email services are not configured") 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{ rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{
"FirstName": member.FirstName, "FirstName": firstName,
"InviterName": inviterName, "InviterName": inviterName,
"InviteLink": inviteLink, "InviteLink": inviteLink,
}) })

View File

@ -31,12 +31,16 @@ type listTeamInvitationsRes struct {
} }
func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem { func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem {
firstName, lastName := row.FirstName, row.LastName
if domain.IsTeamInvitePlaceholderProfile(firstName, lastName) {
firstName, lastName = "", ""
}
return teamInvitationListItem{ return teamInvitationListItem{
ID: row.ID, ID: row.ID,
TeamMemberID: row.TeamMemberID, TeamMemberID: row.TeamMemberID,
Email: row.Email, Email: row.Email,
FirstName: row.FirstName, FirstName: firstName,
LastName: row.LastName, LastName: lastName,
TeamRole: string(row.TeamRole), TeamRole: string(row.TeamRole),
Status: string(row.Status), Status: string(row.Status),
ExpiresAt: row.ExpiresAt.Format(time.RFC3339), ExpiresAt: row.ExpiresAt.Format(time.RFC3339),
@ -46,7 +50,7 @@ func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitati
// InviteTeamMember godoc // InviteTeamMember godoc
// @Summary Invite a team member by email // @Summary Invite a team member by email
// @Description Creates a pending team member and sends an invitation email with a setup link // @Description Creates a pending team member (email + team_role only) and sends an invitation email; profile is completed on accept
// @Tags team // @Tags team
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -236,8 +240,8 @@ func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error {
} }
// AcceptTeamInvitation godoc // AcceptTeamInvitation godoc
// @Summary Accept team invitation and set password // @Summary Accept team invitation and complete account setup
// @Description Public endpoint to activate a team member account after following the invite link // @Description Public endpoint to set password and profile details after following the invite link
// @Tags team // @Tags team
// @Accept json // @Accept json
// @Produce json // @Produce json