package app import ( "context" "encoding/json" "errors" "time" "github.com/jackc/pgx/v5" "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auth" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/domain" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type WorkspaceService struct { repo repository.WorkspaceRepository templates repository.TemplateRepository quota repository.QuotaRepository aiPointUsage repository.AIPointUsageRepository cache sharedcache.Cache cacheGroup singleflight.Group } func NewWorkspaceService( repo repository.WorkspaceRepository, templates repository.TemplateRepository, quota repository.QuotaRepository, aiPointUsage repository.AIPointUsageRepository, ) *WorkspaceService { return &WorkspaceService{ repo: repo, templates: templates, quota: quota, aiPointUsage: aiPointUsage, } } func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService { s.cache = c return s } func (s *WorkspaceService) Overview(ctx context.Context) (*domain.WorkspaceOverview, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceOverviewCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.WorkspaceOverview, error) { articleCount, err := s.repo.CountArticlesByTenant(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") } publishedCount, err := s.repo.CountPublishedArticlesByTenant(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to count published articles") } brandCount, err := s.repo.CountBrandsByTenant(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to count brands") } return &domain.WorkspaceOverview{ ArticleCount: articleCount, PublishedCount: publishedCount, BrandCount: brandCount, }, nil }) } func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentArticle, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceRecentArticlesCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]domain.RecentArticle, error) { rows, err := s.repo.GetRecentArticles(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to fetch recent articles") } articles := make([]domain.RecentArticle, 0, len(rows)) for _, row := range rows { articles = append(articles, domain.RecentArticle{ ID: row.ID, GenerateStatus: row.GenerateStatus, PublishStatus: row.PublishStatus, SourceType: row.SourceType, GenerationMode: row.GenerationMode, CreatedAt: row.CreatedAt, Title: row.Title, WordCount: row.WordCount, SourceLabel: row.SourceLabel, TemplateName: row.TemplateName, }) } return articles, nil }) } func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) { status, err := s.quota.GetArticleQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC()) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return &domain.QuotaSummary{Balance: 0}, nil } return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance") } aiStatus, err := s.quota.GetAIQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC()) if err != nil { if errors.Is(err, pgx.ErrNoRows) { aiStatus = &repository.AIQuotaStatus{} } else { return nil, response.ErrInternal(50010, "query_failed", "failed to get ai points balance") } } return &domain.QuotaSummary{ PlanCode: status.PlanCode, PlanName: status.PlanName, TotalQuota: status.Total, UsedQuota: status.Used, Balance: status.Balance, ResetAt: status.ResetAt, AIPointsTotal: aiStatus.Total, AIPointsUsed: aiStatus.Used, AIPointsBalance: aiStatus.Balance, AIPointBaseChars: aiStatus.BaseChars, AIPointsResetAt: aiStatus.ResetAt, }, nil }) } func (s *WorkspaceService) AIPointUsageLogs(ctx context.Context, page, pageSize int) (*domain.AIPointUsageListResponse, error) { actor := auth.MustActor(ctx) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize rows, err := s.aiPointUsage.List(ctx, repository.AIPointUsageListParams{ TenantID: actor.TenantID, Limit: pageSize, Offset: offset, }) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list ai point usage") } total, err := s.aiPointUsage.Count(ctx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to count ai point usage") } items := make([]domain.AIPointUsageLog, 0, len(rows)) for _, row := range rows { items = append(items, domain.AIPointUsageLog{ ID: row.ID, UsageType: row.UsageType, ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResourceUID: row.ResourceUID, RequestChars: row.RequestChars, BaseChars: row.BaseChars, Points: row.Points, Status: row.Status, Model: row.Model, ErrorMessage: row.ErrorMessage, CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, }) } return &domain.AIPointUsageListResponse{ Items: items, Total: total, Page: page, PageSize: pageSize, }, nil } type TemplateCard struct { ID int64 `json:"id"` Scope string `json:"scope"` TemplateKey string `json:"template_key"` TemplateName string `json:"template_name"` OriginType string `json:"origin_type"` CardConfigJSON map[string]interface{} `json:"card_config"` PromptVisibility string `json:"prompt_visibility"` } func (s *WorkspaceService) TemplateCards(ctx context.Context) ([]TemplateCard, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceTemplateCardsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]TemplateCard, error) { rows, err := s.templates.ListTemplates(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to fetch templates") } cards := make([]TemplateCard, 0, len(rows)) for _, row := range rows { card := TemplateCard{ ID: row.ID, Scope: row.Scope, TemplateKey: row.TemplateKey, TemplateName: row.TemplateName, OriginType: row.OriginType, PromptVisibility: effectivePromptVisibility(&row, actor.TenantID), } _ = json.Unmarshal(row.CardConfigJSON, &card.CardConfigJSON) cards = append(cards, card) } return cards, nil }) } func (s *WorkspaceService) KolCards(ctx context.Context) ([]domain.KolWorkspaceCard, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceKolCardsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]domain.KolWorkspaceCard, error) { rows, err := s.repo.ListKolCards(loadCtx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to fetch KOL cards") } cards := make([]domain.KolWorkspaceCard, 0, len(rows)) for _, row := range rows { cards = append(cards, domain.KolWorkspaceCard{ SubscriptionPromptID: row.SubscriptionPromptID, GrantedAt: row.GrantedAt, UpdatedAt: row.UpdatedAt, PromptName: row.PromptName, PlatformHint: row.PlatformHint, PackageName: row.PackageName, PackageCover: row.PackageCover, KolDisplayName: row.KolDisplayName, }) } return cards, nil }) }