From 31bd1e3814315899d45f6151c9f7ea665f2db427 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 22 May 2026 03:43:00 -0700 Subject: [PATCH] Add team member email invitations for admin panel onboarding 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 --- cmd/main.go | 9 +- .../000068_team_invitations.down.sql | 1 + db/migrations/000068_team_invitations.up.sql | 18 + db/query/team_invitations.sql | 80 +++++ gen/db/models.go | 26 ++ gen/db/team_invitations.sql.go | 284 +++++++++++++++ internal/config/config.go | 11 + internal/domain/activity_log.go | 1 + internal/domain/team_invitation.go | 79 +++++ internal/pkgs/helpers/helpers.go | 9 + internal/ports/team.go | 10 + internal/repository/team_invitations.go | 152 ++++++++ internal/services/rbac/seeds.go | 7 +- internal/services/team/invite.go | 327 ++++++++++++++++++ internal/services/team/service.go | 25 +- internal/services/team/team.go | 7 + internal/web_server/handlers/team_handler.go | 5 + .../handlers/team_invitation_handler.go | 296 ++++++++++++++++ internal/web_server/routes.go | 6 + 19 files changed, 1349 insertions(+), 4 deletions(-) create mode 100644 db/migrations/000068_team_invitations.down.sql create mode 100644 db/migrations/000068_team_invitations.up.sql create mode 100644 db/query/team_invitations.sql create mode 100644 gen/db/team_invitations.sql.go create mode 100644 internal/domain/team_invitation.go create mode 100644 internal/repository/team_invitations.go create mode 100644 internal/services/team/invite.go create mode 100644 internal/web_server/handlers/team_invitation_handler.go diff --git a/cmd/main.go b/cmd/main.go index 1ccea52..2d63059 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -437,7 +437,14 @@ func main() { ) // Team management service - teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry) + teamSvc := team.NewService( + repository.NewTeamStore(store), + cfg.RefreshExpiry, + emailTemplateSvc, + messengerSvc, + cfg.TeamInviteBaseURL, + cfg.TeamInviteExpiry, + ) // santimpayClient := santimpay.NewSantimPayClient(cfg) diff --git a/db/migrations/000068_team_invitations.down.sql b/db/migrations/000068_team_invitations.down.sql new file mode 100644 index 0000000..08d9d20 --- /dev/null +++ b/db/migrations/000068_team_invitations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS team_invitations; diff --git a/db/migrations/000068_team_invitations.up.sql b/db/migrations/000068_team_invitations.up.sql new file mode 100644 index 0000000..bb50800 --- /dev/null +++ b/db/migrations/000068_team_invitations.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS team_invitations ( + id BIGSERIAL PRIMARY KEY, + team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE, + token VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'accepted', 'expired', 'revoked') + ), + expires_at TIMESTAMPTZ NOT NULL, + invited_by BIGINT, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations(token); +CREATE INDEX IF NOT EXISTS idx_team_invitations_team_member_id ON team_invitations(team_member_id); +CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations(status); +CREATE INDEX IF NOT EXISTS idx_team_invitations_expires_at ON team_invitations(expires_at); diff --git a/db/query/team_invitations.sql b/db/query/team_invitations.sql new file mode 100644 index 0000000..e965de8 --- /dev/null +++ b/db/query/team_invitations.sql @@ -0,0 +1,80 @@ +-- name: CreateTeamInvitation :one +INSERT INTO team_invitations ( + team_member_id, + token, + status, + expires_at, + invited_by, + updated_at +) +VALUES ($1, $2, 'pending', $3, $4, CURRENT_TIMESTAMP) +RETURNING *; + +-- name: GetTeamInvitationByToken :one +SELECT * FROM team_invitations +WHERE token = $1; + +-- name: GetTeamInvitationByID :one +SELECT * FROM team_invitations +WHERE id = $1; + +-- name: GetPendingTeamInvitationByMemberID :one +SELECT * FROM team_invitations +WHERE team_member_id = $1 + AND status = 'pending' +ORDER BY created_at DESC +LIMIT 1; + +-- name: RevokePendingTeamInvitationsForMember :exec +UPDATE team_invitations +SET status = 'revoked', + updated_at = CURRENT_TIMESTAMP +WHERE team_member_id = $1 + AND status = 'pending'; + +-- name: AcceptTeamInvitation :one +UPDATE team_invitations +SET status = 'accepted', + accepted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending' +RETURNING *; + +-- name: RevokeTeamInvitation :one +UPDATE team_invitations +SET status = 'revoked', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending' +RETURNING *; + +-- name: ExpireTeamInvitation :exec +UPDATE team_invitations +SET status = 'expired', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending'; + +-- name: ListTeamInvitations :many +SELECT + ti.id, + ti.team_member_id, + ti.token, + ti.status, + ti.expires_at, + ti.invited_by, + ti.accepted_at, + ti.created_at, + ti.updated_at, + tm.email, + tm.first_name, + tm.last_name, + tm.team_role, + COUNT(*) OVER () AS total_count +FROM team_invitations ti +INNER JOIN team_members tm ON tm.id = ti.team_member_id +WHERE (sqlc.narg('status')::text IS NULL OR ti.status = sqlc.narg('status')::text) +ORDER BY ti.created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; diff --git a/gen/db/models.go b/gen/db/models.go index a5217f0..e827781 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -43,6 +43,20 @@ type Device struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type EmailTemplate struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Subject string `json:"subject"` + BodyText string `json:"body_text"` + BodyHtml string `json:"body_html"` + Variables []byte `json:"variables"` + IsSystem bool `json:"is_system"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type ExamPrepCatalogCourse struct { ID int64 `json:"id"` Name string `json:"name"` @@ -445,6 +459,18 @@ type SubscriptionPlan struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type TeamInvitation struct { + ID int64 `json:"id"` + TeamMemberID int64 `json:"team_member_id"` + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + InvitedBy pgtype.Int8 `json:"invited_by"` + AcceptedAt pgtype.Timestamptz `json:"accepted_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type TeamMember struct { ID int64 `json:"id"` FirstName string `json:"first_name"` diff --git a/gen/db/team_invitations.sql.go b/gen/db/team_invitations.sql.go new file mode 100644 index 0000000..473be79 --- /dev/null +++ b/gen/db/team_invitations.sql.go @@ -0,0 +1,284 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: team_invitations.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AcceptTeamInvitation = `-- name: AcceptTeamInvitation :one +UPDATE team_invitations +SET status = 'accepted', + accepted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending' +RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at +` + +func (q *Queries) AcceptTeamInvitation(ctx context.Context, id int64) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, AcceptTeamInvitation, id) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const CreateTeamInvitation = `-- name: CreateTeamInvitation :one +INSERT INTO team_invitations ( + team_member_id, + token, + status, + expires_at, + invited_by, + updated_at +) +VALUES ($1, $2, 'pending', $3, $4, CURRENT_TIMESTAMP) +RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at +` + +type CreateTeamInvitationParams struct { + TeamMemberID int64 `json:"team_member_id"` + Token string `json:"token"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + InvitedBy pgtype.Int8 `json:"invited_by"` +} + +func (q *Queries) CreateTeamInvitation(ctx context.Context, arg CreateTeamInvitationParams) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, CreateTeamInvitation, + arg.TeamMemberID, + arg.Token, + arg.ExpiresAt, + arg.InvitedBy, + ) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ExpireTeamInvitation = `-- name: ExpireTeamInvitation :exec +UPDATE team_invitations +SET status = 'expired', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending' +` + +func (q *Queries) ExpireTeamInvitation(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ExpireTeamInvitation, id) + return err +} + +const GetPendingTeamInvitationByMemberID = `-- name: GetPendingTeamInvitationByMemberID :one +SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations +WHERE team_member_id = $1 + AND status = 'pending' +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetPendingTeamInvitationByMemberID(ctx context.Context, teamMemberID int64) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, GetPendingTeamInvitationByMemberID, teamMemberID) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetTeamInvitationByID = `-- name: GetTeamInvitationByID :one +SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations +WHERE id = $1 +` + +func (q *Queries) GetTeamInvitationByID(ctx context.Context, id int64) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, GetTeamInvitationByID, id) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetTeamInvitationByToken = `-- name: GetTeamInvitationByToken :one +SELECT id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at FROM team_invitations +WHERE token = $1 +` + +func (q *Queries) GetTeamInvitationByToken(ctx context.Context, token string) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, GetTeamInvitationByToken, token) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ListTeamInvitations = `-- name: ListTeamInvitations :many +SELECT + ti.id, + ti.team_member_id, + ti.token, + ti.status, + ti.expires_at, + ti.invited_by, + ti.accepted_at, + ti.created_at, + ti.updated_at, + tm.email, + tm.first_name, + tm.last_name, + tm.team_role, + COUNT(*) OVER () AS total_count +FROM team_invitations ti +INNER JOIN team_members tm ON tm.id = ti.team_member_id +WHERE ($1::text IS NULL OR ti.status = $1::text) +ORDER BY ti.created_at DESC +LIMIT $3::INT +OFFSET $2::INT +` + +type ListTeamInvitationsParams struct { + Status pgtype.Text `json:"status"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type ListTeamInvitationsRow struct { + ID int64 `json:"id"` + TeamMemberID int64 `json:"team_member_id"` + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + InvitedBy pgtype.Int8 `json:"invited_by"` + AcceptedAt pgtype.Timestamptz `json:"accepted_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TeamRole string `json:"team_role"` + TotalCount int64 `json:"total_count"` +} + +func (q *Queries) ListTeamInvitations(ctx context.Context, arg ListTeamInvitationsParams) ([]ListTeamInvitationsRow, error) { + rows, err := q.db.Query(ctx, ListTeamInvitations, arg.Status, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTeamInvitationsRow + for rows.Next() { + var i ListTeamInvitationsRow + if err := rows.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + &i.FirstName, + &i.LastName, + &i.TeamRole, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RevokePendingTeamInvitationsForMember = `-- name: RevokePendingTeamInvitationsForMember :exec +UPDATE team_invitations +SET status = 'revoked', + updated_at = CURRENT_TIMESTAMP +WHERE team_member_id = $1 + AND status = 'pending' +` + +func (q *Queries) RevokePendingTeamInvitationsForMember(ctx context.Context, teamMemberID int64) error { + _, err := q.db.Exec(ctx, RevokePendingTeamInvitationsForMember, teamMemberID) + return err +} + +const RevokeTeamInvitation = `-- name: RevokeTeamInvitation :one +UPDATE team_invitations +SET status = 'revoked', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status = 'pending' +RETURNING id, team_member_id, token, status, expires_at, invited_by, accepted_at, created_at, updated_at +` + +func (q *Queries) RevokeTeamInvitation(ctx context.Context, id int64) (TeamInvitation, error) { + row := q.db.QueryRow(ctx, RevokeTeamInvitation, id) + var i TeamInvitation + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.Status, + &i.ExpiresAt, + &i.InvitedBy, + &i.AcceptedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/config/config.go b/internal/config/config.go index 4b24a08..74feaba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -135,6 +135,8 @@ type Config struct { TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"` ResendApiKey string ResendSenderEmail string + TeamInviteBaseURL string + TeamInviteExpiry time.Duration RedisAddr string KafkaBrokers []string FCMServiceAccountKey string @@ -476,6 +478,15 @@ func (c *Config) loadEnv() error { } c.ResendSenderEmail = resendSenderEmail + c.TeamInviteBaseURL = strings.TrimSpace(os.Getenv("TEAM_INVITE_BASE_URL")) + inviteExpiryHours := 168 + if raw := strings.TrimSpace(os.Getenv("TEAM_INVITE_EXPIRY_HOURS")); raw != "" { + if h, err := strconv.Atoi(raw); err == nil && h > 0 { + inviteExpiryHours = h + } + } + c.TeamInviteExpiry = time.Duration(inviteExpiryHours) * time.Hour + c.FCMServiceAccountKey = strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY")) if fp := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY_FILE")); fp != "" { raw, err := os.ReadFile(fp) diff --git a/internal/domain/activity_log.go b/internal/domain/activity_log.go index 5671204..3762a7c 100644 --- a/internal/domain/activity_log.go +++ b/internal/domain/activity_log.go @@ -25,6 +25,7 @@ const ( ActionUserDeleted ActivityAction = "USER_DELETED" ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED" ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED" + ActionTeamMemberInvited ActivityAction = "TEAM_MEMBER_INVITED" ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED" ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED" ActionCategoryCreated ActivityAction = "CATEGORY_CREATED" diff --git a/internal/domain/team_invitation.go b/internal/domain/team_invitation.go new file mode 100644 index 0000000..cdab399 --- /dev/null +++ b/internal/domain/team_invitation.go @@ -0,0 +1,79 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrTeamInvitationNotFound = errors.New("team invitation not found") + ErrTeamInvitationExpired = errors.New("team invitation has expired") + ErrTeamInvitationAlreadyUsed = errors.New("team invitation has already been accepted") + ErrTeamInvitationRevoked = errors.New("team invitation has been revoked") + ErrTeamMemberPendingInvitation = errors.New("team member must accept their invitation before signing in") + ErrTeamInviteBaseURLNotConfigured = errors.New("team invite base URL is not configured") +) + +type TeamInvitationStatus string + +const ( + TeamInvitationStatusPending TeamInvitationStatus = "pending" + TeamInvitationStatusAccepted TeamInvitationStatus = "accepted" + TeamInvitationStatusExpired TeamInvitationStatus = "expired" + TeamInvitationStatusRevoked TeamInvitationStatus = "revoked" +) + +type TeamInvitation struct { + ID int64 + TeamMemberID int64 + Token string + Status TeamInvitationStatus + ExpiresAt time.Time + InvitedBy *int64 + AcceptedAt *time.Time + CreatedAt time.Time + UpdatedAt *time.Time +} + +type TeamInvitationWithMember struct { + TeamInvitation + Email string + FirstName string + LastName string + TeamRole TeamRole +} + +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"` +} + +type AcceptTeamInvitationReq struct { + Token string `json:"token" validate:"required"` + Password string `json:"password" validate:"required,min=8"` +} + +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"` +} + +type InviteTeamMemberRes struct { + InvitationID int64 `json:"invitation_id"` + TeamMemberID int64 `json:"team_member_id"` + Email string `json:"email"` + ExpiresAt string `json:"expires_at"` +} diff --git a/internal/pkgs/helpers/helpers.go b/internal/pkgs/helpers/helpers.go index cb4a4ec..8bfc338 100644 --- a/internal/pkgs/helpers/helpers.go +++ b/internal/pkgs/helpers/helpers.go @@ -2,6 +2,7 @@ package helpers import ( random "crypto/rand" + "encoding/hex" "fmt" "strings" @@ -14,6 +15,14 @@ func GenerateID() string { return uuid.New().String() } +func GenerateInviteToken() (string, error) { + b := make([]byte, 32) + if _, err := random.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + func GenerateOTP() string { num := 100000 + rand.UintN(899999) return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999] diff --git a/internal/ports/team.go b/internal/ports/team.go index ab63845..9a20085 100644 --- a/internal/ports/team.go +++ b/internal/ports/team.go @@ -38,4 +38,14 @@ type TeamStore interface { RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) + + CreateTeamInvitation(ctx context.Context, invitation domain.TeamInvitation) (domain.TeamInvitation, error) + GetTeamInvitationByToken(ctx context.Context, token string) (domain.TeamInvitation, error) + GetTeamInvitationByID(ctx context.Context, id int64) (domain.TeamInvitation, error) + GetPendingTeamInvitationByMemberID(ctx context.Context, memberID int64) (domain.TeamInvitation, error) + RevokePendingTeamInvitationsForMember(ctx context.Context, memberID int64) error + AcceptTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) + RevokeTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) + ExpireTeamInvitation(ctx context.Context, invitationID int64) error + ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error) } diff --git a/internal/repository/team_invitations.go b/internal/repository/team_invitations.go new file mode 100644 index 0000000..6ffab70 --- /dev/null +++ b/internal/repository/team_invitations.go @@ -0,0 +1,152 @@ +package repository + +import ( + "context" + "errors" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func mapDBTeamInvitation(row dbgen.TeamInvitation) domain.TeamInvitation { + inv := domain.TeamInvitation{ + ID: row.ID, + TeamMemberID: row.TeamMemberID, + Token: row.Token, + Status: domain.TeamInvitationStatus(row.Status), + ExpiresAt: row.ExpiresAt.Time, + CreatedAt: row.CreatedAt.Time, + } + if row.InvitedBy.Valid { + inv.InvitedBy = &row.InvitedBy.Int64 + } + if row.AcceptedAt.Valid { + t := row.AcceptedAt.Time + inv.AcceptedAt = &t + } + if row.UpdatedAt.Valid { + t := row.UpdatedAt.Time + inv.UpdatedAt = &t + } + return inv +} + +func (s *Store) CreateTeamInvitation(ctx context.Context, invitation domain.TeamInvitation) (domain.TeamInvitation, error) { + var invitedBy pgtype.Int8 + if invitation.InvitedBy != nil { + invitedBy = pgtype.Int8{Int64: *invitation.InvitedBy, Valid: true} + } + + row, err := s.queries.CreateTeamInvitation(ctx, dbgen.CreateTeamInvitationParams{ + TeamMemberID: invitation.TeamMemberID, + Token: invitation.Token, + ExpiresAt: pgtype.Timestamptz{Time: invitation.ExpiresAt, Valid: true}, + InvitedBy: invitedBy, + }) + if err != nil { + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) GetTeamInvitationByToken(ctx context.Context, token string) (domain.TeamInvitation, error) { + row, err := s.queries.GetTeamInvitationByToken(ctx, token) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound + } + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) GetTeamInvitationByID(ctx context.Context, id int64) (domain.TeamInvitation, error) { + row, err := s.queries.GetTeamInvitationByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound + } + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) GetPendingTeamInvitationByMemberID(ctx context.Context, memberID int64) (domain.TeamInvitation, error) { + row, err := s.queries.GetPendingTeamInvitationByMemberID(ctx, memberID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound + } + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) RevokePendingTeamInvitationsForMember(ctx context.Context, memberID int64) error { + return s.queries.RevokePendingTeamInvitationsForMember(ctx, memberID) +} + +func (s *Store) AcceptTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) { + row, err := s.queries.AcceptTeamInvitation(ctx, invitationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound + } + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) RevokeTeamInvitation(ctx context.Context, invitationID int64) (domain.TeamInvitation, error) { + row, err := s.queries.RevokeTeamInvitation(ctx, invitationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamInvitation{}, domain.ErrTeamInvitationNotFound + } + return domain.TeamInvitation{}, err + } + return mapDBTeamInvitation(row), nil +} + +func (s *Store) ExpireTeamInvitation(ctx context.Context, invitationID int64) error { + return s.queries.ExpireTeamInvitation(ctx, invitationID) +} + +func (s *Store) ListTeamInvitations(ctx context.Context, status *string, limit, offset int32) ([]domain.TeamInvitationWithMember, int64, error) { + rows, err := s.queries.ListTeamInvitations(ctx, dbgen.ListTeamInvitationsParams{ + Status: toPgText(status), + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + out := make([]domain.TeamInvitationWithMember, 0, len(rows)) + var total int64 + for _, row := range rows { + inv := mapDBTeamInvitation(dbgen.TeamInvitation{ + ID: row.ID, + TeamMemberID: row.TeamMemberID, + Token: row.Token, + Status: row.Status, + ExpiresAt: row.ExpiresAt, + InvitedBy: row.InvitedBy, + AcceptedAt: row.AcceptedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + out = append(out, domain.TeamInvitationWithMember{ + TeamInvitation: inv, + Email: row.Email, + FirstName: row.FirstName, + LastName: row.LastName, + TeamRole: domain.TeamRole(row.TeamRole), + }) + total = row.TotalCount + } + return out, total, nil +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 9ad6350..446e1ea 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -276,6 +276,10 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "team.members.update_status", Name: "Update Team Member Status", Description: "Update team member status", GroupName: "Team"}, {Key: "team.members.delete", Name: "Delete Team Member", Description: "Delete a team member", GroupName: "Team"}, {Key: "team.members.change_password", Name: "Change Team Password", Description: "Change team member password", GroupName: "Team"}, + {Key: "team.members.invite", Name: "Invite Team Member", Description: "Send email invitation for a new team member", GroupName: "Team"}, + {Key: "team.invitations.list", Name: "List Team Invitations", Description: "List team member invitations", GroupName: "Team"}, + {Key: "team.invitations.resend", Name: "Resend Team Invitation", Description: "Resend a pending team invitation email", GroupName: "Team"}, + {Key: "team.invitations.revoke", Name: "Revoke Team Invitation", Description: "Revoke a pending team invitation", GroupName: "Team"}, // Sub-course Prerequisites {Key: "subcourse_prerequisites.add", Name: "Add Prerequisite", Description: "Add sub-course prerequisite", GroupName: "Sub-course Prerequisites"}, @@ -467,8 +471,9 @@ var DefaultRolePermissions = map[string][]string{ "vimeo.uploads.pull", "vimeo.uploads.tus", // Team (full access) - "team.profile.get_mine", "team.stats", "team.members.list", "team.members.create", + "team.profile.get_mine", "team.stats", "team.members.list", "team.members.create", "team.members.invite", "team.members.get", "team.members.update", "team.members.update_status", "team.members.delete", "team.members.change_password", + "team.invitations.list", "team.invitations.resend", "team.invitations.revoke", // Sub-course Prerequisites "subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove", diff --git a/internal/services/team/invite.go b/internal/services/team/invite.go new file mode 100644 index 0000000..37f66c3 --- /dev/null +++ b/internal/services/team/invite.go @@ -0,0 +1,327 @@ +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 +} diff --git a/internal/services/team/service.go b/internal/services/team/service.go index 76031b4..422a7e8 100644 --- a/internal/services/team/service.go +++ b/internal/services/team/service.go @@ -2,19 +2,40 @@ package team import ( "Yimaru-Backend/internal/ports" + emailtemplates "Yimaru-Backend/internal/services/emailtemplates" + "Yimaru-Backend/internal/services/messenger" + "time" ) type Service struct { - teamStore ports.TeamStore + teamStore ports.TeamStore refreshExpirySec int + emailTemplateSvc *emailtemplates.Service + messengerSvc *messenger.Service + inviteBaseURL string + inviteExpiry time.Duration } -func NewService(teamStore ports.TeamStore, refreshExpirySeconds int) *Service { +func NewService( + teamStore ports.TeamStore, + refreshExpirySeconds int, + emailTemplateSvc *emailtemplates.Service, + messengerSvc *messenger.Service, + inviteBaseURL string, + inviteExpiry time.Duration, +) *Service { if refreshExpirySeconds <= 0 { refreshExpirySeconds = 7 * 24 * 3600 } + if inviteExpiry <= 0 { + inviteExpiry = 7 * 24 * time.Hour + } return &Service{ teamStore: teamStore, refreshExpirySec: refreshExpirySeconds, + emailTemplateSvc: emailTemplateSvc, + messengerSvc: messengerSvc, + inviteBaseURL: inviteBaseURL, + inviteExpiry: inviteExpiry, } } diff --git a/internal/services/team/team.go b/internal/services/team/team.go index b934a6f..979735c 100644 --- a/internal/services/team/team.go +++ b/internal/services/team/team.go @@ -133,6 +133,13 @@ func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (dom return domain.TeamMember{}, err } + if member.Status != domain.TeamMemberStatusActive { + return domain.TeamMember{}, domain.ErrInvalidTeamMemberStatus + } + if !member.EmailVerified { + return domain.TeamMember{}, domain.ErrTeamMemberPendingInvitation + } + if err := bcrypt.CompareHashAndPassword(member.Password, []byte(req.Password)); err != nil { return domain.TeamMember{}, err } diff --git a/internal/web_server/handlers/team_handler.go b/internal/web_server/handlers/team_handler.go index f1bbf6f..413d207 100644 --- a/internal/web_server/handlers/team_handler.go +++ b/internal/web_server/handlers/team_handler.go @@ -109,6 +109,11 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error { Message: "Failed to login", Error: "Account is not active", }) + case errors.Is(err, domain.ErrTeamMemberPendingInvitation): + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Please accept your invitation email and set your password before signing in", + }) default: h.mongoLoggerSvc.Error("Team member login failed", zap.Int("status_code", fiber.StatusInternalServerError), diff --git a/internal/web_server/handlers/team_invitation_handler.go b/internal/web_server/handlers/team_invitation_handler.go new file mode 100644 index 0000000..6a5d921 --- /dev/null +++ b/internal/web_server/handlers/team_invitation_handler.go @@ -0,0 +1,296 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +type teamInvitationListItem struct { + ID int64 `json:"id"` + TeamMemberID int64 `json:"team_member_id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TeamRole string `json:"team_role"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +type listTeamInvitationsRes struct { + Invitations []teamInvitationListItem `json:"invitations"` + TotalCount int64 `json:"total_count"` +} + +func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem { + return teamInvitationListItem{ + ID: row.ID, + TeamMemberID: row.TeamMemberID, + Email: row.Email, + FirstName: row.FirstName, + LastName: row.LastName, + TeamRole: string(row.TeamRole), + Status: string(row.Status), + ExpiresAt: row.ExpiresAt.Format(time.RFC3339), + CreatedAt: row.CreatedAt.Format(time.RFC3339), + } +} + +// InviteTeamMember godoc +// @Summary Invite a team member by email +// @Description Creates a pending team member and sends an invitation email with a setup link +// @Tags team +// @Accept json +// @Produce json +// @Param body body domain.InviteTeamMemberReq true "Invite payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/invite [post] +func (h *Handler) InviteTeamMember(c *fiber.Ctx) error { + var req domain.InviteTeamMemberReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + + inviterID, _ := c.Locals("user_id").(int64) + var invitedBy *int64 + if inviterID > 0 { + invitedBy = &inviterID + } + + res, err := h.teamSvc.InviteTeamMember(c.Context(), req, invitedBy) + if err != nil { + return h.teamInvitationError(c, err, "Failed to send team invitation") + } + + actorRole := "" + if role, ok := c.Locals("role").(domain.Role); ok { + actorRole = string(role) + } + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"email": res.Email, "team_member_id": res.TeamMemberID}) + go h.activityLogSvc.RecordAction(context.Background(), invitedBy, &actorRole, domain.ActionTeamMemberInvited, domain.ResourceTeamMember, &res.TeamMemberID, "Invited team member: "+res.Email, meta, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Team invitation sent successfully", + Data: res, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ResendTeamInvitation godoc +// @Summary Resend team invitation +// @Description Revokes the current pending invite and sends a new invitation email +// @Tags team +// @Produce json +// @Param id path int true "Team member ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id}/resend-invite [post] +func (h *Handler) ResendTeamInvitation(c *fiber.Ctx) error { + memberID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid team member ID", + }) + } + + inviterID, _ := c.Locals("user_id").(int64) + var invitedBy *int64 + if inviterID > 0 { + invitedBy = &inviterID + } + + res, err := h.teamSvc.ResendTeamInvitation(c.Context(), memberID, invitedBy) + if err != nil { + return h.teamInvitationError(c, err, "Failed to resend team invitation") + } + + return c.JSON(domain.Response{ + Message: "Team invitation resent successfully", + Data: res, + Success: true, + }) +} + +// ListTeamInvitations godoc +// @Summary List team invitations +// @Description Lists team member invitations with optional status filter +// @Tags team +// @Produce json +// @Param status query string false "pending, accepted, expired, or revoked" +// @Param limit query int false "Limit (default 20)" +// @Param offset query int false "Offset (default 0)" +// @Success 200 {object} domain.Response +// @Router /api/v1/team/invitations [get] +func (h *Handler) ListTeamInvitations(c *fiber.Ctx) error { + status := strings.TrimSpace(c.Query("status")) + var statusPtr *string + if status != "" { + statusPtr = &status + } + + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + + rows, total, err := h.teamSvc.ListTeamInvitations(c.Context(), statusPtr, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to list team invitations", + Error: err.Error(), + }) + } + + out := make([]teamInvitationListItem, 0, len(rows)) + for _, row := range rows { + out = append(out, mapTeamInvitationListItem(row)) + } + + return c.JSON(domain.Response{ + Message: "Team invitations retrieved successfully", + Data: listTeamInvitationsRes{ + Invitations: out, + TotalCount: total, + }, + Success: true, + }) +} + +// RevokeTeamInvitation godoc +// @Summary Revoke a pending team invitation +// @Description Revokes the invitation and removes the pending team member if not yet accepted +// @Tags team +// @Produce json +// @Param id path int true "Invitation ID" +// @Success 200 {object} domain.Response +// @Router /api/v1/team/invitations/{id}/revoke [post] +func (h *Handler) RevokeTeamInvitation(c *fiber.Ctx) error { + invitationID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || invitationID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid invitation ID", + }) + } + + if err := h.teamSvc.RevokeTeamInvitation(c.Context(), invitationID); err != nil { + return h.teamInvitationError(c, err, "Failed to revoke team invitation") + } + + return c.JSON(domain.Response{ + Message: "Team invitation revoked successfully", + Data: fiber.Map{"id": invitationID}, + Success: true, + }) +} + +// VerifyTeamInvitation godoc +// @Summary Verify team invitation token +// @Description Public endpoint used by the admin panel accept-invite page +// @Tags team +// @Produce json +// @Param token query string true "Invitation token" +// @Success 200 {object} domain.Response +// @Router /api/v1/team/invitations/verify [get] +func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invitation token is required", + }) + } + + res, err := h.teamSvc.VerifyTeamInvitation(c.Context(), token) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify invitation", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Invitation verification completed", + Data: res, + Success: true, + }) +} + +// AcceptTeamInvitation godoc +// @Summary Accept team invitation and set password +// @Description Public endpoint to activate a team member account after following the invite link +// @Tags team +// @Accept json +// @Produce json +// @Param body body domain.AcceptTeamInvitationReq true "Accept invitation payload" +// @Success 200 {object} domain.Response +// @Router /api/v1/team/invitations/accept [post] +func (h *Handler) AcceptTeamInvitation(c *fiber.Ctx) error { + var req domain.AcceptTeamInvitationReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + + member, err := h.teamSvc.AcceptTeamInvitation(c.Context(), req) + if err != nil { + return h.teamInvitationError(c, err, "Failed to accept team invitation") + } + + return c.JSON(domain.Response{ + Message: "Team account activated successfully. You can now sign in.", + Data: toTeamMemberResponse(&member), + Success: true, + }) +} + +func (h *Handler) teamInvitationError(c *fiber.Ctx, err error, message string) error { + switch { + case errors.Is(err, domain.ErrTeamMemberEmailExists): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Email already exists"}) + case errors.Is(err, domain.ErrInvalidTeamRole), errors.Is(err, domain.ErrInvalidEmploymentType): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()}) + case errors.Is(err, domain.ErrTeamInviteBaseURLNotConfigured): + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: "TEAM_INVITE_BASE_URL is not configured"}) + case errors.Is(err, domain.ErrTeamInvitationNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Invitation not found"}) + case errors.Is(err, domain.ErrTeamInvitationExpired): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has expired"}) + case errors.Is(err, domain.ErrTeamInvitationAlreadyUsed): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has already been accepted"}) + case errors.Is(err, domain.ErrTeamInvitationRevoked): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has been revoked"}) + case errors.Is(err, domain.ErrTeamMemberNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Team member not found"}) + default: + h.mongoLoggerSvc.Error(message, zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()}) + } +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 8ef3208..d424379 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -396,15 +396,21 @@ func (a *App) initAppRoutes() { teamGroup := groupV1.Group("/team") teamGroup.Post("/login", h.TeamMemberLogin) teamGroup.Post("/refresh", h.TeamMemberRefresh) + teamGroup.Get("/invitations/verify", h.VerifyTeamInvitation) + teamGroup.Post("/invitations/accept", h.AcceptTeamInvitation) teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile) teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats) teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers) + teamGroup.Post("/members/invite", a.authMiddleware, a.RequirePermission("team.members.invite"), h.InviteTeamMember) teamGroup.Post("/members", a.authMiddleware, a.RequirePermission("team.members.create"), h.CreateTeamMember) + teamGroup.Post("/members/:id/resend-invite", a.authMiddleware, a.RequirePermission("team.invitations.resend"), h.ResendTeamInvitation) teamGroup.Get("/members/:id", a.authMiddleware, a.RequirePermission("team.members.get"), h.GetTeamMember) teamGroup.Put("/members/:id", a.authMiddleware, a.RequirePermission("team.members.update"), h.UpdateTeamMember) teamGroup.Patch("/members/:id/status", a.authMiddleware, a.RequirePermission("team.members.update_status"), h.UpdateTeamMemberStatus) teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) + teamGroup.Get("/invitations", a.authMiddleware, a.RequirePermission("team.invitations.list"), h.ListTeamInvitations) + teamGroup.Post("/invitations/:id/revoke", a.authMiddleware, a.RequirePermission("team.invitations.revoke"), h.RevokeTeamInvitation) // Ratings groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)