// Package admin implements ops-console use cases (ECR-006). package admin import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) // Service is ops-admin application layer. type Service struct { Repo *repository.AdminRepo Reports *repository.ReportRepo } // 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"` } // AdminMe is the public admin profile. type AdminMe struct { ID uuid.UUID `json:"id"` Username string `json:"username"` } 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 } return &LoginResult{ Token: token, ExpiresAt: exp, Admin: AdminMe{ID: acc.ID, Username: acc.Username}, }, 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) } // 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}, 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) } // UserDetail is admin view of one user. type UserDetail struct { ID uuid.UUID `json:"id"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` Profiles []repository.ProfileBrief `json:"profiles"` 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) { ok, err := s.Repo.UserExists(ctx, userID) if err != nil { return nil, err } if !ok { return nil, ErrUserNotFound } users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0) if err != nil || len(users) == 0 { return nil, ErrUserNotFound } profiles, err := s.Repo.ListProfilesForUser(ctx, userID) 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, &userID, 10, 0) if err != nil { return nil, err } return &UserDetail{ ID: users[0].ID, Status: users[0].Status, CreatedAt: users[0].CreatedAt, Profiles: profiles, 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 := planDays(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) } // ListOrders lists commerce orders. func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) { return s.Repo.ListOrders(ctx, nil, limit, offset) } // 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) { switch plan { case "month": return 31, nil case "quarter": return 92, nil case "year": return 366, nil default: return 0, ErrInvalidPlan } } func newToken() (string, error) { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return "", err } return "adm_" + hex.EncodeToString(b), nil }