Compare commits
2 Commits
868e5ba001
...
79851d31b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 79851d31b3 | |||
| 31bd1e3814 |
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
1
db/migrations/000068_team_invitations.down.sql
Normal file
1
db/migrations/000068_team_invitations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS team_invitations;
|
||||
18
db/migrations/000068_team_invitations.up.sql
Normal file
18
db/migrations/000068_team_invitations.up.sql
Normal 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);
|
||||
80
db/query/team_invitations.sql
Normal file
80
db/query/team_invitations.sql
Normal 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;
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
284
gen/db/team_invitations.sql.go
Normal file
284
gen/db/team_invitations.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
internal/domain/team_invitation.go
Normal file
79
internal/domain/team_invitation.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
152
internal/repository/team_invitations.go
Normal file
152
internal/repository/team_invitations.go
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
327
internal/services/team/invite.go
Normal file
327
internal/services/team/invite.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
296
internal/web_server/handlers/team_invitation_handler.go
Normal file
296
internal/web_server/handlers/team_invitation_handler.go
Normal 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()})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user