Simplify team invite to email and role; collect profile on accept
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ad7f094cf
commit
215a4bd1dc
|
|
@ -43,32 +43,47 @@ 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"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
LastName string `json:"last_name" validate:"required"`
|
TeamRole string `json:"team_role" validate:"required"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
|
||||||
PhoneNumber string `json:"phone_number"`
|
|
||||||
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"`
|
TeamRole string `json:"team_role,omitempty"`
|
||||||
LastName string `json:"last_name,omitempty"`
|
NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"`
|
||||||
TeamRole string `json:"team_role,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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type InviteTeamMemberRes struct {
|
type InviteTeamMemberRes struct {
|
||||||
|
|
|
||||||
|
|
@ -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,30 +40,15 @@ 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),
|
Status: domain.TeamMemberStatusInactive,
|
||||||
Department: strings.TrimSpace(req.Department),
|
EmailVerified: false,
|
||||||
JobTitle: strings.TrimSpace(req.JobTitle),
|
CreatedBy: invitedBy,
|
||||||
EmploymentType: domain.EmploymentType(req.EmploymentType),
|
|
||||||
HireDate: hireDate,
|
|
||||||
Status: domain.TeamMemberStatusInactive,
|
|
||||||
EmailVerified: false,
|
|
||||||
Permissions: req.Permissions,
|
|
||||||
CreatedBy: invitedBy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := s.teamStore.CreateTeamMember(ctx, member)
|
created, err := s.teamStore.CreateTeamMember(ctx, member)
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user