package store import ( "context" "encoding/json" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ) type ClonedVoice struct { ID string `json:"id"` GatewayUserID string `json:"gatewayUserId,omitempty"` UserID string `json:"userId"` GatewayTenantID string `json:"gatewayTenantId,omitempty"` TenantID string `json:"tenantId,omitempty"` TenantKey string `json:"tenantKey,omitempty"` Provider string `json:"provider"` PlatformID string `json:"platformId,omitempty"` PlatformName string `json:"platformName,omitempty"` PlatformModelID string `json:"platformModelId,omitempty"` Model string `json:"model,omitempty"` PreviewModel string `json:"previewModel,omitempty"` VoiceID string `json:"voiceId"` DisplayName string `json:"displayName,omitempty"` DemoAudioURL string `json:"demoAudioUrl,omitempty"` Status string `json:"status"` ExpiresAt string `json:"expiresAt,omitempty"` LastUsedAt string `json:"lastUsedAt,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type ClonedVoiceInput struct { GatewayUserID string UserID string GatewayTenantID string TenantID string TenantKey string Provider string PlatformID string PlatformModelID string SourceTaskID string SourceAttemptID string Model string PreviewModel string VoiceID string DisplayName string DemoAudioURL string Status string ExpiresAt *time.Time Metadata map[string]any } const clonedVoiceColumns = ` v.id::text, COALESCE(v.gateway_user_id::text, ''), v.user_id, COALESCE(v.gateway_tenant_id::text, ''), COALESCE(v.tenant_id, ''), COALESCE(v.tenant_key, ''), v.provider, COALESCE(v.platform_id::text, ''), COALESCE(p.name, ''), COALESCE(v.platform_model_id::text, ''), COALESCE(v.model, ''), COALESCE(v.preview_model, ''), v.voice_id, COALESCE(v.display_name, ''), COALESCE(v.demo_audio_url, ''), v.status, COALESCE(v.expires_at::text, ''), COALESCE(v.last_used_at::text, ''), COALESCE(v.metadata, '{}'::jsonb), v.created_at, v.updated_at` func (s *Store) UpsertClonedVoice(ctx context.Context, input ClonedVoiceInput) (ClonedVoice, error) { metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) status := strings.TrimSpace(input.Status) if status == "" { status = "active" } return scanClonedVoice(s.pool.QueryRow(ctx, ` WITH upsert AS ( INSERT INTO gateway_cloned_voices ( gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key, provider, platform_id, platform_model_id, source_task_id, source_attempt_id, model, preview_model, voice_id, display_name, demo_audio_url, status, expires_at, metadata ) VALUES ( NULLIF($1, '')::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''), $6, NULLIF($7, '')::uuid, NULLIF($8, '')::uuid, NULLIF($9, '')::uuid, NULLIF($10, '')::uuid, $11, $12, $13, $14, $15, $16, $17, $18::jsonb ) ON CONFLICT (platform_id, voice_id) WHERE platform_id IS NOT NULL AND voice_id <> '' DO UPDATE SET gateway_user_id = EXCLUDED.gateway_user_id, user_id = EXCLUDED.user_id, gateway_tenant_id = EXCLUDED.gateway_tenant_id, tenant_id = EXCLUDED.tenant_id, tenant_key = EXCLUDED.tenant_key, provider = EXCLUDED.provider, platform_model_id = EXCLUDED.platform_model_id, source_task_id = EXCLUDED.source_task_id, source_attempt_id = EXCLUDED.source_attempt_id, model = EXCLUDED.model, preview_model = EXCLUDED.preview_model, display_name = EXCLUDED.display_name, demo_audio_url = EXCLUDED.demo_audio_url, status = EXCLUDED.status, expires_at = EXCLUDED.expires_at, metadata = gateway_cloned_voices.metadata || EXCLUDED.metadata, updated_at = now() RETURNING * ) SELECT `+clonedVoiceColumns+` FROM upsert v LEFT JOIN integration_platforms p ON p.id = v.platform_id`, input.GatewayUserID, input.UserID, input.GatewayTenantID, input.TenantID, input.TenantKey, input.Provider, input.PlatformID, input.PlatformModelID, input.SourceTaskID, input.SourceAttemptID, input.Model, input.PreviewModel, input.VoiceID, input.DisplayName, input.DemoAudioURL, status, input.ExpiresAt, string(metadata), )) } func (s *Store) ListClonedVoices(ctx context.Context, user *auth.User) ([]ClonedVoice, error) { gatewayUserID, userID := clonedVoiceUserKeys(user) rows, err := s.pool.Query(ctx, ` SELECT `+clonedVoiceColumns+` FROM gateway_cloned_voices v LEFT JOIN integration_platforms p ON p.id = v.platform_id WHERE ( ( NULLIF($1, '')::uuid IS NOT NULL AND v.gateway_user_id = NULLIF($1, '')::uuid ) OR ( NULLIF($2, '') IS NOT NULL AND v.user_id = $2 ) ) AND v.status <> 'deleted' ORDER BY v.created_at DESC`, gatewayUserID, userID) if err != nil { return nil, err } defer rows.Close() items := make([]ClonedVoice, 0) for rows.Next() { item, err := scanClonedVoice(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Store) FindClonedVoiceForUser(ctx context.Context, user *auth.User, clonedVoiceID string, voiceID string) (ClonedVoice, bool, error) { gatewayUserID, userID := clonedVoiceUserKeys(user) clonedVoiceID = strings.TrimSpace(clonedVoiceID) voiceID = strings.TrimSpace(voiceID) if clonedVoiceID == "" && voiceID == "" { return ClonedVoice{}, false, nil } item, err := scanClonedVoice(s.pool.QueryRow(ctx, ` SELECT `+clonedVoiceColumns+` FROM gateway_cloned_voices v LEFT JOIN integration_platforms p ON p.id = v.platform_id WHERE ( ( NULLIF($1, '')::uuid IS NOT NULL AND v.gateway_user_id = NULLIF($1, '')::uuid ) OR ( NULLIF($2, '') IS NOT NULL AND v.user_id = $2 ) ) AND ( (NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid) OR (NULLIF($4, '') IS NOT NULL AND v.voice_id = $4) ) AND v.status NOT IN ('deleted', 'failed') ORDER BY CASE WHEN NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid THEN 0 ELSE 1 END, v.created_at DESC LIMIT 1`, gatewayUserID, userID, clonedVoiceID, voiceID)) if err != nil { if IsNotFound(err) { return ClonedVoice{}, false, nil } return ClonedVoice{}, false, err } return item, true, nil } func (s *Store) DeleteClonedVoiceForUser(ctx context.Context, user *auth.User, clonedVoiceID string, voiceID string) (ClonedVoice, bool, error) { gatewayUserID, userID := clonedVoiceUserKeys(user) clonedVoiceID = strings.TrimSpace(clonedVoiceID) voiceID = strings.TrimSpace(voiceID) if clonedVoiceID == "" && voiceID == "" { return ClonedVoice{}, false, nil } item, err := scanClonedVoice(s.pool.QueryRow(ctx, ` WITH updated AS ( UPDATE gateway_cloned_voices v SET status = 'deleted', updated_at = now() WHERE ( ( NULLIF($1, '')::uuid IS NOT NULL AND v.gateway_user_id = NULLIF($1, '')::uuid ) OR ( NULLIF($2, '') IS NOT NULL AND v.user_id = $2 ) ) AND ( (NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid) OR (NULLIF($4, '') IS NOT NULL AND v.voice_id = $4) ) AND v.status <> 'deleted' RETURNING * ) SELECT `+clonedVoiceColumns+` FROM updated v LEFT JOIN integration_platforms p ON p.id = v.platform_id`, gatewayUserID, userID, clonedVoiceID, voiceID)) if err != nil { if IsNotFound(err) { return ClonedVoice{}, false, nil } return ClonedVoice{}, false, err } return item, true, nil } func (s *Store) TouchClonedVoiceUsage(ctx context.Context, clonedVoiceID string) error { if strings.TrimSpace(clonedVoiceID) == "" { return nil } _, err := s.pool.Exec(ctx, ` UPDATE gateway_cloned_voices SET last_used_at = now(), expires_at = now() + interval '7 days', updated_at = now() WHERE id = $1::uuid`, clonedVoiceID) return err } func (s *Store) MarkClonedVoiceStatus(ctx context.Context, clonedVoiceID string, status string) error { if strings.TrimSpace(clonedVoiceID) == "" || strings.TrimSpace(status) == "" { return nil } _, err := s.pool.Exec(ctx, ` UPDATE gateway_cloned_voices SET status = $2, updated_at = now() WHERE id = $1::uuid`, clonedVoiceID, status) return err } func clonedVoiceUserKeys(user *auth.User) (string, string) { if user == nil { return "", "" } gatewayUserID := strings.TrimSpace(user.GatewayUserID) if gatewayUserID == "" && user.Source == "gateway" { gatewayUserID = strings.TrimSpace(user.ID) } userID := strings.TrimSpace(user.ID) return gatewayUserID, userID } type clonedVoiceScanner interface { Scan(dest ...any) error } func scanClonedVoice(scanner clonedVoiceScanner) (ClonedVoice, error) { var item ClonedVoice var metadata []byte if err := scanner.Scan( &item.ID, &item.GatewayUserID, &item.UserID, &item.GatewayTenantID, &item.TenantID, &item.TenantKey, &item.Provider, &item.PlatformID, &item.PlatformName, &item.PlatformModelID, &item.Model, &item.PreviewModel, &item.VoiceID, &item.DisplayName, &item.DemoAudioURL, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &metadata, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return ClonedVoice{}, err } item.Metadata = decodeObject(metadata) return item, nil }