Merge pull request #1209 from trheyi/main
Add invitation model and related functionality
This commit is contained in:
commit
36909d9f54
11 changed files with 1322 additions and 194 deletions
303
data/bindata.go
303
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -27,6 +27,7 @@ var systemModels = map[string]string{
|
|||
"__yao.audit": "yao/models/audit.mod.yao",
|
||||
"__yao.config": "yao/models/config.mod.yao",
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
"__yao.invitation": "yao/models/invitation.mod.yao",
|
||||
"__yao.job.category": "yao/models/job/category.mod.yao",
|
||||
"__yao.job": "yao/models/job/job.mod.yao",
|
||||
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ const (
|
|||
ErrFailedToDeleteTeam = "failed to delete team: %w"
|
||||
ErrFailedToDeleteMember = "failed to delete member: %w"
|
||||
|
||||
// Invitation Code related errors
|
||||
ErrInvitationCodeNotFound = "invitation code not found"
|
||||
ErrInvitationCodeAlreadyUsed = "invitation code has already been used"
|
||||
ErrInvitationCodeExpired = "invitation code has expired"
|
||||
ErrInvitationCodeNotPublished = "invitation code is not published"
|
||||
ErrFailedToCreateInvitationCode = "failed to create invitation code: %w"
|
||||
ErrFailedToUseInvitationCode = "failed to use invitation code: %w"
|
||||
ErrFailedToDeleteInvitationCode = "failed to delete invitation code: %w"
|
||||
|
||||
// MFA related errors
|
||||
ErrMFANotEnabled = "MFA is not enabled for this user"
|
||||
ErrMFAAlreadyEnabled = "MFA is already enabled for this user"
|
||||
|
|
@ -185,6 +194,7 @@ type DefaultUser struct {
|
|||
oauthAccountModel string
|
||||
teamModel string
|
||||
memberModel string
|
||||
invitationModel string
|
||||
cache store.Store
|
||||
|
||||
// ID Generation Configuration
|
||||
|
|
@ -240,6 +250,7 @@ type DefaultUserOptions struct {
|
|||
OAuthAccountModel string // bind to a specific oauth account model
|
||||
TeamModel string // bind to a specific team model
|
||||
MemberModel string // bind to a specific member model
|
||||
InvitationModel string // bind to a specific invitation code model
|
||||
Cache store.Store
|
||||
|
||||
// ID Generation Strategy
|
||||
|
|
@ -308,6 +319,11 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
|
|||
memberModel = "__yao.member"
|
||||
}
|
||||
|
||||
invitationModel := options.InvitationModel
|
||||
if invitationModel == "" {
|
||||
invitationModel = "__yao.invitation"
|
||||
}
|
||||
|
||||
// Set ID generation strategy with defaults
|
||||
idStrategy := options.IDStrategy
|
||||
if idStrategy == "" {
|
||||
|
|
@ -397,6 +413,7 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
|
|||
oauthAccountModel: oauthAccountModel,
|
||||
teamModel: teamModel,
|
||||
memberModel: memberModel,
|
||||
invitationModel: invitationModel,
|
||||
cache: options.Cache,
|
||||
idStrategy: idStrategy,
|
||||
idPrefix: idPrefix,
|
||||
|
|
|
|||
208
openapi/oauth/providers/user/invitation.go
Normal file
208
openapi/oauth/providers/user/invitation.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
)
|
||||
|
||||
// Invitation Code Resource (Official Platform Invitation Codes)
|
||||
|
||||
// CreateInvitationCodes creates invitation codes in batch
|
||||
// Supports creating multiple invitation codes at once for efficiency
|
||||
func (u *DefaultUser) CreateInvitationCodes(ctx context.Context, codeData []maps.MapStrAny) ([]string, error) {
|
||||
if len(codeData) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
codes := make([]string, 0, len(codeData))
|
||||
m := model.Select(u.invitationModel)
|
||||
|
||||
// Validate and prepare data - collect all possible columns
|
||||
columnsSet := make(map[string]bool)
|
||||
columnsSet["code"] = true
|
||||
columnsSet["status"] = true
|
||||
columnsSet["is_published"] = true
|
||||
columnsSet["code_type"] = true
|
||||
|
||||
for i := range codeData {
|
||||
// Validate required fields
|
||||
code, hasCode := codeData[i]["code"].(string)
|
||||
if !hasCode || code == "" {
|
||||
return nil, fmt.Errorf("code is required in codeData at index %d", i)
|
||||
}
|
||||
|
||||
// Set default values if not provided
|
||||
if _, exists := codeData[i]["status"]; !exists {
|
||||
codeData[i]["status"] = "draft"
|
||||
}
|
||||
if _, exists := codeData[i]["is_published"]; !exists {
|
||||
codeData[i]["is_published"] = false
|
||||
}
|
||||
if _, exists := codeData[i]["code_type"]; !exists {
|
||||
codeData[i]["code_type"] = "official"
|
||||
}
|
||||
|
||||
// Collect optional columns
|
||||
for _, col := range []string{"owner_id", "description", "source", "expires_at", "metadata"} {
|
||||
if _, exists := codeData[i][col]; exists {
|
||||
columnsSet[col] = true
|
||||
}
|
||||
}
|
||||
|
||||
codes = append(codes, code)
|
||||
}
|
||||
|
||||
// Build ordered column list
|
||||
columns := []string{"code", "status", "is_published", "code_type"}
|
||||
for _, col := range []string{"owner_id", "description", "source", "expires_at", "metadata"} {
|
||||
if columnsSet[col] {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
}
|
||||
|
||||
// Build values matrix
|
||||
values := make([][]interface{}, 0, len(codeData))
|
||||
for i := range codeData {
|
||||
row := make([]interface{}, len(columns))
|
||||
for j, col := range columns {
|
||||
if val, exists := codeData[i][col]; exists {
|
||||
row[j] = val
|
||||
} else {
|
||||
row[j] = nil
|
||||
}
|
||||
}
|
||||
values = append(values, row)
|
||||
}
|
||||
|
||||
// Batch insert
|
||||
err := m.Insert(columns, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ErrFailedToCreateInvitationCode, err)
|
||||
}
|
||||
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// UseInvitationCode marks an invitation code as used (redemption)
|
||||
// This is called when a user successfully uses an invitation code during registration
|
||||
func (u *DefaultUser) UseInvitationCode(ctx context.Context, code string, userID string) error {
|
||||
m := model.Select(u.invitationModel)
|
||||
|
||||
// First, get the invitation code to validate it
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"id", "code", "status", "is_published", "expires_at", "used_by"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(ErrFailedToUseInvitationCode, err)
|
||||
}
|
||||
|
||||
if len(invitations) == 0 {
|
||||
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||
}
|
||||
|
||||
invitation := invitations[0]
|
||||
|
||||
// Check if already used
|
||||
if usedBy := invitation["used_by"]; usedBy != nil && usedBy != "" {
|
||||
return fmt.Errorf(ErrInvitationCodeAlreadyUsed)
|
||||
}
|
||||
|
||||
// Check if published (handle both bool and int64 types from different databases)
|
||||
isPublished := false
|
||||
switch v := invitation["is_published"].(type) {
|
||||
case bool:
|
||||
isPublished = v
|
||||
case int64:
|
||||
isPublished = v != 0
|
||||
case int:
|
||||
isPublished = v != 0
|
||||
}
|
||||
if !isPublished {
|
||||
return fmt.Errorf(ErrInvitationCodeNotPublished)
|
||||
}
|
||||
|
||||
// Check status
|
||||
status, ok := invitation["status"].(string)
|
||||
if !ok || status != "active" {
|
||||
return fmt.Errorf("invitation code status must be 'active' to use, current status: %s", status)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if expiresAt := invitation["expires_at"]; expiresAt != nil {
|
||||
if expired, err := checkTimeExpired(expiresAt); err == nil && expired {
|
||||
return fmt.Errorf(ErrInvitationCodeExpired)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
updateData := maps.MapStrAny{
|
||||
"used_by": userID,
|
||||
"used_at": time.Now(),
|
||||
"status": "used",
|
||||
}
|
||||
|
||||
affected, err := m.UpdateWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
Limit: 1,
|
||||
}, updateData)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(ErrFailedToUseInvitationCode, err)
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInvitationCode soft deletes an invitation code
|
||||
func (u *DefaultUser) DeleteInvitationCode(ctx context.Context, code string) error {
|
||||
// First check if invitation code exists
|
||||
m := model.Select(u.invitationModel)
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"id", "code"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(ErrFailedToDeleteInvitationCode, err)
|
||||
}
|
||||
|
||||
if len(invitations) == 0 {
|
||||
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||
}
|
||||
|
||||
// Proceed with soft delete
|
||||
affected, err := m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(ErrFailedToDeleteInvitationCode, err)
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
528
openapi/oauth/providers/user/invitation_test.go
Normal file
528
openapi/oauth/providers/user/invitation_test.go
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// TestCreateInvitationCodes tests batch creation of invitation codes
|
||||
func TestCreateInvitationCodes(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ctx := context.Background()
|
||||
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||
|
||||
// Test Case 1: Create multiple invitation codes successfully
|
||||
t.Run("Create multiple codes successfully", func(t *testing.T) {
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-BETA-001",
|
||||
"code_type": "beta",
|
||||
"description": "Beta testing code 1",
|
||||
"owner_id": nil, // Official code
|
||||
"status": "draft",
|
||||
},
|
||||
{
|
||||
"code": "TEST-BETA-002",
|
||||
"code_type": "beta",
|
||||
"description": "Beta testing code 2",
|
||||
"owner_id": nil, // Official code
|
||||
"status": "draft",
|
||||
},
|
||||
{
|
||||
"code": "TEST-PARTNER-001",
|
||||
"code_type": "partner",
|
||||
"description": "Partner code 1",
|
||||
"owner_id": nil,
|
||||
"status": "draft",
|
||||
},
|
||||
}
|
||||
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(codes))
|
||||
assert.Contains(t, codes, "TEST-BETA-001")
|
||||
assert.Contains(t, codes, "TEST-BETA-002")
|
||||
assert.Contains(t, codes, "TEST-PARTNER-001")
|
||||
|
||||
// Verify codes were created in database
|
||||
m := model.Select("__yao.invitation")
|
||||
for _, code := range codes {
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code", "status", "code_type"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(invitations))
|
||||
assert.Equal(t, "draft", invitations[0]["status"])
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for _, code := range codes {
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: code},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test Case 2: Create with default values
|
||||
t.Run("Create with default values", func(t *testing.T) {
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-DEFAULT-001",
|
||||
},
|
||||
}
|
||||
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(codes))
|
||||
|
||||
// Verify default values
|
||||
m := model.Select("__yao.invitation")
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code", "status", "is_published", "code_type"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-DEFAULT-001"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(invitations))
|
||||
assert.Equal(t, "draft", invitations[0]["status"])
|
||||
// is_published can be bool(false) or int64(0) or int(0) - all are valid
|
||||
assert.NotNil(t, invitations[0]["is_published"])
|
||||
assert.Equal(t, "official", invitations[0]["code_type"])
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-DEFAULT-001"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 3: Empty batch
|
||||
t.Run("Empty batch", func(t *testing.T) {
|
||||
codes, err := provider.CreateInvitationCodes(ctx, []maps.MapStrAny{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(codes))
|
||||
})
|
||||
|
||||
// Test Case 4: Missing required field (code)
|
||||
t.Run("Missing required field", func(t *testing.T) {
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code_type": "beta",
|
||||
"description": "Missing code field",
|
||||
},
|
||||
}
|
||||
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "code is required")
|
||||
assert.Equal(t, 0, len(codes))
|
||||
})
|
||||
|
||||
// Test Case 5: Duplicate code (should fail)
|
||||
t.Run("Duplicate code", func(t *testing.T) {
|
||||
// Create first code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-DUPLICATE",
|
||||
"code_type": "official",
|
||||
},
|
||||
}
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(codes))
|
||||
|
||||
// Try to create duplicate
|
||||
codes, err = provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Cleanup
|
||||
m := model.Select("__yao.invitation")
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-DUPLICATE"},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestUseInvitationCode tests invitation code redemption
|
||||
func TestUseInvitationCode(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ctx := context.Background()
|
||||
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||
m := model.Select("__yao.invitation")
|
||||
|
||||
// Test Case 1: Successfully use a valid invitation code
|
||||
t.Run("Use valid code successfully", func(t *testing.T) {
|
||||
// Create a valid, published, active invitation code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-001",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Use the invitation code
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-001", "user_123")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify code was marked as used
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code", "status", "used_by", "used_at"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-001"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(invitations))
|
||||
assert.Equal(t, "used", invitations[0]["status"])
|
||||
assert.Equal(t, "user_123", invitations[0]["used_by"])
|
||||
assert.NotNil(t, invitations[0]["used_at"])
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-001"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 2: Try to use non-existent code
|
||||
t.Run("Use non-existent code", func(t *testing.T) {
|
||||
err := provider.UseInvitationCode(ctx, "NONEXISTENT-CODE", "user_123")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||
})
|
||||
|
||||
// Test Case 3: Try to use already used code
|
||||
t.Run("Use already used code", func(t *testing.T) {
|
||||
// Create and use a code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-002",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First use
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-002", "user_123")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to use again
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-002", "user_456")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeAlreadyUsed)
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-002"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 4: Try to use unpublished code
|
||||
t.Run("Use unpublished code", func(t *testing.T) {
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-003",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": false, // Not published
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-003", "user_123")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeNotPublished)
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-003"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 5: Try to use code with wrong status
|
||||
t.Run("Use code with draft status", func(t *testing.T) {
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-004",
|
||||
"code_type": "beta",
|
||||
"status": "draft", // Not active
|
||||
"is_published": true,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-004", "user_123")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "status must be 'active'")
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-004"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 6: Try to use expired code
|
||||
t.Run("Use expired code", func(t *testing.T) {
|
||||
// Create code that expired yesterday
|
||||
yesterday := time.Now().Add(-24 * time.Hour)
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-005",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
"expires_at": yesterday,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-005", "user_123")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeExpired)
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-005"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Test Case 7: Use code that has not expired yet
|
||||
t.Run("Use code with future expiration", func(t *testing.T) {
|
||||
// Create code that expires tomorrow
|
||||
tomorrow := time.Now().Add(24 * time.Hour)
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-USE-006",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
"expires_at": tomorrow,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = provider.UseInvitationCode(ctx, "TEST-USE-006", "user_123")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-USE-006"},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteInvitationCode tests invitation code deletion
|
||||
func TestDeleteInvitationCode(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ctx := context.Background()
|
||||
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||
m := model.Select("__yao.invitation")
|
||||
|
||||
// Test Case 1: Successfully delete an invitation code
|
||||
t.Run("Delete code successfully", func(t *testing.T) {
|
||||
// Create a code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-DELETE-001",
|
||||
"code_type": "beta",
|
||||
},
|
||||
}
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(codes))
|
||||
|
||||
// Delete the code
|
||||
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-001")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify code was soft deleted (should not appear in normal queries)
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-DELETE-001"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(invitations), "Code should be soft deleted")
|
||||
})
|
||||
|
||||
// Test Case 2: Try to delete non-existent code
|
||||
t.Run("Delete non-existent code", func(t *testing.T) {
|
||||
err := provider.DeleteInvitationCode(ctx, "NONEXISTENT-DELETE-CODE")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||
})
|
||||
|
||||
// Test Case 3: Delete used code
|
||||
t.Run("Delete used code", func(t *testing.T) {
|
||||
// Create and use a code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-DELETE-002",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Use the code
|
||||
err = provider.UseInvitationCode(ctx, "TEST-DELETE-002", "user_123")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Delete the used code (should succeed)
|
||||
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-002")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "TEST-DELETE-002"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(invitations))
|
||||
})
|
||||
|
||||
// Test Case 4: Try to delete same code twice
|
||||
t.Run("Delete code twice", func(t *testing.T) {
|
||||
// Create a code
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "TEST-DELETE-003",
|
||||
"code_type": "beta",
|
||||
},
|
||||
}
|
||||
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First delete
|
||||
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-003")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Second delete (should fail)
|
||||
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-003")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// TestInvitationCodeWorkflow tests the complete workflow: create -> use -> delete
|
||||
func TestInvitationCodeWorkflow(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ctx := context.Background()
|
||||
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||
m := model.Select("__yao.invitation")
|
||||
|
||||
t.Run("Complete workflow", func(t *testing.T) {
|
||||
// Step 1: Create multiple codes
|
||||
codeData := []maps.MapStrAny{
|
||||
{
|
||||
"code": "WORKFLOW-001",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
"description": "Workflow test code 1",
|
||||
},
|
||||
{
|
||||
"code": "WORKFLOW-002",
|
||||
"code_type": "beta",
|
||||
"status": "active",
|
||||
"is_published": true,
|
||||
"description": "Workflow test code 2",
|
||||
},
|
||||
}
|
||||
|
||||
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(codes))
|
||||
|
||||
// Step 2: Use first code
|
||||
err = provider.UseInvitationCode(ctx, "WORKFLOW-001", "user_workflow_1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Step 3: Verify first code is used
|
||||
invitations, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code", "status", "used_by"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: "WORKFLOW-001"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(invitations))
|
||||
assert.Equal(t, "used", invitations[0]["status"])
|
||||
assert.Equal(t, "user_workflow_1", invitations[0]["used_by"])
|
||||
|
||||
// Step 4: Delete second code (unused)
|
||||
err = provider.DeleteInvitationCode(ctx, "WORKFLOW-002")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Step 5: Delete first code (used)
|
||||
err = provider.DeleteInvitationCode(ctx, "WORKFLOW-001")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Step 6: Verify both codes are deleted
|
||||
invitations, err = m.Get(model.QueryParam{
|
||||
Select: []interface{}{"code"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "code", Value: []string{"WORKFLOW-001", "WORKFLOW-002"}, OP: "in"},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(invitations))
|
||||
})
|
||||
}
|
||||
|
|
@ -329,6 +329,16 @@ type UserProvider interface {
|
|||
// Member List and Search
|
||||
PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||
|
||||
// ============================================================================
|
||||
// Invitation Code Resource (Official Platform Invitation Codes)
|
||||
// ============================================================================
|
||||
// Note: This is for official platform invitation codes required during beta testing.
|
||||
// Not to be confused with user-to-user invitation functionality (coming later).
|
||||
|
||||
CreateInvitationCodes(ctx context.Context, codeData []maps.MapStrAny) ([]string, error)
|
||||
UseInvitationCode(ctx context.Context, code string, userID string) error
|
||||
DeleteInvitationCode(ctx context.Context, code string) error
|
||||
|
||||
// ============================================================================
|
||||
// Utils
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -386,22 +386,6 @@ func sendVerificationMessage(ctx context.Context, config *EntryConfig, usernameT
|
|||
return nil
|
||||
}
|
||||
|
||||
// sendEntryVerificationCode generates and sends a verification code to the user's email or mobile
|
||||
// Returns OTP ID and error
|
||||
func sendEntryVerificationCode(ctx context.Context, config *EntryConfig, usernameType, username, locale string) (string, error) {
|
||||
// Generate OTP
|
||||
otpID, verificationCode := generateEntryOTP()
|
||||
log.Debug("Generated OTP for %s: ID=%s", username, otpID)
|
||||
|
||||
// Send verification message
|
||||
err := sendVerificationMessage(ctx, config, usernameType, username, verificationCode, locale)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return otpID, nil
|
||||
}
|
||||
|
||||
// createPublicEntryConfig creates a deep copy of EntryConfig without sensitive data
|
||||
// This prevents modifying the global config when removing secrets
|
||||
func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
||||
|
|
@ -487,24 +471,7 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// Deep copy Messenger config (without sensitive data)
|
||||
if config.Messenger != nil {
|
||||
publicConfig.Messenger = &MessengerConfig{}
|
||||
|
||||
if config.Messenger.Mail != nil {
|
||||
publicConfig.Messenger.Mail = &MessengerChannelConfig{
|
||||
Channel: config.Messenger.Mail.Channel,
|
||||
Template: config.Messenger.Mail.Template,
|
||||
}
|
||||
}
|
||||
|
||||
if config.Messenger.SMS != nil {
|
||||
publicConfig.Messenger.SMS = &MessengerChannelConfig{
|
||||
Channel: config.Messenger.SMS.Channel,
|
||||
Template: config.Messenger.SMS.Template,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Messenger config is intentionally not copied to public config (backend only)
|
||||
|
||||
// Deep copy ThirdParty config
|
||||
if config.ThirdParty != nil {
|
||||
|
|
@ -538,6 +505,18 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// Deep copy Invite config
|
||||
if config.Invite != nil {
|
||||
publicConfig.Invite = &InvitePageConfig{
|
||||
Title: config.Invite.Title,
|
||||
Description: config.Invite.Description,
|
||||
Placeholder: config.Invite.Placeholder,
|
||||
ApplyLink: config.Invite.ApplyLink,
|
||||
ApplyPrompt: config.Invite.ApplyPrompt,
|
||||
ApplyText: config.Invite.ApplyText,
|
||||
}
|
||||
}
|
||||
|
||||
return publicConfig
|
||||
}
|
||||
|
||||
|
|
@ -1081,3 +1060,131 @@ func GinEntryLogin(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GinVerifyInvite is the handler for verifying and redeeming invitation code
|
||||
// This endpoint is called with the temporary access token (scope: invite_verification)
|
||||
// after user registration when invite is required
|
||||
func GinVerifyInvite(c *gin.Context) {
|
||||
// Parse request body
|
||||
var req struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Get authorized info from the temporary token
|
||||
authInfo := oauth.GetAuthorizedInfo(c)
|
||||
if authInfo == nil || authInfo.Scope != ScopeInviteVerification {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInsufficientScope.Code,
|
||||
ErrorDescription: "Invalid or missing token scope. Expected invite_verification scope",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from auth info
|
||||
userID := authInfo.UserID
|
||||
if userID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidToken.Code,
|
||||
ErrorDescription: "User ID not found in token",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user provider
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
log.Error("Failed to get user provider: %s", err.Error())
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Internal server error",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Redeem invitation code
|
||||
err = userProvider.UseInvitationCode(ctx, req.InvitationCode, userID)
|
||||
if err != nil {
|
||||
log.Error("Failed to use invitation code: %s", err.Error())
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: fmt.Sprintf("Failed to verify invitation code: %s", err.Error()),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Update user status to active
|
||||
err = userProvider.UpdateUserStatus(ctx, userID, "active")
|
||||
if err != nil {
|
||||
log.Error("Failed to update user status: %s", err.Error())
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to activate user account",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate login context
|
||||
loginCtx := makeLoginContext(c)
|
||||
|
||||
// Generate full login token
|
||||
loginResponse, err := LoginByUserID(userID, loginCtx)
|
||||
if err != nil {
|
||||
log.Error("Failed to generate login token: %s", err.Error())
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to generate login credentials",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create session ID
|
||||
sid := utils.GetSessionID(c)
|
||||
if sid == "" {
|
||||
sid = generateSessionID()
|
||||
}
|
||||
|
||||
// Send session cookie
|
||||
response.SendSessionCookie(c, sid)
|
||||
|
||||
// Handle different login statuses (in case MFA is enabled or team selection needed)
|
||||
switch loginResponse.Status {
|
||||
case LoginStatusMFA, LoginStatusTeamSelection:
|
||||
// Return temporary token for next step verification
|
||||
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
||||
SessionID: sid,
|
||||
AccessToken: loginResponse.AccessToken,
|
||||
ExpiresIn: loginResponse.ExpiresIn,
|
||||
MFAEnabled: loginResponse.MFAEnabled,
|
||||
Status: loginResponse.Status,
|
||||
})
|
||||
default:
|
||||
// Success - return full token set
|
||||
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
||||
SessionID: sid,
|
||||
IDToken: loginResponse.IDToken,
|
||||
AccessToken: loginResponse.AccessToken,
|
||||
RefreshToken: loginResponse.RefreshToken,
|
||||
ExpiresIn: loginResponse.ExpiresIn,
|
||||
RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn,
|
||||
MFAEnabled: loginResponse.MFAEnabled,
|
||||
Status: loginResponse.Status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,22 +93,23 @@ type ProviderRegisterConfig struct {
|
|||
// EntryConfig represents the unified auth entry configuration (login + register)
|
||||
// This merges signin and register configurations into a single entry point
|
||||
type EntryConfig struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
SuccessURL string `json:"success_url,omitempty"`
|
||||
FailureURL string `json:"failure_url,omitempty"`
|
||||
LogoutRedirect string `json:"logout_redirect,omitempty"` // From signin config
|
||||
ClientID string `json:"client_id,omitempty"` // From signin config
|
||||
ClientSecret string `json:"client_secret,omitempty"` // From signin config (not exposed to frontend)
|
||||
AutoLogin bool `json:"auto_login,omitempty"` // From register config
|
||||
Role string `json:"role,omitempty"` // From register config
|
||||
Type string `json:"type,omitempty"` // From register config - User type id
|
||||
Form *FormConfig `json:"form,omitempty"`
|
||||
Token *TokenConfig `json:"token,omitempty"` // From signin config
|
||||
Messenger *MessengerConfig `json:"messenger,omitempty"` // From register config
|
||||
InviteRequired bool `json:"invite_required,omitempty"` // From register config
|
||||
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
SuccessURL string `json:"success_url,omitempty"`
|
||||
FailureURL string `json:"failure_url,omitempty"`
|
||||
LogoutRedirect string `json:"logout_redirect,omitempty"` // From signin config
|
||||
ClientID string `json:"client_id,omitempty"` // From signin config
|
||||
ClientSecret string `json:"client_secret,omitempty"` // From signin config (not exposed to frontend)
|
||||
AutoLogin bool `json:"auto_login,omitempty"` // From register config
|
||||
Role string `json:"role,omitempty"` // From register config
|
||||
Type string `json:"type,omitempty"` // From register config - User type id
|
||||
Form *FormConfig `json:"form,omitempty"`
|
||||
Token *TokenConfig `json:"token,omitempty"` // From signin config
|
||||
Messenger *MessengerConfig `json:"messenger,omitempty"` // From register config
|
||||
InviteRequired bool `json:"invite_required,omitempty"` // From register config
|
||||
Invite *InvitePageConfig `json:"invite,omitempty"` // Invite code page configuration
|
||||
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
||||
}
|
||||
|
||||
// MessengerConfig represents the messenger configuration for user registration
|
||||
|
|
@ -123,6 +124,16 @@ type MessengerChannelConfig struct {
|
|||
Template string `json:"template,omitempty"` // Template name for this channel
|
||||
}
|
||||
|
||||
// InvitePageConfig represents the invitation code page configuration
|
||||
type InvitePageConfig struct {
|
||||
Title string `json:"title,omitempty"` // Page title for invite code verification
|
||||
Description string `json:"description,omitempty"` // Description text for invite code page
|
||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for invite code input
|
||||
ApplyLink string `json:"apply_link,omitempty"` // Optional link to apply for invitation code
|
||||
ApplyPrompt string `json:"apply_prompt,omitempty"` // Prompt text before apply link (e.g., "Don't have an invitation code?")
|
||||
ApplyText string `json:"apply_text,omitempty"` // Text for apply link (e.g., "Apply for invitation code")
|
||||
}
|
||||
|
||||
// YaoClientConfig represents the Yao OpenAPI Client config
|
||||
type YaoClientConfig struct {
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
group.POST("/entry/verify", GinEntryVerify) // Verify login/register email or mobile (public)
|
||||
|
||||
// Register a new user
|
||||
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
||||
group.POST("/entry/login", oauth.Guard, GinEntryLogin) // Login a user
|
||||
group.POST("/entry/otp", oauth.Guard, GinSendOTP) // Send OTP
|
||||
group.POST("/logout", oauth.Guard, GinLogout) // User logout
|
||||
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
||||
group.POST("/entry/login", oauth.Guard, GinEntryLogin) // Login a user
|
||||
group.POST("/entry/invite/verify", oauth.Guard, GinVerifyInvite) // Verify invitation code (redeem)
|
||||
group.POST("/entry/otp", oauth.Guard, GinSendOTP) // Send OTP
|
||||
group.POST("/logout", oauth.Guard, GinLogout) // User logout
|
||||
|
||||
// Logined User Settings
|
||||
attachProfile(group, oauth) // User profile management
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ var testSystemModels = map[string]string{
|
|||
"__yao.audit": "yao/models/audit.mod.yao",
|
||||
"__yao.config": "yao/models/config.mod.yao",
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
"__yao.invitation": "yao/models/invitation.mod.yao",
|
||||
"__yao.job.category": "yao/models/job/category.mod.yao",
|
||||
"__yao.job": "yao/models/job/job.mod.yao",
|
||||
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
||||
|
|
|
|||
221
yao/models/invitation.mod.yao
Normal file
221
yao/models/invitation.mod.yao
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{
|
||||
"name": "Invitation Code",
|
||||
"label": "Invitation Code",
|
||||
"description": "Official invitation code management for platform access control",
|
||||
"tags": ["invitation", "code", "access", "beta", "registration"],
|
||||
"table": {
|
||||
"name": "invitation_code",
|
||||
"comment": "Official invitation code management for platform access control"
|
||||
},
|
||||
"columns": [
|
||||
// ============================================================================
|
||||
// Basic Fields
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "id",
|
||||
"type": "ID",
|
||||
"label": "ID",
|
||||
"comment": "Primary key identifier",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"type": "string",
|
||||
"label": "Invitation Code",
|
||||
"comment": "Unique invitation code string",
|
||||
"length": 100,
|
||||
"unique": true,
|
||||
"index": true,
|
||||
"nullable": false
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Ownership Information
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "owner_id",
|
||||
"type": "string",
|
||||
"label": "Owner ID",
|
||||
"comment": "User ID who owns this invitation code (null = official/system generated)",
|
||||
"length": 255,
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Usage Information
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "used_by",
|
||||
"type": "string",
|
||||
"label": "Used By",
|
||||
"comment": "User ID who used this invitation code",
|
||||
"length": 255,
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
{
|
||||
"name": "used_at",
|
||||
"type": "timestamp",
|
||||
"label": "Used At",
|
||||
"comment": "When the invitation code was used",
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Status Management
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "status",
|
||||
"type": "enum",
|
||||
"label": "Status",
|
||||
"comment": "Invitation code status",
|
||||
"option": [
|
||||
"draft", // Code created but not published yet
|
||||
"active", // Code is active and available for use
|
||||
"used", // Code has been used by a user
|
||||
"expired", // Code has expired
|
||||
"revoked", // Code has been manually revoked/disabled
|
||||
"suspended" // Code temporarily suspended
|
||||
],
|
||||
"default": "draft",
|
||||
"index": true,
|
||||
"nullable": false
|
||||
},
|
||||
{
|
||||
"name": "is_published",
|
||||
"type": "boolean",
|
||||
"label": "Is Published",
|
||||
"comment": "Whether the code is published and available for use",
|
||||
"default": false,
|
||||
"index": true
|
||||
},
|
||||
{
|
||||
"name": "published_at",
|
||||
"type": "timestamp",
|
||||
"label": "Published At",
|
||||
"comment": "When the invitation code was published",
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Expiration
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"label": "Expires At",
|
||||
"comment": "When the invitation code expires",
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Code Configuration
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "code_type",
|
||||
"type": "enum",
|
||||
"label": "Code Type",
|
||||
"comment": "Type of invitation code",
|
||||
"option": [
|
||||
"official", // Official code issued by platform
|
||||
"beta", // Beta testing code
|
||||
"partner", // Partner/affiliate code
|
||||
"promotional", // Promotional code
|
||||
"custom" // Custom code type
|
||||
],
|
||||
"default": "official",
|
||||
"index": true
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Additional Information
|
||||
// ============================================================================
|
||||
{
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"label": "Description",
|
||||
"comment": "Description or notes about this invitation code",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"type": "string",
|
||||
"label": "Source",
|
||||
"comment": "Source or channel where this code is distributed",
|
||||
"length": 255,
|
||||
"nullable": true,
|
||||
"index": true
|
||||
},
|
||||
{
|
||||
"name": "metadata",
|
||||
"type": "json",
|
||||
"label": "Metadata",
|
||||
"comment": "Additional metadata and custom fields",
|
||||
"nullable": true
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_invitation_code_status_published",
|
||||
"columns": ["status", "is_published"],
|
||||
"type": "index",
|
||||
"comment": "Index on status and published flag for filtering active codes"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_owner_status",
|
||||
"columns": ["owner_id", "status"],
|
||||
"type": "index",
|
||||
"comment": "Index on owner and status for owner's code management"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_type_status",
|
||||
"columns": ["code_type", "status", "is_published"],
|
||||
"type": "index",
|
||||
"comment": "Index on code type and status for filtering by type"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_expires",
|
||||
"columns": ["status", "expires_at"],
|
||||
"type": "index",
|
||||
"comment": "Index on status and expiration for cleanup jobs"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_available",
|
||||
"columns": ["status", "is_published", "used_by"],
|
||||
"type": "index",
|
||||
"comment": "Index for finding available codes (not used yet)"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_published_at",
|
||||
"columns": ["is_published", "published_at"],
|
||||
"type": "index",
|
||||
"comment": "Index on published flag and time for sorting"
|
||||
},
|
||||
{
|
||||
"name": "idx_invitation_code_source",
|
||||
"columns": ["source", "code_type", "status"],
|
||||
"type": "index",
|
||||
"comment": "Index on source and type for analytics"
|
||||
}
|
||||
],
|
||||
"relations": {
|
||||
"owner": {
|
||||
"type": "hasOne",
|
||||
"model": "__yao.user",
|
||||
"key": "owner_id",
|
||||
"foreign": "user_id"
|
||||
},
|
||||
"user": {
|
||||
"type": "hasOne",
|
||||
"model": "__yao.user",
|
||||
"key": "used_by",
|
||||
"foreign": "user_id"
|
||||
}
|
||||
},
|
||||
"values": [],
|
||||
"option": { "timestamps": true, "soft_deletes": true, "permission": true }
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue