Compare commits

...

2 Commits

Author SHA1 Message Date
79851d31b3 email invitation 2026-05-22 05:17:19 -07:00
31bd1e3814 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 <cursoragent@cursor.com>
2026-05-22 03:43:00 -07:00
20 changed files with 1349 additions and 547 deletions

View File

@ -437,7 +437,14 @@ func main() {
) )
// Team management service // 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) // santimpayClient := santimpay.NewSantimPayClient(cfg)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS team_invitations;

View File

@ -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);

View File

@ -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;

View File

@ -43,6 +43,20 @@ type Device struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` 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 { type ExamPrepCatalogCourse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -445,6 +459,18 @@ type SubscriptionPlan struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` 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 { type TeamMember struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`

View File

@ -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
}

View File

@ -135,6 +135,8 @@ type Config struct {
TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"` TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"`
ResendApiKey string ResendApiKey string
ResendSenderEmail string ResendSenderEmail string
TeamInviteBaseURL string
TeamInviteExpiry time.Duration
RedisAddr string RedisAddr string
KafkaBrokers []string KafkaBrokers []string
FCMServiceAccountKey string FCMServiceAccountKey string
@ -476,6 +478,15 @@ func (c *Config) loadEnv() error {
} }
c.ResendSenderEmail = resendSenderEmail 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")) c.FCMServiceAccountKey = strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY"))
if fp := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY_FILE")); fp != "" { if fp := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_KEY_FILE")); fp != "" {
raw, err := os.ReadFile(fp) raw, err := os.ReadFile(fp)

View File

@ -25,6 +25,7 @@ const (
ActionUserDeleted ActivityAction = "USER_DELETED" ActionUserDeleted ActivityAction = "USER_DELETED"
ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED" ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED"
ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED" ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED"
ActionTeamMemberInvited ActivityAction = "TEAM_MEMBER_INVITED"
ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED" ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED"
ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED" ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED"
ActionCategoryCreated ActivityAction = "CATEGORY_CREATED" ActionCategoryCreated ActivityAction = "CATEGORY_CREATED"

View File

@ -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"`
}

View File

@ -2,6 +2,7 @@ package helpers
import ( import (
random "crypto/rand" random "crypto/rand"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
@ -14,6 +15,14 @@ func GenerateID() string {
return uuid.New().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 { func GenerateOTP() string {
num := 100000 + rand.UintN(899999) num := 100000 + rand.UintN(899999)
return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999] return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999]

View File

@ -38,4 +38,14 @@ type TeamStore interface {
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
BulkReactivateTeamMembersByRole(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)
} }

View File

@ -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
}

View File

@ -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.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.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.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 // Sub-course Prerequisites
{Key: "subcourse_prerequisites.add", Name: "Add Prerequisite", Description: "Add sub-course prerequisite", GroupName: "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", "vimeo.uploads.pull", "vimeo.uploads.tus",
// Team (full access) // 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.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 // Sub-course Prerequisites
"subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove", "subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove",

View File

@ -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
}

View File

@ -2,19 +2,40 @@ package team
import ( import (
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/messenger"
"time"
) )
type Service struct { type Service struct {
teamStore ports.TeamStore teamStore ports.TeamStore
refreshExpirySec int 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 { if refreshExpirySeconds <= 0 {
refreshExpirySeconds = 7 * 24 * 3600 refreshExpirySeconds = 7 * 24 * 3600
} }
if inviteExpiry <= 0 {
inviteExpiry = 7 * 24 * time.Hour
}
return &Service{ return &Service{
teamStore: teamStore, teamStore: teamStore,
refreshExpirySec: refreshExpirySeconds, refreshExpirySec: refreshExpirySeconds,
emailTemplateSvc: emailTemplateSvc,
messengerSvc: messengerSvc,
inviteBaseURL: inviteBaseURL,
inviteExpiry: inviteExpiry,
} }
} }

View File

@ -133,6 +133,13 @@ func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (dom
return domain.TeamMember{}, err 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 { if err := bcrypt.CompareHashAndPassword(member.Password, []byte(req.Password)); err != nil {
return domain.TeamMember{}, err return domain.TeamMember{}, err
} }

View File

@ -109,6 +109,11 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
Message: "Failed to login", Message: "Failed to login",
Error: "Account is not active", 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: default:
h.mongoLoggerSvc.Error("Team member login failed", h.mongoLoggerSvc.Error("Team member login failed",
zap.Int("status_code", fiber.StatusInternalServerError), zap.Int("status_code", fiber.StatusInternalServerError),

View File

@ -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()})
}
}

View File

@ -396,15 +396,21 @@ func (a *App) initAppRoutes() {
teamGroup := groupV1.Group("/team") teamGroup := groupV1.Group("/team")
teamGroup.Post("/login", h.TeamMemberLogin) teamGroup.Post("/login", h.TeamMemberLogin)
teamGroup.Post("/refresh", h.TeamMemberRefresh) 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("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile)
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats) teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers) 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", 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.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.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.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.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.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 // Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)

View File

@ -1,543 +0,0 @@
{
"info": {
"_postman_id": "c4a8f2e1-9b3d-4c7a-a1e6-chapa-payments-01",
"name": "Chapa Subscription Payments",
"description": "Postman collection for Yimaru LMS Chapa subscription payment flow.\n\n## Setup\n1. Set `base_url` (default `http://localhost:8080`).\n2. Set `learner_email`, `learner_password`, and `learner_phone`.\n3. Set `chapa_webhook_secret` (same as `CHAPA_WEBHOOK_SECRET` in `.env`).\n4. Run **Customer Login** to populate `access_token`.\n5. Run **List Subscription Plans** to populate `plan_id`.\n6. Run **Subscribe with Payment** — open `payment_url` in a browser and complete Chapa test checkout.\n7. Run **Verify Payment** (uses `tx_ref` saved as `session_id`).\n\n## Notes\n- `session_id` in verify/cancel paths is Chapa `tx_ref` (UUID returned at checkout).\n- Webhook request includes a pre-request script that signs the body with HMAC-SHA256.\n- See `docs/CHAPA_INTEGRATION.md` for dashboard webhook URL configuration.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080"
},
{
"key": "access_token",
"value": ""
},
{
"key": "learner_email",
"value": "learner@example.com"
},
{
"key": "learner_password",
"value": "your-password"
},
{
"key": "learner_phone",
"value": "0912345678"
},
{
"key": "plan_id",
"value": "1"
},
{
"key": "payment_id",
"value": ""
},
{
"key": "tx_ref",
"value": ""
},
{
"key": "payment_url",
"value": ""
},
{
"key": "chapa_webhook_secret",
"value": ""
},
{
"key": "chapa_ref_id",
"value": "APqDvYw1okk2"
}
],
"item": [
{
"name": "00 - Auth",
"item": [
{
"name": "Customer Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"if (body.data && body.data.access_token) {",
" pm.collectionVariables.set('access_token', body.data.access_token);",
" pm.test('Access token saved', function () {",
" pm.expect(body.data.access_token).to.be.a('string').and.not.empty;",
" });",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{learner_email}}\",\n \"password\": \"{{learner_password}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/customer-login",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "customer-login"]
},
"description": "Authenticates a learner and saves `access_token` for subsequent requests."
},
"response": []
}
]
},
{
"name": "01 - Subscription Plans",
"item": [
{
"name": "List Subscription Plans",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"if (Array.isArray(body.data) && body.data.length > 0) {",
" pm.collectionVariables.set('plan_id', String(body.data[0].id));",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/subscription-plans?active_only=true",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscription-plans"],
"query": [
{
"key": "active_only",
"value": "true"
}
]
},
"description": "Public list of active plans. Saves first plan `id` to `plan_id`."
},
"response": []
},
{
"name": "Get Subscription Plan by ID",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/subscription-plans/{{plan_id}}",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscription-plans", "{{plan_id}}"]
}
},
"response": []
}
]
},
{
"name": "02 - Chapa Payment Flow",
"item": [
{
"name": "Subscribe with Payment (Checkout)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"if (body.data) {",
" if (body.data.payment_id) {",
" pm.collectionVariables.set('payment_id', String(body.data.payment_id));",
" }",
" if (body.data.session_id) {",
" pm.collectionVariables.set('tx_ref', body.data.session_id);",
" }",
" if (body.data.payment_url) {",
" pm.collectionVariables.set('payment_url', body.data.payment_url);",
" }",
" pm.test('Payment URL returned', function () {",
" pm.expect(body.data.payment_url).to.be.a('string').and.not.empty;",
" });",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/subscriptions/checkout",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscriptions", "checkout"]
},
"description": "Primary learner endpoint. Returns Chapa `payment_url`. Open it in a browser to complete payment. `session_id` in the response is the Chapa `tx_ref`."
},
"response": []
},
{
"name": "Initiate Subscription Payment",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const body = pm.response.json();",
"if (body.data) {",
" if (body.data.payment_id) pm.collectionVariables.set('payment_id', String(body.data.payment_id));",
" if (body.data.session_id) pm.collectionVariables.set('tx_ref', body.data.session_id);",
" if (body.data.payment_url) pm.collectionVariables.set('payment_url', body.data.payment_url);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/payments/subscribe",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "subscribe"]
},
"description": "Alias of checkout — same Chapa initialize flow."
},
"response": []
},
{
"name": "Verify Payment",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/verify/{{tx_ref}}",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "verify", "{{tx_ref}}"]
},
"description": "Verifies payment with Chapa using `tx_ref` (path param named `session_id` in the API). Run after completing checkout."
},
"response": []
},
{
"name": "Get My Payments",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments?limit=20&offset=0",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments"],
"query": [
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "Get Payment by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/{{payment_id}}",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "{{payment_id}}"]
}
},
"response": []
},
{
"name": "Cancel Pending Payment",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/{{payment_id}}/cancel",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "{{payment_id}}", "cancel"]
},
"description": "Only works while payment status is PENDING."
},
"response": []
},
{
"name": "Get Chapa Payment Methods",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/methods",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "methods"]
}
},
"response": []
}
]
},
{
"name": "03 - Subscription Status",
"item": [
{
"name": "Get My Subscription",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/subscriptions/me",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscriptions", "me"]
},
"description": "Returns active subscription after successful payment."
},
"response": []
},
{
"name": "Check Subscription Status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/subscriptions/status",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscriptions", "status"]
}
},
"response": []
},
{
"name": "Get Subscription History",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/subscriptions/history",
"host": ["{{base_url}}"],
"path": ["api", "v1", "subscriptions", "history"]
}
},
"response": []
}
]
},
{
"name": "04 - Chapa Webhooks (no auth)",
"item": [
{
"name": "Chapa Webhook (charge.success)",
"event": [
{
"listen": "prerequest",
"script": {
"exec": [
"const secret = pm.collectionVariables.get('chapa_webhook_secret') || '';",
"const body = pm.request.body.raw || '';",
"if (!secret) {",
" console.warn('Set chapa_webhook_secret collection variable to sign the webhook');",
"}",
"const signature = CryptoJS.HmacSHA256(body, secret).toString(CryptoJS.enc.Hex);",
"pm.request.headers.upsert({ key: 'x-chapa-signature', value: signature });"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"event\": \"charge.success\",\n \"type\": \"API\",\n \"tx_ref\": \"{{tx_ref}}\",\n \"reference\": \"{{chapa_ref_id}}\",\n \"status\": \"success\",\n \"amount\": \"500.00\",\n \"currency\": \"ETB\",\n \"payment_method\": \"telebirr\",\n \"mode\": \"test\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/payments/webhook",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "webhook"]
},
"description": "Simulates Chapa webhook. Requires valid `tx_ref` from a real initialize call. Backend re-verifies with Chapa API before activating subscription. Set `chapa_webhook_secret` to match dashboard / `.env`."
},
"response": []
},
{
"name": "Chapa Callback (GET)",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/chapa/callback?trx_ref={{tx_ref}}&ref_id={{chapa_ref_id}}&status=success",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "chapa", "callback"],
"query": [
{
"key": "trx_ref",
"value": "{{tx_ref}}"
},
{
"key": "ref_id",
"value": "{{chapa_ref_id}}"
},
{
"key": "status",
"value": "success"
}
]
},
"description": "Simulates Chapa redirect to `CHAPA_CALLBACK_URL`. Uses same verify flow as webhook."
},
"response": []
}
]
},
{
"name": "05 - ArifPay Direct (legacy)",
"description": "OTP/direct payment flows still use ArifPay. Subscription checkout uses Chapa (folder 02).",
"item": [
{
"name": "Get Direct Payment Methods",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/payments/direct/methods",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "direct", "methods"]
}
},
"response": []
},
{
"name": "Initiate Direct Payment",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\",\n \"payment_method\": \"TELEBIRR\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/payments/direct",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "direct"]
}
},
"response": []
},
{
"name": "Verify Direct Payment OTP",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"session_id\": \"{{tx_ref}}\",\n \"otp\": \"123456\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/payments/direct/verify-otp",
"host": ["{{base_url}}"],
"path": ["api", "v1", "payments", "direct", "verify-otp"]
}
},
"response": []
}
]
}
]
}