// Package admin implements ops-console use cases (ECR-006). package admin import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home" ) // Service is ops-admin application layer. type Service struct { Repo *repository.AdminRepo Reports *repository.ReportRepo Home *homesvc.Service Scales *repository.ScaleRepo } // BootstrapConfig seeds the first admin when table is empty. type BootstrapConfig struct { Username string Password string } // EnsureBootstrap creates the first admin from config when needed. func (s *Service) EnsureBootstrap(ctx context.Context, cfg BootstrapConfig) error { if cfg.Username == "" || cfg.Password == "" { return nil } n, err := s.Repo.CountAccounts(ctx) if err != nil || n > 0 { return err } hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bcrypt.DefaultCost) if err != nil { return err } _, err = s.Repo.CreateAccount(ctx, cfg.Username, string(hash)) return err } // LoginResult is returned after successful login. type LoginResult struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` Admin AdminMe `json:"admin"` } var ( ErrBadCredentials = errString("invalid credentials") ErrInvalidPlan = errString("invalid plan") ErrUserNotFound = errString("user not found") ) type errString string func (e errString) Error() string { return string(e) } // Login verifies password and issues a session token. func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) { acc, err := s.Repo.FindByUsername(ctx, username) if err != nil { return nil, err } if acc == nil || acc.Status != "active" { return nil, ErrBadCredentials } if bcrypt.CompareHashAndPassword([]byte(acc.PasswordHash), []byte(password)) != nil { return nil, ErrBadCredentials } token, err := newToken() if err != nil { return nil, err } exp := time.Now().UTC().Add(12 * time.Hour) 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: *me, }, nil } // ResolveAdminID implements middleware.AdminSessionResolver. func (s *Service) ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error) { return s.Repo.ResolveSession(ctx, token) } // Logout deletes the session for token. func (s *Service) Logout(ctx context.Context, token string) error { if token == "" { return nil } return s.Repo.DeleteSession(ctx, token) } // 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) } // DashboardStats exposes ops overview. func (s *Service) DashboardStats(ctx context.Context) (*repository.DashboardStats, error) { return s.Repo.GetDashboardStats(ctx) } // UserDetail is admin view of one user. type UserDetail struct { ID uuid.UUID `json:"id"` Status string `json:"status"` Phone *string `json:"phone,omitempty"` Nickname *string `json:"nickname,omitempty"` AskPaidQuotaLeft int `json:"ask_paid_quota_left"` CreatedAt time.Time `json:"created_at"` Profiles []repository.ProfileBrief `json:"profiles"` Reports []repository.ReportBrief `json:"reports"` Membership *repository.MembershipRow `json:"membership"` Orders []repository.OrderListItem `json:"recent_orders"` } // GetUser loads user detail for admin. func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) { phone, nickname, paidLeft, status, createdAt, err := s.Repo.GetUserAccount(ctx, userID) if err != nil { ok, e2 := s.Repo.UserExists(ctx, userID) if e2 != nil { return nil, e2 } if !ok { return nil, ErrUserNotFound } return nil, err } profiles, err := s.Repo.ListProfilesForUser(ctx, userID) if err != nil { return nil, err } reports, err := s.Repo.ListReportsForUser(ctx, userID, 20) if err != nil { return nil, err } mem, err := s.Reports.GetMembership(ctx, userID) if err != nil { return nil, err } orders, err := s.Repo.ListOrders(ctx, repository.OrderListFilter{ UserID: &userID, Limit: 10, Offset: 0, }) if err != nil { return nil, err } return &UserDetail{ ID: userID, Status: status, Phone: phone, Nickname: nickname, AskPaidQuotaLeft: paidLeft, CreatedAt: createdAt, Profiles: profiles, Reports: reports, Membership: mem, Orders: orders, }, nil } // GrantInput for membership grant. type GrantInput struct { Plan string `json:"plan"` } // GrantMembership extends membership and writes audit. func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error { days, err := s.PlanDurationDays(ctx, plan) if err != nil { return err } ok, err := s.Repo.UserExists(ctx, userID) if err != nil { return err } if !ok { return ErrUserNotFound } meta, _ := json.Marshal(map[string]any{"plan": plan, "days": days}) return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta) } // GrantAskQuotaInput adds paid ask replies. type GrantAskQuotaInput struct { Delta int `json:"delta"` } 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 delta <= 0 || delta > 1000 { return 0, ErrInvalidAskDelta } ok, err := s.Repo.UserExists(ctx, userID) if err != nil { return 0, err } if !ok { return 0, ErrUserNotFound } meta, _ := json.Marshal(map[string]any{"delta": delta}) return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, meta) } // ListOrders lists commerce orders with Ops-D filters. func (s *Service) ListOrders(ctx context.Context, f repository.OrderListFilter) ([]repository.OrderListItem, error) { return s.Repo.ListOrders(ctx, f) } // ListPlanPrices returns membership catalog display prices. func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, error) { return s.Repo.ListPlanPrices(ctx) } // UpsertPlanPrices updates display prices (not historical order amounts). // Caller must enforce admin.membership.plans.write. func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error { if len(items) == 0 { return ErrInvalidPlan } meta, _ := json.Marshal(map[string]any{"count": len(items)}) return s.Repo.UpsertPlanPricesWithAudit(ctx, adminID, items, meta) } // ListAuditLogs lists audit entries. func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repository.AuditListItem, error) { return s.Repo.ListAuditLogs(ctx, limit, offset) } func planDays(plan string) (int, error) { return planDaysFallback(plan) } func newToken() (string, error) { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return "", err } return "adm_" + hex.EncodeToString(b), nil } // OrderFilterFromQuery builds repository filter from HTTP query strings. func OrderFilterFromQuery(userID, status, kind, from, to string, limit, offset int) repository.OrderListFilter { f := repository.OrderListFilter{ Status: status, Kind: kind, Limit: limit, Offset: offset, } if userID != "" { if id, err := uuid.Parse(userID); err == nil { f.UserID = &id } } if from != "" { if t, err := time.Parse("2006-01-02", from); err == nil { f.From = &t } } if to != "" { if t, err := time.Parse("2006-01-02", to); err == nil { end := t.Add(24 * time.Hour) f.To = &end } } return f }