merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrSystemPromptNotFound = errString("system prompt not found")
|
||||
var ErrKnowledgeSourceNotFound = errString("knowledge source not found")
|
||||
|
||||
// ListSystemPrompts returns SystemPrompt catalog.
|
||||
func (s *Service) ListSystemPrompts(ctx context.Context) ([]repository.SystemPromptRow, error) {
|
||||
items, err := s.Repo.ListSystemPrompts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.SystemPromptRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetSystemPrompt loads one prompt.
|
||||
func (s *Service) GetSystemPrompt(ctx context.Context, id uuid.UUID) (*repository.SystemPromptRow, error) {
|
||||
row, err := s.Repo.GetSystemPrompt(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSystemPromptNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListKnowledgeSources returns KnowledgeSource catalog.
|
||||
func (s *Service) ListKnowledgeSources(ctx context.Context) ([]repository.KnowledgeSourceRow, error) {
|
||||
items, err := s.Repo.ListKnowledgeSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.KnowledgeSourceRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeSource loads one source.
|
||||
func (s *Service) GetKnowledgeSource(ctx context.Context, id uuid.UUID) (*repository.KnowledgeSourceRow, error) {
|
||||
row, err := s.Repo.GetKnowledgeSource(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrKnowledgeSourceNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// AskSessionDetail is AskSessionView plus messages.
|
||||
type AskSessionDetail struct {
|
||||
repository.AskSessionView
|
||||
Messages []repository.AskMessageView `json:"messages"`
|
||||
}
|
||||
|
||||
var ErrAskThreadNotFound = errString("ask thread not found")
|
||||
|
||||
// ListAskSessions lists AskSessionView rows.
|
||||
func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]repository.AskSessionView, error) {
|
||||
items, err := s.Repo.ListAskSessions(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.AskSessionView{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAskSessionDetail loads meta + messages.
|
||||
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
|
||||
view, err := s.Repo.GetAskSession(ctx, threadID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAskThreadNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs, err := s.Repo.ListAskMessagesForAdmin(ctx, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []repository.AskMessageView{}
|
||||
}
|
||||
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrBlockPolicyNotFound = errString("block policy not found")
|
||||
|
||||
// ListBlockPolicies returns catalog.
|
||||
func (s *Service) ListBlockPolicies(ctx context.Context) ([]repository.BlockPolicyRow, error) {
|
||||
items, err := s.Repo.ListBlockPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.BlockPolicyRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetBlockPolicy loads one.
|
||||
func (s *Service) GetBlockPolicy(ctx context.Context, id uuid.UUID) (*repository.BlockPolicyRow, error) {
|
||||
row, err := s.Repo.GetBlockPolicy(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBlockPolicyNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrBannerNotFound = errString("banner not found")
|
||||
var ErrFeedSlotNotFound = errString("feed slot not found")
|
||||
|
||||
// ListBanners returns OpsCMS Banner catalog.
|
||||
func (s *Service) ListBanners(ctx context.Context) ([]repository.BannerRow, error) {
|
||||
items, err := s.Repo.ListBanners(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.BannerRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetBanner loads one banner.
|
||||
func (s *Service) GetBanner(ctx context.Context, id uuid.UUID) (*repository.BannerRow, error) {
|
||||
row, err := s.Repo.GetBanner(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBannerNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListFeedSlots returns OpsCMS FeedSlot catalog.
|
||||
func (s *Service) ListFeedSlots(ctx context.Context) ([]repository.FeedSlotRow, error) {
|
||||
items, err := s.Repo.ListFeedSlots(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.FeedSlotRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetFeedSlot loads one feed slot.
|
||||
func (s *Service) GetFeedSlot(ctx context.Context, id uuid.UUID) (*repository.FeedSlotRow, error) {
|
||||
row, err := s.Repo.GetFeedSlot(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFeedSlotNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -45,6 +45,18 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
|
||||
return s.Scales.ListAllAdmin(ctx)
|
||||
}
|
||||
|
||||
// GetScaleAdmin loads one scale for explore read projection.
|
||||
func (s *Service) GetScaleAdmin(ctx context.Context, id uuid.UUID) (*repository.ScaleAdminItem, error) {
|
||||
if s.Scales == nil {
|
||||
return nil, errors.New("scales unavailable")
|
||||
}
|
||||
row, err := s.Scales.GetAdmin(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrScaleNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrFilterRuleNotFound = errString("filter rule not found")
|
||||
|
||||
// ListFilterRules returns FilterRule catalog.
|
||||
func (s *Service) ListFilterRules(ctx context.Context) ([]repository.FilterRuleRow, error) {
|
||||
items, err := s.Repo.ListFilterRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.FilterRuleRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetFilterRule loads one rule.
|
||||
func (s *Service) GetFilterRule(ctx context.Context, id uuid.UUID) (*repository.FilterRuleRow, error) {
|
||||
row, err := s.Repo.GetFilterRule(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFilterRuleNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// EvaluateContent runs read-only filter preview.
|
||||
func (s *Service) EvaluateContent(ctx context.Context, text string) ([]repository.FilterMatch, error) {
|
||||
return s.Repo.EvaluateFilterRules(ctx, text)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrCrisisPolicyNotFound = errString("crisis policy not found")
|
||||
|
||||
// ListCrisisPolicies returns CrisisPolicy catalog.
|
||||
func (s *Service) ListCrisisPolicies(ctx context.Context) ([]repository.CrisisPolicyRow, error) {
|
||||
items, err := s.Repo.ListCrisisPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.CrisisPolicyRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetCrisisPolicy loads one policy.
|
||||
func (s *Service) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*repository.CrisisPolicyRow, error) {
|
||||
row, err := s.Repo.GetCrisisPolicy(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCrisisPolicyNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// EvaluateCrisis runs read-only policy preview.
|
||||
func (s *Service) EvaluateCrisis(ctx context.Context, text string) ([]repository.CrisisMatch, error) {
|
||||
return s.Repo.EvaluateCrisisPolicies(ctx, text)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrCrisisEventNotFound = errString("crisis event not found")
|
||||
|
||||
// ListCrisisEvents returns catalog.
|
||||
func (s *Service) ListCrisisEvents(ctx context.Context) ([]repository.CrisisEventRow, error) {
|
||||
items, err := s.Repo.ListCrisisEvents(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.CrisisEventRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetCrisisEvent loads one.
|
||||
func (s *Service) GetCrisisEvent(ctx context.Context, id uuid.UUID) (*repository.CrisisEventRow, error) {
|
||||
row, err := s.Repo.GetCrisisEvent(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCrisisEventNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// EntitlementFlags summarizes effective rights.
|
||||
type EntitlementFlags struct {
|
||||
ReportDetailViaMembership bool `json:"report_detail_via_membership"`
|
||||
DeepAccessCount int `json:"deep_access_count"`
|
||||
}
|
||||
|
||||
// UserEntitlement is the CommerceEntitlement ops read model.
|
||||
type UserEntitlement struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Membership *repository.MembershipRow `json:"membership"`
|
||||
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
|
||||
Flags EntitlementFlags `json:"flags"`
|
||||
DeepAccesses []repository.DeepAccessBrief `json:"deep_accesses"`
|
||||
}
|
||||
|
||||
// GetUserEntitlement aggregates membership + deep access + quotas.
|
||||
func (s *Service) GetUserEntitlement(ctx context.Context, userID uuid.UUID) (*UserEntitlement, error) {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
_, _, paidLeft, _, _, err := s.Repo.GetUserAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mem, err := s.Reports.GetMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := s.Repo.CountDeepAccessForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.Repo.ListDeepAccessForUser(ctx, userID, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.DeepAccessBrief{}
|
||||
}
|
||||
return &UserEntitlement{
|
||||
UserID: userID,
|
||||
Membership: mem,
|
||||
AskPaidQuotaLeft: paidLeft,
|
||||
Flags: EntitlementFlags{
|
||||
ReportDetailViaMembership: mem != nil && mem.Active,
|
||||
DeepAccessCount: count,
|
||||
},
|
||||
DeepAccesses: items,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrFunnelDefinitionNotFound = errString("funnel definition not found")
|
||||
|
||||
// ListFunnelDefinitions returns catalog.
|
||||
func (s *Service) ListFunnelDefinitions(ctx context.Context) ([]repository.FunnelDefinitionRow, error) {
|
||||
items, err := s.Repo.ListFunnelDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.FunnelDefinitionRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetFunnelDefinition loads one.
|
||||
func (s *Service) GetFunnelDefinition(ctx context.Context, id uuid.UUID) (*repository.FunnelDefinitionRow, error) {
|
||||
row, err := s.Repo.GetFunnelDefinition(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFunnelDefinitionNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrHandoffCaseNotFound = errString("handoff case not found")
|
||||
|
||||
// ListHandoffCases returns catalog.
|
||||
func (s *Service) ListHandoffCases(ctx context.Context) ([]repository.HandoffCaseRow, error) {
|
||||
items, err := s.Repo.ListHandoffCases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.HandoffCaseRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetHandoffCase loads one.
|
||||
func (s *Service) GetHandoffCase(ctx context.Context, id uuid.UUID) (*repository.HandoffCaseRow, error) {
|
||||
row, err := s.Repo.GetHandoffCase(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrHandoffCaseNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrImageCardDeckNotFound = errString("image card deck not found")
|
||||
|
||||
// ListImageCardDecks returns catalog.
|
||||
func (s *Service) ListImageCardDecks(ctx context.Context) ([]repository.ImageCardDeckRow, error) {
|
||||
items, err := s.Repo.ListImageCardDecks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.ImageCardDeckRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetImageCardDeck loads one.
|
||||
func (s *Service) GetImageCardDeck(ctx context.Context, id uuid.UUID) (*repository.ImageCardDeckRow, error) {
|
||||
row, err := s.Repo.GetImageCardDeck(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrImageCardDeckNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// PsychTag is a stable label derived from report types (not NLP).
|
||||
type PsychTag struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// BehaviorSnapshot is a thin ops read of recent activity.
|
||||
type BehaviorSnapshot struct {
|
||||
Events []repository.BehaviorEventBrief `json:"events"`
|
||||
AskThreadCount int `json:"ask_thread_count"`
|
||||
}
|
||||
|
||||
// UserInsight is the UserIntelligence admin read model.
|
||||
type UserInsight struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfilesCount int `json:"profiles_count"`
|
||||
ReportsByType []repository.ReportTypeCount `json:"reports_by_type"`
|
||||
RecentReports []repository.ReportBrief `json:"recent_reports"`
|
||||
Tags []PsychTag `json:"tags"`
|
||||
Behavior BehaviorSnapshot `json:"behavior"`
|
||||
}
|
||||
|
||||
var reportTypeLabels = map[string]string{
|
||||
"portrait": "愈心解码",
|
||||
"star": "星座",
|
||||
"rhythm": "节律",
|
||||
"relation": "关系",
|
||||
"synastry": "合盘",
|
||||
"image_card": "意象卡",
|
||||
}
|
||||
|
||||
// GetUserInsight aggregates read-only ops insight for one user.
|
||||
func (s *Service) GetUserInsight(ctx context.Context, userID uuid.UUID) (*UserInsight, error) {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
pc, err := s.Repo.CountProfilesForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byType, err := s.Repo.CountReportsByType(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if byType == nil {
|
||||
byType = []repository.ReportTypeCount{}
|
||||
}
|
||||
reports, err := s.Repo.ListReportsForUser(ctx, userID, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reports == nil {
|
||||
reports = []repository.ReportBrief{}
|
||||
}
|
||||
events, err := s.Repo.ListRecentEventsForUser(ctx, userID, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if events == nil {
|
||||
events = []repository.BehaviorEventBrief{}
|
||||
}
|
||||
askN, err := s.Repo.CountAskThreadsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserInsight{
|
||||
UserID: userID,
|
||||
ProfilesCount: pc,
|
||||
ReportsByType: byType,
|
||||
RecentReports: reports,
|
||||
Tags: tagsFromReportTypes(byType),
|
||||
Behavior: BehaviorSnapshot{
|
||||
Events: events,
|
||||
AskThreadCount: askN,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tagsFromReportTypes(counts []repository.ReportTypeCount) []PsychTag {
|
||||
out := make([]PsychTag, 0, len(counts))
|
||||
for _, c := range counts {
|
||||
if c.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
label := reportTypeLabels[c.Type]
|
||||
if label == "" {
|
||||
label = c.Type
|
||||
}
|
||||
out = append(out, PsychTag{Code: c.Type, Label: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrInterventionOutcomeNotFound = errString("intervention outcome not found")
|
||||
|
||||
// ListInterventionOutcomes returns catalog.
|
||||
func (s *Service) ListInterventionOutcomes(ctx context.Context) ([]repository.InterventionOutcomeRow, error) {
|
||||
items, err := s.Repo.ListInterventionOutcomes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.InterventionOutcomeRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetInterventionOutcome loads one.
|
||||
func (s *Service) GetInterventionOutcome(ctx context.Context, id uuid.UUID) (*repository.InterventionOutcomeRow, error) {
|
||||
row, err := s.Repo.GetInterventionOutcome(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInterventionOutcomeNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrKnowledgeChunkNotFound = errString("knowledge chunk not found")
|
||||
|
||||
// ListKnowledgeChunks returns catalog.
|
||||
func (s *Service) ListKnowledgeChunks(ctx context.Context) ([]repository.KnowledgeChunkRow, error) {
|
||||
items, err := s.Repo.ListKnowledgeChunks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.KnowledgeChunkRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeChunk loads one.
|
||||
func (s *Service) GetKnowledgeChunk(ctx context.Context, id uuid.UUID) (*repository.KnowledgeChunkRow, error) {
|
||||
row, err := s.Repo.GetKnowledgeChunk(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrKnowledgeChunkNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStatusEdge = errString("invalid status transition")
|
||||
ErrReasonRequired = errString("reason required")
|
||||
)
|
||||
|
||||
var allowedStatusEdges = map[string]map[string]struct{}{
|
||||
"active": {"disabled": {}, "banned": {}, "suspended": {}},
|
||||
"disabled": {"active": {}, "banned": {}},
|
||||
"suspended": {"active": {}, "banned": {}, "disabled": {}},
|
||||
"banned": {"active": {}, "disabled": {}},
|
||||
}
|
||||
|
||||
// TransitionUserStatus migrates UserStatus with audit.
|
||||
func (s *Service) TransitionUserStatus(ctx context.Context, adminID, userID uuid.UUID, toStatus, reason string) error {
|
||||
toStatus = strings.TrimSpace(toStatus)
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return ErrReasonRequired
|
||||
}
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
from, err := s.Repo.GetUserStatus(ctx, userID)
|
||||
if err != nil || from == "" {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if from == toStatus {
|
||||
return ErrInvalidStatusEdge
|
||||
}
|
||||
next, okEdge := allowedStatusEdges[from]
|
||||
if !okEdge {
|
||||
return ErrInvalidStatusEdge
|
||||
}
|
||||
if _, okEdge = next[toStatus]; !okEdge {
|
||||
return ErrInvalidStatusEdge
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]string{
|
||||
"from": from, "to": toStatus, "reason": reason,
|
||||
})
|
||||
return s.Repo.TransitionUserStatusWithAudit(ctx, adminID, userID, from, toStatus, reason, meta)
|
||||
}
|
||||
|
||||
// ListStatusTransitions returns recent transitions.
|
||||
func (s *Service) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]repository.AccountTransition, error) {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
items, err := s.Repo.ListStatusTransitions(ctx, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.AccountTransition{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlanNotFound = errString("plan not found")
|
||||
ErrInvalidPlanU = errString("invalid plan update")
|
||||
)
|
||||
|
||||
// ListMembershipPlans returns catalog.
|
||||
func (s *Service) ListMembershipPlans(ctx context.Context) ([]repository.MembershipPlanRow, error) {
|
||||
items, err := s.Repo.ListMembershipPlans(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.MembershipPlanRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetMembershipPlan returns one plan.
|
||||
func (s *Service) GetMembershipPlan(ctx context.Context, code string) (*repository.MembershipPlanRow, error) {
|
||||
p, err := s.Repo.GetMembershipPlan(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, ErrPlanNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateMembershipPlan updates mutable fields.
|
||||
func (s *Service) UpdateMembershipPlan(
|
||||
ctx context.Context, adminID uuid.UUID, code, title string, days, amount int, active bool,
|
||||
) (*repository.MembershipPlanRow, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || days <= 0 || amount < 0 {
|
||||
return nil, ErrInvalidPlanU
|
||||
}
|
||||
if _, err := s.GetMembershipPlan(ctx, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"title": title, "duration_days": days, "amount_cents": amount, "active": active,
|
||||
})
|
||||
if err := s.Repo.UpdateMembershipPlanWithAudit(ctx, adminID, code, title, days, amount, active, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetMembershipPlan(ctx, code)
|
||||
}
|
||||
|
||||
// PlanDurationDays resolves grant length from catalog with hardcoded fallback.
|
||||
func (s *Service) PlanDurationDays(ctx context.Context, plan string) (int, error) {
|
||||
p, err := s.Repo.GetMembershipPlan(ctx, plan)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p != nil && p.Active && p.DurationDays > 0 {
|
||||
return p.DurationDays, nil
|
||||
}
|
||||
return planDaysFallback(plan)
|
||||
}
|
||||
|
||||
func planDaysFallback(plan string) (int, error) {
|
||||
switch plan {
|
||||
case "month":
|
||||
return 31, nil
|
||||
case "quarter":
|
||||
return 92, nil
|
||||
case "year":
|
||||
return 366, nil
|
||||
default:
|
||||
return 0, ErrInvalidPlan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrModerationCaseNotFound = errString("moderation case not found")
|
||||
|
||||
// ListModerationCases returns catalog.
|
||||
func (s *Service) ListModerationCases(ctx context.Context) ([]repository.ModerationCaseRow, error) {
|
||||
items, err := s.Repo.ListModerationCases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.ModerationCaseRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetModerationCase loads one.
|
||||
func (s *Service) GetModerationCase(ctx context.Context, id uuid.UUID) (*repository.ModerationCaseRow, error) {
|
||||
row, err := s.Repo.GetModerationCase(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrModerationCaseNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrPrivacyRequestNotFound = errString("privacy request not found")
|
||||
|
||||
// ListPrivacyRequests returns catalog.
|
||||
func (s *Service) ListPrivacyRequests(ctx context.Context) ([]repository.PrivacyRequestRow, error) {
|
||||
items, err := s.Repo.ListPrivacyRequests(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.PrivacyRequestRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetPrivacyRequest loads one.
|
||||
func (s *Service) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*repository.PrivacyRequestRow, error) {
|
||||
row, err := s.Repo.GetPrivacyRequest(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPrivacyRequestNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadFeedbackRating = errString("rating must be 1-5")
|
||||
ErrBadFeedbackTag = errString("invalid tag")
|
||||
ErrFeedbackNoteLong = errString("note too long")
|
||||
)
|
||||
|
||||
// ListQualityFeedback lists recent QualityFeedback.
|
||||
func (s *Service) ListQualityFeedback(ctx context.Context, limit, offset int) ([]repository.QualityFeedbackRow, error) {
|
||||
items, err := s.Repo.ListQualityFeedback(ctx, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.QualityFeedbackRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CreateQualityFeedback is ops-submitted feedback.
|
||||
func (s *Service) CreateQualityFeedback(
|
||||
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note string,
|
||||
) (*repository.QualityFeedbackRow, error) {
|
||||
var tagPtr, notePtr *string
|
||||
if strings.TrimSpace(tag) != "" {
|
||||
t := strings.TrimSpace(tag)
|
||||
tagPtr = &t
|
||||
}
|
||||
if strings.TrimSpace(note) != "" {
|
||||
n := strings.TrimSpace(note)
|
||||
notePtr = &n
|
||||
}
|
||||
row, err := s.Repo.CreateAdminQualityFeedback(ctx, adminID, threadID, messageID, rating, tagPtr, notePtr)
|
||||
if err == nil {
|
||||
return row, nil
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "rating"):
|
||||
return nil, ErrBadFeedbackRating
|
||||
case strings.Contains(msg, "tag"):
|
||||
return nil, ErrBadFeedbackTag
|
||||
case strings.Contains(msg, "note"):
|
||||
return nil, ErrFeedbackNoteLong
|
||||
case strings.Contains(msg, "thread not found"):
|
||||
return nil, ErrAskThreadNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Permission catalog frozen in ECR-013A Spec.
|
||||
const (
|
||||
PermUsersRead = "admin.users.read"
|
||||
PermMembershipGrant = "admin.users.membership.grant"
|
||||
PermAskQuotaGrant = "admin.users.ask_quota.grant"
|
||||
PermOrdersRead = "admin.orders.read"
|
||||
PermAuditRead = "admin.audit.read"
|
||||
PermAnalyticsRead = "admin.analytics.read"
|
||||
PermContentWrite = "admin.content.write"
|
||||
PermRolesRead = "admin.roles.read"
|
||||
PermRolesWrite = "admin.roles.write"
|
||||
PermUsersStatusWrite = "admin.users.status.write"
|
||||
PermMembershipPlansRead = "admin.membership.plans.read"
|
||||
PermMembershipPlansWrite = "admin.membership.plans.write"
|
||||
PermMembershipCodesRead = "admin.membership.codes.read"
|
||||
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||
PermAskRead = "admin.ask.read"
|
||||
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
||||
PermContentSafetyRead = "admin.content_safety.read"
|
||||
PermAIConfigRead = "admin.ai_config.read"
|
||||
PermCrisisRead = "admin.crisis.read"
|
||||
PermCMSRead = "admin.cms.read"
|
||||
PermPrivacyRead = "admin.privacy.read"
|
||||
PermExploreRead = "admin.explore.read"
|
||||
PermGrowthRead = "admin.growth.read"
|
||||
)
|
||||
|
||||
var knownPermissions = map[string]struct{}{
|
||||
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
|
||||
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
|
||||
}
|
||||
|
||||
var (
|
||||
ErrRoleNotFound = errString("role not found")
|
||||
ErrInvalidPerm = errString("invalid permission code")
|
||||
ErrProtectSystem = errString("system role protected")
|
||||
)
|
||||
|
||||
// AdminMe is the public admin profile (with RBAC).
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// RoleDTO is list/detail payload.
|
||||
type RoleDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
System bool `json:"system"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// HasPermission reports whether admin holds code.
|
||||
func (s *Service) HasPermission(ctx context.Context, adminID uuid.UUID, code string) (bool, error) {
|
||||
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, p := range perms {
|
||||
if p == code {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DenyPermission audits a forbidden attempt.
|
||||
func (s *Service) DenyPermission(ctx context.Context, adminID uuid.UUID, code, path string) {
|
||||
meta, _ := json.Marshal(map[string]string{"permission": code, "path": path})
|
||||
_ = s.Repo.InsertAudit(ctx, adminID, "permission.denied", "permission", code, meta)
|
||||
}
|
||||
|
||||
// Me returns the current admin profile with permissions.
|
||||
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
_, roleName, _ := s.Repo.GetAdminRoleMeta(ctx, adminID)
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: roleName, Permissions: perms}, nil
|
||||
}
|
||||
|
||||
// ListRoles returns roles without permissions.
|
||||
func (s *Service) ListRoles(ctx context.Context) ([]RoleDTO, error) {
|
||||
roles, err := s.Repo.ListAdminRoles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RoleDTO, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
out = append(out, RoleDTO{ID: r.ID, Name: r.Name, System: r.System})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRole returns role + permissions.
|
||||
func (s *Service) GetRole(ctx context.Context, id uuid.UUID) (*RoleDTO, error) {
|
||||
role, err := s.Repo.GetAdminRole(ctx, id)
|
||||
if err != nil || role == nil {
|
||||
return nil, ErrRoleNotFound
|
||||
}
|
||||
perms, err := s.Repo.ListRolePermissions(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
return &RoleDTO{ID: role.ID, Name: role.Name, System: role.System, Permissions: perms}, nil
|
||||
}
|
||||
|
||||
// ReplaceRolePermissions updates permissions with audit.
|
||||
func (s *Service) ReplaceRolePermissions(ctx context.Context, adminID, roleID uuid.UUID, codes []string) error {
|
||||
role, err := s.Repo.GetAdminRole(ctx, roleID)
|
||||
if err != nil || role == nil {
|
||||
return ErrRoleNotFound
|
||||
}
|
||||
for _, c := range codes {
|
||||
if _, ok := knownPermissions[c]; !ok {
|
||||
return ErrInvalidPerm
|
||||
}
|
||||
}
|
||||
if err := s.Repo.ReplaceRolePermissions(ctx, roleID, codes); err != nil {
|
||||
return err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"permissions": codes, "role": role.Name})
|
||||
return s.Repo.InsertAudit(ctx, adminID, "roles.permissions.update", "admin_role", roleID.String(), meta)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadBatchQty = errString("quantity must be 1..100")
|
||||
ErrCodeDisable = errString("code not unused")
|
||||
ErrBatchNotFound = errString("batch not found")
|
||||
)
|
||||
|
||||
// CreateRedemptionBatch generates codes for a membership plan.
|
||||
func (s *Service) CreateRedemptionBatch(
|
||||
ctx context.Context, adminID uuid.UUID, label, planCode string, qty int,
|
||||
) (*repository.RedemptionBatch, []repository.RedemptionCodeRow, error) {
|
||||
label = strings.TrimSpace(label)
|
||||
planCode = strings.TrimSpace(planCode)
|
||||
if label == "" || qty < 1 || qty > 100 {
|
||||
return nil, nil, ErrBadBatchQty
|
||||
}
|
||||
if _, err := s.GetMembershipPlan(ctx, planCode); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
codes := make([]string, 0, qty)
|
||||
seen := map[string]struct{}{}
|
||||
for len(codes) < qty {
|
||||
c, err := newRedemptionCode()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"label": label, "plan": planCode, "quantity": qty})
|
||||
return s.Repo.CreateRedemptionBatchWithCodes(ctx, adminID, label, planCode, codes, meta)
|
||||
}
|
||||
|
||||
// ListRedemptionBatches lists recent batches.
|
||||
func (s *Service) ListRedemptionBatches(ctx context.Context, limit int) ([]repository.RedemptionBatch, error) {
|
||||
items, err := s.Repo.ListRedemptionBatches(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.RedemptionBatch{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListRedemptionCodes lists codes in a batch.
|
||||
func (s *Service) ListRedemptionCodes(ctx context.Context, batchID uuid.UUID) ([]repository.RedemptionCodeRow, error) {
|
||||
ok, err := s.Repo.BatchExists(ctx, batchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrBatchNotFound
|
||||
}
|
||||
items, err := s.Repo.ListRedemptionCodesByBatch(ctx, batchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.RedemptionCodeRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DisableRedemptionCode voids an unused code.
|
||||
func (s *Service) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
|
||||
err := s.Repo.DisableRedemptionCode(ctx, adminID, codeID)
|
||||
if err != nil && err.Error() == "code not unused" {
|
||||
return ErrCodeDisable
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func newRedemptionCode() (string, error) {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "YXG-" + strings.ToUpper(hex.EncodeToString(b)), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrReportTemplateNotFound = errString("report template not found")
|
||||
|
||||
// ListReportTemplates returns catalog.
|
||||
func (s *Service) ListReportTemplates(ctx context.Context) ([]repository.ReportTemplateRow, error) {
|
||||
items, err := s.Repo.ListReportTemplates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.ReportTemplateRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetReportTemplate loads one.
|
||||
func (s *Service) GetReportTemplate(ctx context.Context, id uuid.UUID) (*repository.ReportTemplateRow, error) {
|
||||
row, err := s.Repo.GetReportTemplate(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrReportTemplateNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrRhythmConfigNotFound = errString("rhythm config not found")
|
||||
|
||||
// ListRhythmConfigs returns catalog.
|
||||
func (s *Service) ListRhythmConfigs(ctx context.Context) ([]repository.RhythmConfigRow, error) {
|
||||
items, err := s.Repo.ListRhythmConfigs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.RhythmConfigRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetRhythmConfig loads one.
|
||||
func (s *Service) GetRhythmConfig(ctx context.Context, id uuid.UUID) (*repository.RhythmConfigRow, error) {
|
||||
row, err := s.Repo.GetRhythmConfig(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrRhythmConfigNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrScheduledPublicationNotFound = errString("scheduled publication not found")
|
||||
|
||||
// ListScheduledPublications returns catalog.
|
||||
func (s *Service) ListScheduledPublications(ctx context.Context) ([]repository.ScheduledPublicationRow, error) {
|
||||
items, err := s.Repo.ListScheduledPublications(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.ScheduledPublicationRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetScheduledPublication loads one.
|
||||
func (s *Service) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*repository.ScheduledPublicationRow, error) {
|
||||
row, err := s.Repo.GetScheduledPublication(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrScheduledPublicationNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -54,13 +53,6 @@ type LoginResult struct {
|
||||
Admin AdminMe `json:"admin"`
|
||||
}
|
||||
|
||||
// AdminMe is the public admin profile.
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadCredentials = errString("invalid credentials")
|
||||
ErrInvalidPlan = errString("invalid plan")
|
||||
@@ -91,10 +83,14 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
|
||||
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
me, err := s.Me(ctx, acc.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
|
||||
Admin: *me,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -111,15 +107,6 @@ func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
return s.Repo.DeleteSession(ctx, token)
|
||||
}
|
||||
|
||||
// Me returns the current admin profile.
|
||||
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
|
||||
return s.Repo.ListUsers(ctx, q, limit, offset)
|
||||
@@ -198,10 +185,7 @@ type GrantInput struct {
|
||||
|
||||
// GrantMembership extends membership and writes audit.
|
||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := planDays(plan)
|
||||
days, err := s.PlanDurationDays(ctx, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -225,9 +209,6 @@ var ErrInvalidAskDelta = errString("invalid ask quota delta")
|
||||
|
||||
// GrantAskQuota adds purchased ask quota and audits.
|
||||
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delta <= 0 || delta > 1000 {
|
||||
return 0, ErrInvalidAskDelta
|
||||
}
|
||||
@@ -270,16 +251,7 @@ func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repos
|
||||
}
|
||||
|
||||
func planDays(plan string) (int, error) {
|
||||
switch plan {
|
||||
case "month":
|
||||
return 31, nil
|
||||
case "quarter":
|
||||
return 92, nil
|
||||
case "year":
|
||||
return 366, nil
|
||||
default:
|
||||
return 0, ErrInvalidPlan
|
||||
}
|
||||
return planDaysFallback(plan)
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrStarConfigNotFound = errString("star config not found")
|
||||
|
||||
// ListStarConfigs returns catalog.
|
||||
func (s *Service) ListStarConfigs(ctx context.Context) ([]repository.StarConfigRow, error) {
|
||||
items, err := s.Repo.ListStarConfigs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.StarConfigRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetStarConfig loads one.
|
||||
func (s *Service) GetStarConfig(ctx context.Context, id uuid.UUID) (*repository.StarConfigRow, error) {
|
||||
row, err := s.Repo.GetStarConfig(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrStarConfigNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrToolDefinitionNotFound = errString("tool definition not found")
|
||||
|
||||
// ListToolDefinitions returns catalog.
|
||||
func (s *Service) ListToolDefinitions(ctx context.Context) ([]repository.ToolDefinitionRow, error) {
|
||||
items, err := s.Repo.ListToolDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.ToolDefinitionRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetToolDefinition loads one.
|
||||
func (s *Service) GetToolDefinition(ctx context.Context, id uuid.UUID) (*repository.ToolDefinitionRow, error) {
|
||||
row, err := s.Repo.GetToolDefinition(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrToolDefinitionNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
Reference in New Issue
Block a user