mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
Add richer backend models, handlers, services, and repository queries for admin campaigns, discounts, outreach, organizations, limit requests, and provisioning surfaces.
789 lines
30 KiB
Go
789 lines
30 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// AdminService defines the interface for admin operations
|
|
type AdminService interface {
|
|
// Mailbox admin
|
|
SearchMailboxes(ctx context.Context, search *models.AdminMailboxSearch) (*models.AdminMailboxesResult, *errx.Error)
|
|
|
|
// User Management
|
|
SearchUsers(ctx context.Context, search *models.AdminUserSearch) (*models.AdminUsersResult, *errx.Error)
|
|
GetUserDetail(ctx context.Context, userID uuid.UUID) (*models.AdminUserDetail, *errx.Error)
|
|
GetUserPreview(ctx context.Context, userID uuid.UUID) (*models.AdminUserPreview, *errx.Error)
|
|
BanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, scope models.BanScope, ipAddress, userAgent string) *errx.Error
|
|
UnbanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error
|
|
GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, *errx.Error)
|
|
GetUserCampaigns(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) (*models.AdminCampaignsResult, *errx.Error)
|
|
GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error)
|
|
GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, *errx.Error)
|
|
UpdateUserRateLimits(ctx context.Context, adminID, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Worker Management
|
|
ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, *errx.Error)
|
|
GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, *errx.Error)
|
|
UpdateWorker(ctx context.Context, adminID, workerID uuid.UUID, update *models.AdminUpdateWorker, ipAddress, userAgent string) *errx.Error
|
|
GetWorkerEmails(ctx context.Context, workerID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error)
|
|
GetWorkerStats(ctx context.Context, workerID uuid.UUID) (*models.WorkerStats, *errx.Error)
|
|
ReassignEmails(ctx context.Context, adminID uuid.UUID, emailIDs []uuid.UUID, newWorkerID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Warmup Management
|
|
ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, *errx.Error)
|
|
GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error)
|
|
ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, *errx.Error)
|
|
BlockAccount(ctx context.Context, adminID, accountID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error
|
|
UnblockAccount(ctx context.Context, adminID, accountID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Appeals
|
|
ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, *errx.Error)
|
|
GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, *errx.Error)
|
|
ReviewAppeal(ctx context.Context, adminID, appealID uuid.UUID, approved bool, notes string, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Campaign Management
|
|
SearchCampaigns(ctx context.Context, search *models.AdminCampaignSearch) (*models.AdminCampaignsResult, *errx.Error)
|
|
GetCampaignDetail(ctx context.Context, campaignID uuid.UUID) (*models.AdminCampaignDetail, *errx.Error)
|
|
StopCampaign(ctx context.Context, adminID, campaignID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Analytics
|
|
GetPlatformOverview(ctx context.Context) (*models.PlatformOverview, *errx.Error)
|
|
GetAnalyticsTrends(ctx context.Context) (*models.AnalyticsTrends, *errx.Error)
|
|
GetDailyEmailStats(ctx context.Context, startDate, endDate time.Time) ([]models.DailyEmailStats, *errx.Error)
|
|
GetHourlyEmailStats(ctx context.Context, date time.Time) ([]models.HourlyEmailStats, *errx.Error)
|
|
GetWorkerLoadStats(ctx context.Context) ([]models.WorkerLoadStats, *errx.Error)
|
|
GetEmailDistribution(ctx context.Context) ([]models.EmailDistribution, *errx.Error)
|
|
GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, *errx.Error)
|
|
|
|
// Plans
|
|
ListPlans(ctx context.Context, includePrivate bool) ([]models.Plan, *errx.Error)
|
|
SearchPlansForAdmin(ctx context.Context, search *models.AdminPlanSearch) (*models.AdminPlansResult, *errx.Error)
|
|
CreatePlan(ctx context.Context, adminID uuid.UUID, req *models.CreatePlanRequest, ipAddress, userAgent string) (*models.Plan, *errx.Error)
|
|
GetPlan(ctx context.Context, planID uuid.UUID) (*models.Plan, *errx.Error)
|
|
UpdatePlan(ctx context.Context, adminID, planID uuid.UUID, req *models.UpdatePlanRequest, ipAddress, userAgent string) (*models.Plan, *errx.Error)
|
|
DeletePlan(ctx context.Context, adminID, planID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Enterprise Inquiries
|
|
ListEnterpriseInquiries(ctx context.Context, search *models.AdminEnterpriseInquirySearch) (*models.AdminEnterpriseInquiriesResult, *errx.Error)
|
|
GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) (*models.AdminEnterpriseInquiry, *errx.Error)
|
|
UpdateEnterpriseInquiry(ctx context.Context, adminID, inquiryID uuid.UUID, update *models.UpdateEnterpriseInquiryRequest, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Admin Management
|
|
ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, *errx.Error)
|
|
GrantAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, permissions models.AdminPermission, ipAddress, userAgent string) *errx.Error
|
|
RevokeAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
|
|
|
// Audit Logs
|
|
SearchAuditLogs(ctx context.Context, search *models.AdminAuditLogSearch) (*models.AdminAuditLogsResult, *errx.Error)
|
|
|
|
// LogAdminAction writes a row to admin_audit_log. Fire-and-forget; safe
|
|
// to call from any handler that has the admin's identity and a target.
|
|
// Used by the new SSH worker / credentials / release / system handlers.
|
|
LogAdminAction(ctx context.Context, adminID uuid.UUID, action, targetType string, targetID *uuid.UUID, details map[string]any, ipAddress, userAgent string)
|
|
}
|
|
|
|
type adminService struct {
|
|
repo repository.AdminRepository
|
|
}
|
|
|
|
// NewService creates a new admin service
|
|
func NewService(repo repository.AdminRepository) AdminService {
|
|
return &adminService{repo: repo}
|
|
}
|
|
|
|
// logAction logs an admin action
|
|
func (s *adminService) logAction(ctx context.Context, adminID uuid.UUID, action, targetType string, targetID uuid.UUID, details map[string]any, ipAddress, userAgent string) {
|
|
log := &models.AdminAuditLog{
|
|
ID: uuid.New(),
|
|
AdminUserID: adminID,
|
|
Action: action,
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
Details: details,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := s.repo.CreateAuditLog(ctx, log); err != nil {
|
|
sentry.CaptureException(err)
|
|
}
|
|
}
|
|
|
|
// LogAdminAction is the public-facing version of logAction. Goroutine-detached
|
|
// so handlers can call it without taking the request-cycle hit on the audit
|
|
// write. Accepts an optional targetID (uuid.Nil if the action isn't tied to
|
|
// a specific entity, e.g. "check releases").
|
|
func (s *adminService) LogAdminAction(ctx context.Context, adminID uuid.UUID, action, targetType string, targetID *uuid.UUID, details map[string]any, ipAddress, userAgent string) {
|
|
var tid uuid.UUID
|
|
if targetID != nil {
|
|
tid = *targetID
|
|
}
|
|
// Capture values into the goroutine; ctx is replaced with Background so
|
|
// the audit row still lands even if the request context cancels first.
|
|
go func() {
|
|
s.logAction(context.Background(), adminID, action, targetType, tid, details, ipAddress, userAgent)
|
|
}()
|
|
}
|
|
|
|
// User Management
|
|
|
|
func (s *adminService) SearchMailboxes(ctx context.Context, search *models.AdminMailboxSearch) (*models.AdminMailboxesResult, *errx.Error) {
|
|
result, err := s.repo.SearchMailboxesForAdmin(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to search mailboxes")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) SearchUsers(ctx context.Context, search *models.AdminUserSearch) (*models.AdminUsersResult, *errx.Error) {
|
|
result, err := s.repo.SearchUsers(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to search users")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserDetail(ctx context.Context, userID uuid.UUID) (*models.AdminUserDetail, *errx.Error) {
|
|
user, err := s.repo.GetUserDetail(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get user detail")
|
|
}
|
|
if user == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserPreview(ctx context.Context, userID uuid.UUID) (*models.AdminUserPreview, *errx.Error) {
|
|
preview, err := s.repo.GetUserPreview(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get user preview")
|
|
}
|
|
if preview == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return preview, nil
|
|
}
|
|
|
|
func (s *adminService) BanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, scope models.BanScope, ipAddress, userAgent string) *errx.Error {
|
|
// Check if user exists
|
|
user, err := s.repo.GetUserDetail(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to get user")
|
|
}
|
|
if user == nil {
|
|
return errx.ErrNotFound
|
|
}
|
|
|
|
// Check if already banned
|
|
if user.BannedAt != nil {
|
|
return errx.New(errx.BadRequest, "user is already banned")
|
|
}
|
|
|
|
// Cannot ban other admins
|
|
if user.AdminPermissions > 0 {
|
|
return errx.New(errx.Forbidden, "cannot ban admin users")
|
|
}
|
|
|
|
// Default to login-only ban when the caller leaves scope empty so
|
|
// the legacy "you can't log in" behaviour is preserved.
|
|
if scope == 0 {
|
|
scope = models.BanScopeLogin
|
|
}
|
|
|
|
if err := s.repo.BanUser(ctx, userID, adminID, reason, uint32(scope)); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to ban user")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "ban_user", "user", userID, map[string]any{"reason": reason, "scope": uint32(scope)}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
func (s *adminService) UnbanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error {
|
|
// Check if user exists
|
|
user, err := s.repo.GetUserDetail(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to get user")
|
|
}
|
|
if user == nil {
|
|
return errx.ErrNotFound
|
|
}
|
|
|
|
// Check if not banned
|
|
if user.BannedAt == nil {
|
|
return errx.New(errx.BadRequest, "user is not banned")
|
|
}
|
|
|
|
if err := s.repo.UnbanUser(ctx, userID, adminID, reason); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to unban user")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "unban_user", "user", userID, map[string]any{"reason": reason}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
func (s *adminService) GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, *errx.Error) {
|
|
bans, err := s.repo.GetUserBans(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get user bans")
|
|
}
|
|
return bans, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) (*models.AdminCampaignsResult, *errx.Error) {
|
|
search := &models.AdminCampaignSearch{
|
|
UserID: &userID,
|
|
Cursor: cursor,
|
|
Limit: limit,
|
|
}
|
|
result, err := s.repo.SearchCampaigns(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get user campaigns")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) {
|
|
emails, pagination, err := s.repo.GetUserEmails(ctx, userID, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, nil, errx.New(errx.Internal, "failed to get user emails")
|
|
}
|
|
return emails, pagination, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, *errx.Error) {
|
|
limits, err := s.repo.GetUserRateLimits(ctx, userID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get rate limits")
|
|
}
|
|
return limits, nil
|
|
}
|
|
|
|
func (s *adminService) UpdateUserRateLimits(ctx context.Context, adminID, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.UpdateUserRateLimits(ctx, userID, update); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to update rate limits")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "update_rate_limits", "user", userID, map[string]any{"limits": update}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Worker Management
|
|
|
|
func (s *adminService) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, *errx.Error) {
|
|
result, err := s.repo.ListWorkers(ctx, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list workers")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, *errx.Error) {
|
|
worker, err := s.repo.GetWorkerDetail(ctx, workerID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get worker detail")
|
|
}
|
|
if worker == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return worker, nil
|
|
}
|
|
|
|
func (s *adminService) UpdateWorker(ctx context.Context, adminID, workerID uuid.UUID, update *models.AdminUpdateWorker, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.UpdateWorker(ctx, workerID, update); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to update worker")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "update_worker", "worker", workerID, map[string]any{"update": update}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
func (s *adminService) GetWorkerEmails(ctx context.Context, workerID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) {
|
|
emails, pagination, err := s.repo.GetWorkerEmails(ctx, workerID, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, nil, errx.New(errx.Internal, "failed to get worker emails")
|
|
}
|
|
return emails, pagination, nil
|
|
}
|
|
|
|
func (s *adminService) GetWorkerStats(ctx context.Context, workerID uuid.UUID) (*models.WorkerStats, *errx.Error) {
|
|
stats, err := s.repo.GetWorkerStats(ctx, workerID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get worker stats")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *adminService) ReassignEmails(ctx context.Context, adminID uuid.UUID, emailIDs []uuid.UUID, newWorkerID uuid.UUID, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.ReassignEmails(ctx, emailIDs, newWorkerID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to reassign emails")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "reassign_emails", "worker", newWorkerID, map[string]any{"email_count": len(emailIDs)}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Warmup Management
|
|
|
|
func (s *adminService) ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, *errx.Error) {
|
|
pools, err := s.repo.ListWarmupPools(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list warmup pools")
|
|
}
|
|
return pools, nil
|
|
}
|
|
|
|
func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error) {
|
|
result, err := s.repo.GetPoolParticipants(ctx, poolType, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get pool participants")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, *errx.Error) {
|
|
result, err := s.repo.ListBlockedAccounts(ctx, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list blocked accounts")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) BlockAccount(ctx context.Context, adminID, accountID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.BlockAccount(ctx, accountID, adminID, reason); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to block account")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "block_warmup_account", "email_account", accountID, map[string]any{"reason": reason}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
func (s *adminService) UnblockAccount(ctx context.Context, adminID, accountID uuid.UUID, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.UnblockAccount(ctx, accountID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to unblock account")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "unblock_warmup_account", "email_account", accountID, nil, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Appeals
|
|
|
|
func (s *adminService) ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, *errx.Error) {
|
|
result, err := s.repo.ListAppeals(ctx, status, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list appeals")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, *errx.Error) {
|
|
appeal, err := s.repo.GetAppeal(ctx, appealID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get appeal")
|
|
}
|
|
if appeal == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return appeal, nil
|
|
}
|
|
|
|
func (s *adminService) ReviewAppeal(ctx context.Context, adminID, appealID uuid.UUID, approved bool, notes string, ipAddress, userAgent string) *errx.Error {
|
|
appeal, err := s.repo.GetAppeal(ctx, appealID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to get appeal")
|
|
}
|
|
if appeal == nil {
|
|
return errx.ErrNotFound
|
|
}
|
|
|
|
if appeal.Status != models.WarmupAppealStatusPending {
|
|
return errx.New(errx.BadRequest, "appeal has already been reviewed")
|
|
}
|
|
|
|
if err := s.repo.ReviewAppeal(ctx, appealID, adminID, approved, notes); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to review appeal")
|
|
}
|
|
|
|
action := "reject_appeal"
|
|
if approved {
|
|
action = "approve_appeal"
|
|
}
|
|
s.logAction(ctx, adminID, action, "warmup_appeal", appealID, map[string]any{"notes": notes}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Campaign Management
|
|
|
|
func (s *adminService) SearchCampaigns(ctx context.Context, search *models.AdminCampaignSearch) (*models.AdminCampaignsResult, *errx.Error) {
|
|
result, err := s.repo.SearchCampaigns(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to search campaigns")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetCampaignDetail(ctx context.Context, campaignID uuid.UUID) (*models.AdminCampaignDetail, *errx.Error) {
|
|
campaign, err := s.repo.GetCampaignDetail(ctx, campaignID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get campaign detail")
|
|
}
|
|
if campaign == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return campaign, nil
|
|
}
|
|
|
|
func (s *adminService) StopCampaign(ctx context.Context, adminID, campaignID uuid.UUID, ipAddress, userAgent string) *errx.Error {
|
|
campaign, err := s.repo.GetCampaignDetail(ctx, campaignID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to get campaign")
|
|
}
|
|
if campaign == nil {
|
|
return errx.ErrNotFound
|
|
}
|
|
|
|
if err := s.repo.StopCampaign(ctx, campaignID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to stop campaign")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "force_stop_campaign", "campaign", campaignID, map[string]any{"user_id": campaign.UserID}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Analytics
|
|
|
|
func (s *adminService) GetPlatformOverview(ctx context.Context) (*models.PlatformOverview, *errx.Error) {
|
|
overview, err := s.repo.GetPlatformOverview(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get platform overview")
|
|
}
|
|
return overview, nil
|
|
}
|
|
|
|
func (s *adminService) GetAnalyticsTrends(ctx context.Context) (*models.AnalyticsTrends, *errx.Error) {
|
|
trends, err := s.repo.GetAnalyticsTrends(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get analytics trends")
|
|
}
|
|
return trends, nil
|
|
}
|
|
|
|
func (s *adminService) GetDailyEmailStats(ctx context.Context, startDate, endDate time.Time) ([]models.DailyEmailStats, *errx.Error) {
|
|
stats, err := s.repo.GetDailyEmailStats(ctx, startDate, endDate)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get daily email stats")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *adminService) GetHourlyEmailStats(ctx context.Context, date time.Time) ([]models.HourlyEmailStats, *errx.Error) {
|
|
stats, err := s.repo.GetHourlyEmailStats(ctx, date)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get hourly email stats")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *adminService) GetWorkerLoadStats(ctx context.Context) ([]models.WorkerLoadStats, *errx.Error) {
|
|
stats, err := s.repo.GetWorkerLoadStats(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get worker load stats")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *adminService) GetEmailDistribution(ctx context.Context) ([]models.EmailDistribution, *errx.Error) {
|
|
dist, err := s.repo.GetEmailDistribution(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get email distribution")
|
|
}
|
|
return dist, nil
|
|
}
|
|
|
|
func (s *adminService) GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, *errx.Error) {
|
|
stats, err := s.repo.GetUserGrowthStats(ctx, startDate, endDate)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get user growth stats")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
// Plans
|
|
|
|
func (s *adminService) ListPlans(ctx context.Context, includePrivate bool) ([]models.Plan, *errx.Error) {
|
|
plans, err := s.repo.ListPlans(ctx, includePrivate)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list plans")
|
|
}
|
|
return plans, nil
|
|
}
|
|
|
|
func (s *adminService) SearchPlansForAdmin(ctx context.Context, search *models.AdminPlanSearch) (*models.AdminPlansResult, *errx.Error) {
|
|
result, err := s.repo.SearchPlansForAdmin(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to search plans")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) CreatePlan(ctx context.Context, adminID uuid.UUID, req *models.CreatePlanRequest, ipAddress, userAgent string) (*models.Plan, *errx.Error) {
|
|
plan := &models.Plan{
|
|
ID: uuid.New(),
|
|
Name: &req.Name,
|
|
MaxContacts: req.MaxContacts,
|
|
DailyEmails: req.DailyEmails,
|
|
AIGeneration: req.AIGeneration,
|
|
AccountLimit: req.AccountLimit,
|
|
Price: req.Price,
|
|
DiscountedPrice: req.DiscountedPrice,
|
|
Duration: req.Duration,
|
|
Public: req.Public,
|
|
DedicatedWorkers: req.DedicatedWorkers,
|
|
DailyCampaignLimit: req.DailyCampaignLimit,
|
|
MaxCampaigns: req.MaxCampaigns,
|
|
MaxActiveCampaigns: req.MaxActiveCampaigns,
|
|
MaxTeamMembers: req.MaxTeamMembers,
|
|
MaxEmailAccounts: req.MaxEmailAccounts,
|
|
}
|
|
|
|
if err := s.repo.CreatePlan(ctx, plan); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to create plan")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "create_plan", "plan", plan.ID, map[string]any{"name": req.Name}, ipAddress, userAgent)
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *adminService) GetPlan(ctx context.Context, planID uuid.UUID) (*models.Plan, *errx.Error) {
|
|
plan, err := s.repo.GetPlan(ctx, planID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get plan")
|
|
}
|
|
if plan == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *adminService) UpdatePlan(ctx context.Context, adminID, planID uuid.UUID, req *models.UpdatePlanRequest, ipAddress, userAgent string) (*models.Plan, *errx.Error) {
|
|
plan, err := s.repo.GetPlan(ctx, planID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get plan")
|
|
}
|
|
if plan == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Name != nil {
|
|
plan.Name = req.Name
|
|
}
|
|
if req.MaxContacts != nil {
|
|
plan.MaxContacts = *req.MaxContacts
|
|
}
|
|
if req.DailyEmails != nil {
|
|
plan.DailyEmails = *req.DailyEmails
|
|
}
|
|
if req.AIGeneration != nil {
|
|
plan.AIGeneration = *req.AIGeneration
|
|
}
|
|
if req.AccountLimit != nil {
|
|
plan.AccountLimit = *req.AccountLimit
|
|
}
|
|
if req.Price != nil {
|
|
plan.Price = *req.Price
|
|
}
|
|
if req.DiscountedPrice != nil {
|
|
plan.DiscountedPrice = *req.DiscountedPrice
|
|
}
|
|
if req.Duration != nil {
|
|
plan.Duration = *req.Duration
|
|
}
|
|
if req.Public != nil {
|
|
plan.Public = *req.Public
|
|
}
|
|
if req.DedicatedWorkers != nil {
|
|
plan.DedicatedWorkers = *req.DedicatedWorkers
|
|
}
|
|
if req.DailyCampaignLimit != nil {
|
|
plan.DailyCampaignLimit = req.DailyCampaignLimit
|
|
}
|
|
if req.MaxCampaigns != nil {
|
|
plan.MaxCampaigns = req.MaxCampaigns
|
|
}
|
|
if req.MaxActiveCampaigns != nil {
|
|
plan.MaxActiveCampaigns = req.MaxActiveCampaigns
|
|
}
|
|
if req.MaxTeamMembers != nil {
|
|
plan.MaxTeamMembers = req.MaxTeamMembers
|
|
}
|
|
if req.MaxEmailAccounts != nil {
|
|
plan.MaxEmailAccounts = req.MaxEmailAccounts
|
|
}
|
|
|
|
if err := s.repo.UpdatePlan(ctx, plan); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to update plan")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "update_plan", "plan", planID, nil, ipAddress, userAgent)
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *adminService) DeletePlan(ctx context.Context, adminID, planID uuid.UUID, ipAddress, userAgent string) *errx.Error {
|
|
// Check if plan is in use
|
|
inUse, err := s.repo.IsPlanInUse(ctx, planID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to check plan usage")
|
|
}
|
|
if inUse {
|
|
return errx.New(errx.BadRequest, "cannot delete plan that is in use")
|
|
}
|
|
|
|
if err := s.repo.DeletePlan(ctx, planID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to delete plan")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "delete_plan", "plan", planID, nil, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Enterprise Inquiries
|
|
|
|
func (s *adminService) ListEnterpriseInquiries(ctx context.Context, search *models.AdminEnterpriseInquirySearch) (*models.AdminEnterpriseInquiriesResult, *errx.Error) {
|
|
result, err := s.repo.ListEnterpriseInquiries(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list enterprise inquiries")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) (*models.AdminEnterpriseInquiry, *errx.Error) {
|
|
inquiry, err := s.repo.GetEnterpriseInquiry(ctx, id)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to get enterprise inquiry")
|
|
}
|
|
if inquiry == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return inquiry, nil
|
|
}
|
|
|
|
func (s *adminService) UpdateEnterpriseInquiry(ctx context.Context, adminID, inquiryID uuid.UUID, update *models.UpdateEnterpriseInquiryRequest, ipAddress, userAgent string) *errx.Error {
|
|
if err := s.repo.UpdateEnterpriseInquiry(ctx, inquiryID, update); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to update enterprise inquiry")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "update_enterprise_inquiry", "enterprise_inquiry", inquiryID, map[string]any{"update": update}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Admin Management
|
|
|
|
func (s *adminService) ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, *errx.Error) {
|
|
result, err := s.repo.ListAdmins(ctx, cursor, limit)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to list admins")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *adminService) GrantAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, permissions models.AdminPermission, ipAddress, userAgent string) *errx.Error {
|
|
// Cannot modify own permissions
|
|
if adminID == targetUserID {
|
|
return errx.New(errx.BadRequest, "cannot modify your own permissions")
|
|
}
|
|
|
|
if err := s.repo.UpdateUserAdminPermissions(ctx, targetUserID, uint32(permissions), adminID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to grant admin permissions")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "grant_admin", "user", targetUserID, map[string]any{"permissions": permissions}, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
func (s *adminService) RevokeAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, ipAddress, userAgent string) *errx.Error {
|
|
// Cannot modify own permissions
|
|
if adminID == targetUserID {
|
|
return errx.New(errx.BadRequest, "cannot modify your own permissions")
|
|
}
|
|
|
|
if err := s.repo.UpdateUserAdminPermissions(ctx, targetUserID, 0, adminID); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.New(errx.Internal, "failed to revoke admin permissions")
|
|
}
|
|
|
|
s.logAction(ctx, adminID, "revoke_admin", "user", targetUserID, nil, ipAddress, userAgent)
|
|
return nil
|
|
}
|
|
|
|
// Audit Logs
|
|
|
|
func (s *adminService) SearchAuditLogs(ctx context.Context, search *models.AdminAuditLogSearch) (*models.AdminAuditLogsResult, *errx.Error) {
|
|
result, err := s.repo.SearchAuditLogs(ctx, search)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.New(errx.Internal, "failed to search audit logs")
|
|
}
|
|
return result, nil
|
|
}
|