mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-20 16:01:19 +00:00
950965fe90
The audit calls added in the last commit went to AuditService.LogAction, which writes to the general user-facing audit log (Cassandra). The admin audit-log viewer at /admin/audit-logs queries the admin_audit_log table in Postgres, so worker / credentials / release actions never showed up. Add a public AdminService.LogAdminAction that wraps the existing private logAction (writes to admin_audit_log with the same shape as ban_user / update_worker / etc.). Repoint h.audit() at it. Actions now visible in the audit viewer: test, install, restart, upgrade, uninstall, rotate_keys, apply, assign, system_update, reboot, check_releases (plus the existing create/update/delete across workers, AWS creds, and profiles).
761 lines
29 KiB
Go
761 lines
29 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 {
|
|
// 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, 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)
|
|
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, status string, cursor *uuid.UUID, limit int) (*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) 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, 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")
|
|
}
|
|
|
|
if err := s.repo.BanUser(ctx, userID, adminID, reason); 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}, 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) 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, status string, cursor *uuid.UUID, limit int) (*models.AdminEnterpriseInquiriesResult, *errx.Error) {
|
|
result, err := s.repo.ListEnterpriseInquiries(ctx, status, cursor, limit)
|
|
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
|
|
}
|