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 supportedPlatformCount int cache sharedcache.Cache cacheGroup singleflight.Group } func NewWorkspaceService( repo repository.WorkspaceRepository, templates repository.TemplateRepository, quota repository.QuotaRepository, ) *WorkspaceService { return &WorkspaceService{ repo: repo, templates: templates, quota: quota, supportedPlatformCount: 5, } } 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, SupportedPlatformCount: s.supportedPlatformCount, }, 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") } return &domain.QuotaSummary{ PlanCode: status.PlanCode, PlanName: status.PlanName, TotalQuota: status.Total, UsedQuota: status.Used, Balance: status.Balance, ResetAt: status.ResetAt, }, 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 }) }