- Updated invitation creation logic to support email invitations and customizable expiry durations. - Enhanced tests for invitation creation, including scenarios for registered and unregistered users, and handling of missing email requirements. - Refactored API endpoints to use consistent parameter naming for team IDs. - Improved error handling and logging for invitation-related operations, ensuring clarity in failure cases. - Added support for sending invitation emails through the messenger service, with appropriate templates and settings.
1126 lines
35 KiB
Go
1126 lines
35 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yaoapp/gou/model"
|
|
"github.com/yaoapp/gou/process"
|
|
"github.com/yaoapp/kun/exception"
|
|
"github.com/yaoapp/kun/log"
|
|
"github.com/yaoapp/kun/maps"
|
|
"github.com/yaoapp/yao/messenger"
|
|
messengertypes "github.com/yaoapp/yao/messenger/types"
|
|
"github.com/yaoapp/yao/openapi/oauth"
|
|
"github.com/yaoapp/yao/openapi/response"
|
|
)
|
|
|
|
// Team Invitation Management Handlers
|
|
|
|
// GinInvitationList handles GET /teams/:team_id/invitations - Get team invitations
|
|
func GinInvitationList(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
if teamID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse pagination parameters
|
|
page := 1
|
|
pagesize := 20
|
|
|
|
if p := c.Query("page"); p != "" {
|
|
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
|
page = parsed
|
|
}
|
|
}
|
|
|
|
if ps := c.Query("pagesize"); ps != "" {
|
|
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
|
|
pagesize = parsed
|
|
}
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := invitationList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status"))
|
|
if err != nil {
|
|
log.Error("Failed to get team invitations: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to retrieve team invitations",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Return the paginated result
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// GinInvitationGet handles GET /teams/:team_id/invitations/:invitation_id - Get invitation details
|
|
func GinInvitationGet(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Call business logic
|
|
invitationData, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to get invitation details: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to retrieve invitation details",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Convert to response format
|
|
invitation := mapToInvitationDetailResponse(invitationData)
|
|
c.JSON(http.StatusOK, invitation)
|
|
}
|
|
|
|
// GinInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation
|
|
func GinInvitationCreate(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
if teamID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req CreateInvitationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid request body: " + err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Prepare invitation data
|
|
invitationData := maps.MapStrAny{
|
|
"user_id": req.UserID,
|
|
"email": req.Email,
|
|
"member_type": req.MemberType,
|
|
"role_id": req.RoleID,
|
|
"message": req.Message,
|
|
"expiry": req.Expiry,
|
|
}
|
|
|
|
// Add send_email setting from top-level field
|
|
if req.SendEmail != nil {
|
|
if invitationData["settings"] == nil {
|
|
invitationData["settings"] = make(map[string]interface{})
|
|
}
|
|
if settings, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
settings["send_email"] = *req.SendEmail
|
|
}
|
|
}
|
|
|
|
// Add other settings if provided
|
|
if req.Settings != nil {
|
|
if invitationData["settings"] == nil {
|
|
invitationData["settings"] = req.Settings
|
|
} else {
|
|
// Merge settings
|
|
if existingSettings, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
for k, v := range req.Settings {
|
|
existingSettings[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call business logic
|
|
invitationID, err := invitationCreate(c.Request.Context(), authInfo.UserID, teamID, invitationData)
|
|
if err != nil {
|
|
log.Error("Failed to create invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already invited") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusConflict, errorResp)
|
|
} else if strings.Contains(err.Error(), "email is required") || strings.Contains(err.Error(), "is required") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to send invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Return created invitation ID
|
|
c.JSON(http.StatusCreated, gin.H{"invitation_id": invitationID})
|
|
}
|
|
|
|
// GinInvitationResend handles PUT /teams/:team_id/invitations/:invitation_id/resend - Resend invitation
|
|
func GinInvitationResend(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Call business logic
|
|
err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to resend invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else if strings.Contains(err.Error(), "already accepted") || strings.Contains(err.Error(), "invalid status") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to resend invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Invitation resent successfully"})
|
|
}
|
|
|
|
// GinInvitationDelete handles DELETE /teams/:team_id/invitations/:invitation_id - Cancel invitation
|
|
func GinInvitationDelete(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Call business logic
|
|
err := invitationDelete(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to cancel invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to cancel invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Invitation cancelled successfully"})
|
|
}
|
|
|
|
// Yao Process Handlers (for Yao application calls)
|
|
|
|
// ProcessInvitationList user.invitation.list Invitation list processor
|
|
// Args[0] string: team_id
|
|
// Args[1] map: Query parameters {"status": "pending", "page": 1, "pagesize": 20}
|
|
// Return: map: Paginated invitation list
|
|
func ProcessInvitationList(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
if teamID == "" {
|
|
exception.New("team_id is required", 400).Throw()
|
|
}
|
|
|
|
// Parse query parameters
|
|
queryMap := process.ArgsMap(1)
|
|
|
|
// Parse pagination
|
|
page := 1
|
|
pagesize := 20
|
|
|
|
if p := int(toInt64(queryMap["page"])); p > 0 {
|
|
page = p
|
|
}
|
|
|
|
if ps := int(toInt64(queryMap["pagesize"])); ps > 0 && ps <= 100 {
|
|
pagesize = ps
|
|
}
|
|
|
|
// Get status filter
|
|
status := toString(queryMap["status"])
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := invitationList(ctx, userIDStr, teamID, page, pagesize, status)
|
|
if err != nil {
|
|
exception.New("failed to list invitations: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ProcessInvitationGet user.invitation.get Invitation get processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: Invitation details
|
|
func ProcessInvitationGet(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := invitationGet(ctx, userIDStr, teamID, invitationID)
|
|
if err != nil {
|
|
exception.New("failed to get invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ProcessInvitationCreate user.invitation.create Invitation create processor
|
|
// Args[0] string: team_id
|
|
// Args[1] map: Invitation data {"user_id": "user123", "member_type": "user", "role_id": "member", "message": "...", "settings": {...}}
|
|
// Return: map: {"invitation_id": "created_invitation_id"}
|
|
func ProcessInvitationCreate(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationData := maps.MapStrAny(process.ArgsMap(1))
|
|
|
|
if teamID == "" {
|
|
exception.New("team_id is required", 400).Throw()
|
|
}
|
|
|
|
// Validate required fields
|
|
if _, ok := invitationData["user_id"]; !ok {
|
|
exception.New("user_id is required", 400).Throw()
|
|
}
|
|
if _, ok := invitationData["role_id"]; !ok {
|
|
exception.New("role_id is required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
invitationID, err := invitationCreate(ctx, userIDStr, teamID, invitationData)
|
|
if err != nil {
|
|
exception.New("failed to create invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"invitation_id": invitationID,
|
|
}
|
|
}
|
|
|
|
// ProcessInvitationResend user.invitation.resend Invitation resend processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: {"message": "success"}
|
|
func ProcessInvitationResend(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
err := invitationResend(ctx, userIDStr, teamID, invitationID)
|
|
if err != nil {
|
|
exception.New("failed to resend invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"message": "success",
|
|
}
|
|
}
|
|
|
|
// ProcessInvitationDelete user.invitation.delete Invitation delete processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: {"message": "success"}
|
|
func ProcessInvitationDelete(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
err := invitationDelete(ctx, userIDStr, teamID, invitationID)
|
|
if err != nil {
|
|
exception.New("failed to delete invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"message": "success",
|
|
}
|
|
}
|
|
|
|
// Private Business Logic Functions (internal use only)
|
|
|
|
// invitationList handles the business logic for listing team invitations
|
|
func invitationList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) {
|
|
// Check if user has access to the team (read permission: owner or member)
|
|
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Allow access if user is owner or member
|
|
if !isOwner && !isMember {
|
|
return nil, fmt.Errorf("access denied: user is not a member of this team")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Build query parameters for pending invitations
|
|
param := model.QueryParam{
|
|
Wheres: []model.QueryWhere{
|
|
{Column: "team_id", Value: teamID},
|
|
{Column: "status", Value: "pending"}, // Only show pending invitations
|
|
},
|
|
Orders: []model.QueryOrder{
|
|
{Column: "invited_at", Option: "desc"},
|
|
{Column: "created_at", Option: "desc"},
|
|
},
|
|
}
|
|
|
|
// Add additional status filter if provided
|
|
if status != "" && status != "pending" {
|
|
// Replace the default pending status filter
|
|
param.Wheres[1] = model.QueryWhere{
|
|
Column: "status",
|
|
Value: status,
|
|
}
|
|
}
|
|
|
|
// Get paginated invitations (pending members)
|
|
result, err := provider.PaginateMembers(ctx, param, page, pagesize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve invitations: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// invitationGet handles the business logic for getting a specific team invitation
|
|
func invitationGet(ctx context.Context, userID, teamID, invitationID string) (maps.MapStrAny, error) {
|
|
// Check if user has access to the team (read permission: owner or member)
|
|
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Allow access if user is owner or member
|
|
if !isOwner && !isMember {
|
|
return nil, fmt.Errorf("access denied: user is not a member of this team")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get invitation details using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if toString(invitationData["team_id"]) != teamID {
|
|
return nil, fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Only return if it's a pending invitation
|
|
if toString(invitationData["status"]) != "pending" {
|
|
return nil, fmt.Errorf("invitation not found or no longer pending")
|
|
}
|
|
|
|
return invitationData, nil
|
|
}
|
|
|
|
// invitationCreate handles the business logic for creating a team invitation
|
|
// Supports two scenarios:
|
|
// 1. Email invitation: provide email and role, send invitation link via email
|
|
// 2. Link invitation: create invitation link for display in frontend, customizable expiry
|
|
func invitationCreate(ctx context.Context, userID, teamID string, invitationData maps.MapStrAny) (string, error) {
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return "", fmt.Errorf("access denied: only team owner can send invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get team information for email template
|
|
team, err := provider.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get team information: %w", err)
|
|
}
|
|
teamName := toString(team["name"])
|
|
|
|
// Get inviter information for email template
|
|
inviter, err := provider.GetUser(ctx, userID)
|
|
if err != nil {
|
|
log.Warn("Failed to get inviter information: %v", err)
|
|
inviter = maps.MapStrAny{"name": "Team Admin"}
|
|
}
|
|
inviterName := toString(inviter["name"])
|
|
if inviterName == "" {
|
|
inviterName = toString(inviter["email"])
|
|
}
|
|
|
|
// Check if user is already a member or has pending invitation (if user_id is provided)
|
|
var inviteeUserID string
|
|
var inviteeEmail string
|
|
|
|
// Get email from invitation data first
|
|
inviteeEmail = toString(invitationData["email"])
|
|
|
|
if invitationData["user_id"] != nil && invitationData["user_id"] != "" {
|
|
inviteeUserID = toString(invitationData["user_id"])
|
|
exists, err := provider.MemberExists(ctx, teamID, inviteeUserID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to check member existence: %w", err)
|
|
}
|
|
if exists {
|
|
return "", fmt.Errorf("user is already a member or has a pending invitation")
|
|
}
|
|
|
|
// If email not provided, get it from user profile
|
|
if inviteeEmail == "" {
|
|
user, err := provider.GetUser(ctx, inviteeUserID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user information: %w", err)
|
|
}
|
|
inviteeEmail = toString(user["email"])
|
|
|
|
// Update invitation data with email from user profile
|
|
if inviteeEmail != "" {
|
|
invitationData["email"] = inviteeEmail
|
|
}
|
|
}
|
|
} else {
|
|
// For invitations without user_id (general invitation link or unregistered users)
|
|
// Set user_id to nil (NULL in database)
|
|
invitationData["user_id"] = nil
|
|
}
|
|
|
|
// Check send_email requirement early
|
|
shouldSendEmail := false
|
|
if settings, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
shouldSendEmail = toBool(settings["send_email"])
|
|
}
|
|
|
|
// If send_email is true, email must be provided
|
|
if shouldSendEmail && inviteeEmail == "" {
|
|
return "", fmt.Errorf("email is required when send_email is true")
|
|
}
|
|
|
|
// Generate invitation token
|
|
token, err := generateInvitationToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate invitation token: %w", err)
|
|
}
|
|
|
|
// Calculate expiry duration
|
|
expiryDuration, err := getInvitationExpiry(invitationData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse expiry duration: %w", err)
|
|
}
|
|
|
|
// Set invitation-specific fields
|
|
invitationData["team_id"] = teamID
|
|
if invitationData["member_type"] == nil || invitationData["member_type"] == "" {
|
|
invitationData["member_type"] = "user"
|
|
}
|
|
invitationData["status"] = "pending"
|
|
invitationData["invited_by"] = userID
|
|
invitationData["invited_at"] = time.Now()
|
|
invitationData["invitation_token"] = token
|
|
invitationData["invitation_expires_at"] = time.Now().Add(expiryDuration)
|
|
invitationData["created_at"] = time.Now()
|
|
invitationData["updated_at"] = time.Now()
|
|
|
|
// Create invitation (as a pending member)
|
|
memberID, err := provider.CreateMember(ctx, invitationData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create invitation: %w", err)
|
|
}
|
|
|
|
// Get the created member to retrieve the generated invitation_id
|
|
createdMember, err := provider.GetMemberByID(ctx, memberID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to retrieve created invitation: %w", err)
|
|
}
|
|
|
|
// Get the generated invitation_id
|
|
invitationID := toString(createdMember["invitation_id"])
|
|
|
|
// Send email if requested (shouldSendEmail was already determined earlier)
|
|
if shouldSendEmail {
|
|
err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, token, invitationID, invitationData)
|
|
if err != nil {
|
|
log.Error("Failed to send invitation email: %v", err)
|
|
// Don't fail the invitation creation if email fails
|
|
// The invitation link can still be shared manually
|
|
} else {
|
|
log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID)
|
|
}
|
|
}
|
|
|
|
return invitationID, nil
|
|
}
|
|
|
|
// invitationResend handles the business logic for resending a team invitation
|
|
func invitationResend(ctx context.Context, userID, teamID, invitationID string) error {
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return fmt.Errorf("access denied: only team owner can resend invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get existing invitation using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if toString(invitationData["team_id"]) != teamID {
|
|
return fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if toString(invitationData["status"]) != "pending" {
|
|
return fmt.Errorf("invitation is no longer pending and cannot be resent")
|
|
}
|
|
|
|
// Get team information for email template
|
|
team, err := provider.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get team information: %w", err)
|
|
}
|
|
teamName := toString(team["name"])
|
|
|
|
// Get inviter information for email template
|
|
inviter, err := provider.GetUser(ctx, userID)
|
|
if err != nil {
|
|
log.Warn("Failed to get inviter information: %v", err)
|
|
inviter = maps.MapStrAny{"name": "Team Admin"}
|
|
}
|
|
inviterName := toString(inviter["name"])
|
|
if inviterName == "" {
|
|
inviterName = toString(inviter["email"])
|
|
}
|
|
|
|
// Generate new invitation token
|
|
newToken, err := generateInvitationToken()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate new invitation token: %w", err)
|
|
}
|
|
|
|
// Calculate expiry duration (use existing expiry from original invitation data)
|
|
expiryDuration, err := getInvitationExpiry(invitationData)
|
|
if err != nil {
|
|
log.Warn("Failed to parse expiry duration: %v, using default", err)
|
|
expiryDuration = 7 * 24 * time.Hour
|
|
}
|
|
|
|
// Update invitation with new token and extended expiry
|
|
newExpiryTime := time.Now().Add(expiryDuration)
|
|
updateData := maps.MapStrAny{
|
|
"invitation_token": newToken,
|
|
"invitation_expires_at": newExpiryTime,
|
|
"invited_at": time.Now(), // Update invitation time
|
|
"updated_at": time.Now(),
|
|
}
|
|
|
|
// Update invitation using invitation_id
|
|
err = provider.UpdateMemberByInvitationID(ctx, invitationID, updateData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update invitation: %w", err)
|
|
}
|
|
|
|
// Update the invitation data for email sending
|
|
invitationData["invitation_token"] = newToken
|
|
invitationData["invitation_expires_at"] = newExpiryTime
|
|
|
|
// Get email from invitation data
|
|
var inviteeEmail string
|
|
if inviteeUserID := toString(invitationData["user_id"]); inviteeUserID != "" {
|
|
// Get user email for registered user
|
|
user, err := provider.GetUser(ctx, inviteeUserID)
|
|
if err != nil {
|
|
log.Warn("Failed to get user information: %v", err)
|
|
} else {
|
|
inviteeEmail = toString(user["email"])
|
|
}
|
|
}
|
|
|
|
// Send new invitation email if email is available
|
|
if inviteeEmail != "" {
|
|
err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData)
|
|
if err != nil {
|
|
log.Error("Failed to resend invitation email: %v", err)
|
|
// Don't fail the resend if email fails
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// invitationDelete handles the business logic for cancelling a team invitation
|
|
func invitationDelete(ctx context.Context, userID, teamID, invitationID string) error {
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return fmt.Errorf("access denied: only team owner can cancel invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get existing invitation using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if toString(invitationData["team_id"]) != teamID {
|
|
return fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if toString(invitationData["status"]) != "pending" {
|
|
return fmt.Errorf("invitation is no longer pending and cannot be cancelled")
|
|
}
|
|
|
|
// Remove the pending invitation (delete the member record)
|
|
err = provider.RemoveMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel invitation: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Private Helper Functions (internal use only)
|
|
|
|
// generateInvitationToken generates a secure random token for invitations
|
|
func generateInvitationToken() (string, error) {
|
|
bytes := make([]byte, 32) // 32 bytes = 256 bits
|
|
_, err := rand.Read(bytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Use URL-safe base64 encoding and remove padding
|
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil
|
|
}
|
|
|
|
// getInvitationExpiry calculates the expiry duration for an invitation
|
|
// Priority: 1. Request expiry parameter, 2. Team config, 3. Default (7 days)
|
|
func getInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) {
|
|
// Default expiry: 7 days
|
|
defaultExpiry := 7 * 24 * time.Hour
|
|
|
|
// Check if expiry is provided in request
|
|
expiry := toString(invitationData["expiry"])
|
|
if expiry != "" {
|
|
normalizedDuration, err := normalizeDuration(expiry)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid expiry format: %w", err)
|
|
}
|
|
duration, err := time.ParseDuration(normalizedDuration)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse expiry duration: %w", err)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
// Get team config expiry (from global config)
|
|
// Try to get locale from invitation data settings
|
|
locale := "en"
|
|
if settings, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
if loc := toString(settings["locale"]); loc != "" {
|
|
locale = loc
|
|
}
|
|
}
|
|
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.Expiry != "" {
|
|
normalizedDuration, err := normalizeDuration(teamConfig.Invite.Expiry)
|
|
if err != nil {
|
|
log.Warn("Invalid expiry format in team config: %v, using default", err)
|
|
return defaultExpiry, nil
|
|
}
|
|
duration, err := time.ParseDuration(normalizedDuration)
|
|
if err != nil {
|
|
log.Warn("Failed to parse team config expiry: %v, using default", err)
|
|
return defaultExpiry, nil
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
return defaultExpiry, nil
|
|
}
|
|
|
|
// sendInvitationEmail sends an invitation email using messenger service
|
|
func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, token, invitationID string, invitationData maps.MapStrAny) error {
|
|
// Check if messenger is available
|
|
if messenger.Instance == nil {
|
|
return fmt.Errorf("messenger service not available")
|
|
}
|
|
|
|
// Get locale from invitation data settings
|
|
locale := "en"
|
|
if settings, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
if loc := toString(settings["locale"]); loc != "" {
|
|
locale = loc
|
|
}
|
|
}
|
|
|
|
// Get team config for email template and channel
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig == nil || teamConfig.Invite == nil {
|
|
return fmt.Errorf("team configuration not found for locale: %s", locale)
|
|
}
|
|
|
|
// Get email template ID from team config
|
|
emailTemplate := ""
|
|
if teamConfig.Invite.Templates != nil {
|
|
if tpl, ok := teamConfig.Invite.Templates["mail"]; ok {
|
|
emailTemplate = tpl
|
|
}
|
|
}
|
|
if emailTemplate == "" {
|
|
return fmt.Errorf("email template not configured in team config")
|
|
}
|
|
|
|
// Get channel from team config (default to "default")
|
|
channel := "default"
|
|
if teamConfig.Invite.Channel != "" {
|
|
channel = teamConfig.Invite.Channel
|
|
}
|
|
|
|
// Get custom message from invitation data
|
|
customMessage := toString(invitationData["message"])
|
|
|
|
// Prepare template data for messenger
|
|
templateData := messengertypes.TemplateData{
|
|
"to": email,
|
|
"inviter_name": inviterName,
|
|
"team_name": teamName,
|
|
"invitation_id": invitationID,
|
|
"token": token,
|
|
"message": customMessage,
|
|
"role_id": toString(invitationData["role_id"]),
|
|
"expires_at": toString(invitationData["invitation_expires_at"]),
|
|
}
|
|
|
|
// Send email using messenger template
|
|
err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send invitation email: %w", err)
|
|
}
|
|
|
|
log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", email, teamName, invitationID)
|
|
return nil
|
|
}
|
|
|
|
// mapToInvitationResponse converts a map to InvitationResponse
|
|
func mapToInvitationResponse(data maps.MapStr) InvitationResponse {
|
|
invitation := InvitationResponse{
|
|
ID: toInt64(data["id"]),
|
|
TeamID: toString(data["team_id"]),
|
|
UserID: toString(data["user_id"]),
|
|
MemberType: toString(data["member_type"]),
|
|
RoleID: toString(data["role_id"]),
|
|
Status: toString(data["status"]),
|
|
InvitedBy: toString(data["invited_by"]),
|
|
InvitedAt: toTimeString(data["invited_at"]),
|
|
InvitationToken: toString(data["invitation_token"]),
|
|
InvitationExpiresAt: toTimeString(data["invitation_expires_at"]),
|
|
Message: toString(data["message"]),
|
|
CreatedAt: toTimeString(data["created_at"]),
|
|
UpdatedAt: toTimeString(data["updated_at"]),
|
|
}
|
|
|
|
// Add settings if available
|
|
if settings, ok := data["settings"]; ok {
|
|
if settingsMap, ok := settings.(map[string]interface{}); ok {
|
|
invitation.Settings = settingsMap
|
|
}
|
|
}
|
|
|
|
return invitation
|
|
}
|
|
|
|
// mapToInvitationDetailResponse converts a map to InvitationDetailResponse
|
|
func mapToInvitationDetailResponse(data maps.MapStr) InvitationDetailResponse {
|
|
invitation := InvitationDetailResponse{
|
|
InvitationResponse: mapToInvitationResponse(data),
|
|
}
|
|
|
|
// Add user info if available (could be joined from user table)
|
|
if userInfo, ok := data["user_info"]; ok {
|
|
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
|
|
invitation.UserInfo = userInfoMap
|
|
}
|
|
}
|
|
|
|
// Add team info if available (could be joined from team table)
|
|
if teamInfo, ok := data["team_info"]; ok {
|
|
if teamInfoMap, ok := teamInfo.(map[string]interface{}); ok {
|
|
invitation.TeamInfo = teamInfoMap
|
|
}
|
|
}
|
|
|
|
return invitation
|
|
}
|