- Updated the structure of settings in invitation, member, and team responses to use specific types instead of generic maps for better type safety and clarity. - Implemented backward compatibility for existing settings formats to ensure seamless integration with previous data structures. - Enhanced the logic for handling settings in invitation creation and response mapping, improving maintainability and readability of the codebase.
1138 lines
36 KiB
Go
1138 lines
36 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,
|
|
}
|
|
|
|
// Prepare settings
|
|
settings := &InvitationSettings{}
|
|
if req.Settings != nil {
|
|
settings = req.Settings
|
|
}
|
|
|
|
// Add send_email from top-level field (for backward compatibility)
|
|
if req.SendEmail != nil {
|
|
settings.SendEmail = *req.SendEmail
|
|
}
|
|
|
|
// Add settings to invitation data
|
|
if settings.SendEmail || settings.Locale != "" {
|
|
invitationData["settings"] = settings
|
|
}
|
|
|
|
// 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"].(*InvitationSettings); ok && settings != nil {
|
|
shouldSendEmail = settings.SendEmail
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
shouldSendEmail = toBool(settingsMap["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"].(*InvitationSettings); ok && settings != nil {
|
|
if settings.Locale != "" {
|
|
locale = settings.Locale
|
|
}
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
if loc := toString(settingsMap["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"].(*InvitationSettings); ok && settings != nil {
|
|
if settings.Locale != "" {
|
|
locale = settings.Locale
|
|
}
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
if loc := toString(settingsMap["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 invSettings, ok := settings.(*InvitationSettings); ok {
|
|
invitation.Settings = invSettings
|
|
} else if settingsMap, ok := settings.(map[string]interface{}); ok {
|
|
// Convert map to InvitationSettings
|
|
invSettings := &InvitationSettings{
|
|
SendEmail: toBool(settingsMap["send_email"]),
|
|
Locale: toString(settingsMap["locale"]),
|
|
}
|
|
invitation.Settings = invSettings
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|