diff --git a/internal/domain/team_invitation.go b/internal/domain/team_invitation.go index cdab399..2d3a58a 100644 --- a/internal/domain/team_invitation.go +++ b/internal/domain/team_invitation.go @@ -43,32 +43,47 @@ type TeamInvitationWithMember struct { 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 { - FirstName string `json:"first_name" validate:"required"` - LastName string `json:"last_name" 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"` + Email string `json:"email" validate:"required,email"` + TeamRole string `json:"team_role" validate:"required"` } type AcceptTeamInvitationReq struct { Token string `json:"token" validate:"required"` 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 { - Valid bool `json:"valid"` - Email string `json:"email,omitempty"` - FirstName string `json:"first_name,omitempty"` - LastName string `json:"last_name,omitempty"` - TeamRole string `json:"team_role,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - Status string `json:"status,omitempty"` + Valid bool `json:"valid"` + Email string `json:"email,omitempty"` + TeamRole string `json:"team_role,omitempty"` + NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Status string `json:"status,omitempty"` } type InviteTeamMemberRes struct { diff --git a/internal/services/team/invite.go b/internal/services/team/invite.go index 37f66c3..d2ff5a1 100644 --- a/internal/services/team/invite.go +++ b/internal/services/team/invite.go @@ -21,9 +21,6 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem 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) @@ -43,30 +40,15 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem 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, + 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) @@ -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{ + res := 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 + } + 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) { @@ -177,6 +161,29 @@ func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTea 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 @@ -279,8 +286,13 @@ func (s *Service) sendInvitationEmail(ctx context.Context, member domain.TeamMem 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": member.FirstName, + "FirstName": firstName, "InviterName": inviterName, "InviteLink": inviteLink, }) diff --git a/internal/web_server/handlers/team_invitation_handler.go b/internal/web_server/handlers/team_invitation_handler.go index 6a5d921..d42835c 100644 --- a/internal/web_server/handlers/team_invitation_handler.go +++ b/internal/web_server/handlers/team_invitation_handler.go @@ -31,12 +31,16 @@ type listTeamInvitationsRes struct { } func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem { + firstName, lastName := row.FirstName, row.LastName + if domain.IsTeamInvitePlaceholderProfile(firstName, lastName) { + firstName, lastName = "", "" + } return teamInvitationListItem{ ID: row.ID, TeamMemberID: row.TeamMemberID, Email: row.Email, - FirstName: row.FirstName, - LastName: row.LastName, + FirstName: firstName, + LastName: lastName, TeamRole: string(row.TeamRole), Status: string(row.Status), ExpiresAt: row.ExpiresAt.Format(time.RFC3339), @@ -46,7 +50,7 @@ func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitati // InviteTeamMember godoc // @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 // @Accept json // @Produce json @@ -236,8 +240,8 @@ func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error { } // AcceptTeamInvitation godoc -// @Summary Accept team invitation and set password -// @Description Public endpoint to activate a team member account after following the invite link +// @Summary Accept team invitation and complete account setup +// @Description Public endpoint to set password and profile details after following the invite link // @Tags team // @Accept json // @Produce json