Files
warmbly/internal/repository/pg_admin.go
T

1922 lines
56 KiB
Go

package repository
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/models"
)
// AdminRepository defines the interface for admin data access
type AdminRepository interface {
// User Management
SearchUsers(ctx context.Context, search *models.AdminUserSearch) (*models.AdminUsersResult, error)
GetUserDetail(ctx context.Context, userID uuid.UUID) (*models.AdminUserDetail, error)
GetUserPreview(ctx context.Context, userID uuid.UUID) (*models.AdminUserPreview, error)
UpdateUserAdminPermissions(ctx context.Context, userID uuid.UUID, permissions uint32, grantedBy uuid.UUID) error
BanUser(ctx context.Context, userID, bannedBy uuid.UUID, reason string) error
UnbanUser(ctx context.Context, userID, unbannedBy uuid.UUID, reason string) error
GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, error)
GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error)
ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, error)
// Worker Management
ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, error)
GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, error)
UpdateWorker(ctx context.Context, workerID uuid.UUID, update *models.AdminUpdateWorker) error
GetWorkerEmails(ctx context.Context, workerID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error)
GetWorkerStats(ctx context.Context, workerID uuid.UUID) (*models.WorkerStats, error)
ReassignEmails(ctx context.Context, emailIDs []uuid.UUID, newWorkerID uuid.UUID) error
// Warmup Management
ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, error)
GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, error)
ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, error)
BlockAccount(ctx context.Context, accountID uuid.UUID, blockedBy uuid.UUID, reason string) error
UnblockAccount(ctx context.Context, accountID uuid.UUID) error
// Warmup Appeals
ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, error)
GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, error)
ReviewAppeal(ctx context.Context, appealID uuid.UUID, reviewedBy uuid.UUID, approved bool, notes string) error
// Campaign Management
SearchCampaigns(ctx context.Context, search *models.AdminCampaignSearch) (*models.AdminCampaignsResult, error)
GetCampaignDetail(ctx context.Context, campaignID uuid.UUID) (*models.AdminCampaignDetail, error)
StopCampaign(ctx context.Context, campaignID uuid.UUID) error
// Audit Logs
CreateAuditLog(ctx context.Context, log *models.AdminAuditLog) error
SearchAuditLogs(ctx context.Context, search *models.AdminAuditLogSearch) (*models.AdminAuditLogsResult, error)
// Analytics
GetPlatformOverview(ctx context.Context) (*models.PlatformOverview, error)
GetDailyEmailStats(ctx context.Context, startDate, endDate time.Time) ([]models.DailyEmailStats, error)
GetHourlyEmailStats(ctx context.Context, date time.Time) ([]models.HourlyEmailStats, error)
GetWorkerLoadStats(ctx context.Context) ([]models.WorkerLoadStats, error)
GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, error)
GetAnalyticsTrends(ctx context.Context) (*models.AnalyticsTrends, error)
GetEmailDistribution(ctx context.Context) ([]models.EmailDistribution, error)
// Plans
ListPlans(ctx context.Context, includePrivate bool) ([]models.Plan, error)
CreatePlan(ctx context.Context, plan *models.Plan) error
GetPlan(ctx context.Context, planID uuid.UUID) (*models.Plan, error)
UpdatePlan(ctx context.Context, plan *models.Plan) error
DeletePlan(ctx context.Context, planID uuid.UUID) error
IsPlanInUse(ctx context.Context, planID uuid.UUID) (bool, error)
// Enterprise Inquiries
ListEnterpriseInquiries(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.AdminEnterpriseInquiriesResult, error)
GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) (*models.AdminEnterpriseInquiry, error)
UpdateEnterpriseInquiry(ctx context.Context, id uuid.UUID, update *models.UpdateEnterpriseInquiryRequest) error
// User Rate Limits
GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, error)
UpdateUserRateLimits(ctx context.Context, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest) error
}
type adminRepository struct {
db *pgxpool.Pool
}
// NewAdminRepository creates a new admin repository
func NewAdminRepository(db *pgxpool.Pool) AdminRepository {
return &adminRepository{db: db}
}
// SearchUsers searches for users with pagination
func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminUserSearch) (*models.AdminUsersResult, error) {
limit := search.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{}
argNum := 1
whereClause := "WHERE 1=1"
if search.Query != "" {
whereClause += ` AND (u.email ILIKE $` + itoa(argNum) + ` OR u.first_name ILIKE $` + itoa(argNum) + ` OR u.last_name ILIKE $` + itoa(argNum) + `)`
args = append(args, "%"+search.Query+"%")
argNum++
}
if search.Status == "banned" {
whereClause += ` AND u.banned_at IS NOT NULL`
} else if search.Status == "active" {
whereClause += ` AND u.banned_at IS NULL`
}
if search.IsAdmin != nil && *search.IsAdmin {
whereClause += ` AND u.admin_permissions > 0`
}
if search.Cursor != nil {
whereClause += ` AND u.id < $` + itoa(argNum)
args = append(args, *search.Cursor)
argNum++
}
orderBy := "ORDER BY u.created_at DESC"
if search.SortBy != "" {
switch search.SortBy {
case "email":
orderBy = "ORDER BY u.email"
case "name":
orderBy = "ORDER BY u.first_name, u.last_name"
}
if search.SortDesc {
orderBy += " DESC"
}
}
args = append(args, limit+1)
query := `
SELECT
u.id, u.first_name, u.last_name, u.email, u.max_organizations, u.free_trial_used,
u.admin_permissions, u.admin_granted_at, u.admin_granted_by, u.banned_at,
u.created_at, u.updated_at,
(SELECT COUNT(*) FROM organization_members om WHERE om.user_id = u.id) as org_count,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.user_id = u.id::text) as email_count,
(SELECT COUNT(*) FROM campaigns c WHERE c.user_id = u.id) as campaign_count
FROM users u
` + whereClause + `
` + orderBy + `
LIMIT $` + itoa(argNum)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var users []models.AdminUserDetail
for rows.Next() {
var u models.AdminUserDetail
err := rows.Scan(
&u.ID, &u.FirstName, &u.LastName, &u.Email, &u.MaxOrganizations, &u.FreeTrialUsed,
&u.AdminPermissions, &u.AdminGrantedAt, &u.AdminGrantedBy, &u.BannedAt,
&u.CreatedAt, &u.UpdatedAt,
&u.OrganizationCount, &u.EmailAccountCount, &u.CampaignCount,
)
if err != nil {
return nil, err
}
users = append(users, u)
}
result := &models.AdminUsersResult{
Data: users,
Pagination: models.Pagination{
HasMore: len(users) > limit,
},
}
if len(users) > limit {
result.Data = users[:limit]
lastID := users[limit-1].ID
result.Pagination.NextCursor = &lastID
}
// Get total count
countQuery := `SELECT COUNT(*) FROM users u ` + whereClause
var total int64
if err := r.db.QueryRow(ctx, countQuery, args[:len(args)-1]...).Scan(&total); err == nil {
result.Pagination.Total = &total
}
return result, nil
}
// GetUserDetail gets detailed user information
func (r *adminRepository) GetUserDetail(ctx context.Context, userID uuid.UUID) (*models.AdminUserDetail, error) {
query := `
SELECT
u.id, u.first_name, u.last_name, u.email, u.max_organizations, u.free_trial_used,
u.admin_permissions, u.admin_granted_at, u.admin_granted_by, u.banned_at,
u.created_at, u.updated_at,
(SELECT COUNT(*) FROM organization_members om WHERE om.user_id = u.id) as org_count,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.user_id = u.id::text) as email_count,
(SELECT COUNT(*) FROM campaigns c WHERE c.user_id = u.id) as campaign_count
FROM users u
WHERE u.id = $1
`
var u models.AdminUserDetail
err := r.db.QueryRow(ctx, query, userID).Scan(
&u.ID, &u.FirstName, &u.LastName, &u.Email, &u.MaxOrganizations, &u.FreeTrialUsed,
&u.AdminPermissions, &u.AdminGrantedAt, &u.AdminGrantedBy, &u.BannedAt,
&u.CreatedAt, &u.UpdatedAt,
&u.OrganizationCount, &u.EmailAccountCount, &u.CampaignCount,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetUserPreview gets a complete preview of a user's account
func (r *adminRepository) GetUserPreview(ctx context.Context, userID uuid.UUID) (*models.AdminUserPreview, error) {
user, err := r.GetUserDetail(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
preview := &models.AdminUserPreview{
User: *user,
}
// Get organizations
orgQuery := `
SELECT o.id, o.name, o.slug, o.owner_user_id, o.created_at, o.updated_at
FROM organizations o
JOIN organization_members om ON om.organization_id = o.id
WHERE om.user_id = $1
`
orgRows, err := r.db.Query(ctx, orgQuery, userID)
if err == nil {
defer orgRows.Close()
for orgRows.Next() {
var org models.Organization
if err := orgRows.Scan(&org.ID, &org.Name, &org.Slug, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt); err == nil {
preview.Organizations = append(preview.Organizations, org)
}
}
}
// Get subscriptions
subQuery := `
SELECT s.id, s.user_id, s.organization_id, s.plan_id, s.stripe_customer_id,
s.stripe_subscription_id, s.stripe_price_id, s.status,
s.current_period_start, s.current_period_end, s.cancel_at_period_end,
s.canceled_at, s.trial_start, s.trial_end,
s.free_trial_started_at, s.free_trial_ends_at, s.is_enterprise,
s.created_at, s.updated_at
FROM subscriptions s
WHERE s.user_id = $1
`
subRows, err := r.db.Query(ctx, subQuery, userID)
if err == nil {
defer subRows.Close()
for subRows.Next() {
var sub models.Subscription
if err := subRows.Scan(
&sub.ID, &sub.UserID, &sub.OrganizationID, &sub.PlanID, &sub.StripeCustomerID,
&sub.StripeSubscriptionID, &sub.StripePriceID, &sub.Status,
&sub.CurrentPeriodStart, &sub.CurrentPeriodEnd, &sub.CancelAtPeriodEnd,
&sub.CanceledAt, &sub.TrialStart, &sub.TrialEnd,
&sub.FreeTrialStartedAt, &sub.FreeTrialEndsAt, &sub.IsEnterprise,
&sub.CreatedAt, &sub.UpdatedAt,
); err == nil {
preview.Subscriptions = append(preview.Subscriptions, sub)
}
}
}
// Get email accounts
emailQuery := `
SELECT id, email, user_id, organization_id, status, provider,
warmup IS NOT NULL as warmup_enabled, last_synced_at
FROM email_accounts
WHERE user_id = $1::text
`
emailRows, err := r.db.Query(ctx, emailQuery, userID)
if err == nil {
defer emailRows.Close()
for emailRows.Next() {
var email models.AdminWorkerEmail
if err := emailRows.Scan(
&email.ID, &email.Email, &email.UserID, &email.OrganizationID,
&email.Status, &email.Provider, &email.WarmupEnabled, &email.LastSyncedAt,
); err == nil {
preview.EmailAccounts = append(preview.EmailAccounts, email)
}
}
}
// Get recent bans
bans, _ := r.GetUserBans(ctx, userID)
if len(bans) > 5 {
bans = bans[:5]
}
preview.RecentBans = bans
// Get rate limits
preview.RateLimits, _ = r.GetUserRateLimits(ctx, userID)
return preview, nil
}
// UpdateUserAdminPermissions updates a user's admin permissions
func (r *adminRepository) UpdateUserAdminPermissions(ctx context.Context, userID uuid.UUID, permissions uint32, grantedBy uuid.UUID) error {
var grantedAt *time.Time
var grantedByPtr *uuid.UUID
if permissions > 0 {
now := time.Now()
grantedAt = &now
grantedByPtr = &grantedBy
}
query := `
UPDATE users SET
admin_permissions = $2,
admin_granted_at = $3,
admin_granted_by = $4,
updated_at = NOW()
WHERE id = $1
`
_, err := r.db.Exec(ctx, query, userID, permissions, grantedAt, grantedByPtr)
return err
}
// BanUser bans a user
func (r *adminRepository) BanUser(ctx context.Context, userID, bannedBy uuid.UUID, reason string) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Update user
_, err = tx.Exec(ctx, `UPDATE users SET banned_at = NOW(), updated_at = NOW() WHERE id = $1`, userID)
if err != nil {
return err
}
// Create ban record
_, err = tx.Exec(ctx, `
INSERT INTO user_bans (user_id, banned_by, reason, banned_at)
VALUES ($1, $2, $3, NOW())
`, userID, bannedBy, reason)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// UnbanUser unbans a user
func (r *adminRepository) UnbanUser(ctx context.Context, userID, unbannedBy uuid.UUID, reason string) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Update user
_, err = tx.Exec(ctx, `UPDATE users SET banned_at = NULL, updated_at = NOW() WHERE id = $1`, userID)
if err != nil {
return err
}
// Update most recent ban record
_, err = tx.Exec(ctx, `
UPDATE user_bans SET unbanned_at = NOW(), unbanned_by = $2, unban_reason = $3
WHERE user_id = $1 AND unbanned_at IS NULL
`, userID, unbannedBy, reason)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// GetUserBans gets the ban history for a user
func (r *adminRepository) GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, error) {
query := `
SELECT ub.id, ub.user_id, ub.banned_by, ub.reason, ub.banned_at,
ub.unbanned_at, ub.unbanned_by, ub.unban_reason,
bu.id, bu.first_name, bu.last_name, bu.email,
uu.id, uu.first_name, uu.last_name, uu.email
FROM user_bans ub
JOIN users bu ON bu.id = ub.banned_by
LEFT JOIN users uu ON uu.id = ub.unbanned_by
WHERE ub.user_id = $1
ORDER BY ub.banned_at DESC
`
rows, err := r.db.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var bans []models.UserBan
for rows.Next() {
var ban models.UserBan
var bannedByUser models.AdminUserSummary
var unbannedByID, unbannedByFirstName, unbannedByLastName, unbannedByEmail *string
err := rows.Scan(
&ban.ID, &ban.UserID, &ban.BannedBy, &ban.Reason, &ban.BannedAt,
&ban.UnbannedAt, &ban.UnbannedBy, &ban.UnbanReason,
&bannedByUser.ID, &bannedByUser.FirstName, &bannedByUser.LastName, &bannedByUser.Email,
&unbannedByID, &unbannedByFirstName, &unbannedByLastName, &unbannedByEmail,
)
if err != nil {
return nil, err
}
ban.BannedByUser = &bannedByUser
if unbannedByID != nil {
id, _ := uuid.Parse(*unbannedByID)
ban.UnbannedByUser = &models.AdminUserSummary{
ID: id,
FirstName: *unbannedByFirstName,
LastName: *unbannedByLastName,
Email: *unbannedByEmail,
}
}
bans = append(bans, ban)
}
return bans, nil
}
// GetUserEmails gets email accounts belonging to a user
func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{userID, limit + 1}
whereClause := "WHERE ea.user_id = $1::text"
if cursor != nil {
whereClause += " AND ea.id < $3"
args = append(args, *cursor)
}
query := `
SELECT ea.id, ea.email, ea.user_id::uuid, ea.organization_id,
ea.status, ea.provider, ea.warmup IS NOT NULL, ea.last_synced_at
FROM email_accounts ea
` + whereClause + `
ORDER BY ea.created_at DESC
LIMIT $2
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var emails []models.AdminWorkerEmail
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
&e.ID, &e.Email, &e.UserID, &e.OrganizationID,
&e.Status, &e.Provider, &e.WarmupEnabled, &e.LastSyncedAt,
)
if err != nil {
return nil, nil, err
}
emails = append(emails, e)
}
pagination := &models.Pagination{
HasMore: len(emails) > limit,
}
if len(emails) > limit {
emails = emails[:limit]
pagination.NextCursor = &emails[limit-1].ID
}
return emails, pagination, nil
}
// ListAdmins lists all admin users
func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{limit + 1}
whereClause := "WHERE u.admin_permissions > 0"
if cursor != nil {
whereClause += " AND u.id < $2"
args = append(args, *cursor)
}
query := `
SELECT u.id, u.first_name, u.last_name, u.email, u.admin_permissions,
u.admin_granted_at, u.admin_granted_by,
gu.id, gu.first_name, gu.last_name, gu.email
FROM users u
LEFT JOIN users gu ON gu.id = u.admin_granted_by
` + whereClause + `
ORDER BY u.admin_granted_at DESC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var admins []models.AdminInfo
for rows.Next() {
var admin models.AdminInfo
var grantedByID *uuid.UUID
var grantedByFirstName, grantedByLastName, grantedByEmail *string
err := rows.Scan(
&admin.ID, &admin.FirstName, &admin.LastName, &admin.Email, &admin.AdminPermissions,
&admin.AdminGrantedAt, &admin.AdminGrantedBy,
&grantedByID, &grantedByFirstName, &grantedByLastName, &grantedByEmail,
)
if err != nil {
return nil, err
}
if grantedByID != nil {
admin.GrantedByUser = &models.AdminUserSummary{
ID: *grantedByID,
FirstName: *grantedByFirstName,
LastName: *grantedByLastName,
Email: *grantedByEmail,
}
}
admins = append(admins, admin)
}
result := &models.AdminsResult{
Data: admins,
Pagination: models.Pagination{
HasMore: len(admins) > limit,
},
}
if len(admins) > limit {
result.Data = admins[:limit]
lastID := admins[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// ListWorkers lists all workers with details
func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{limit + 1}
whereClause := ""
if cursor != nil {
whereClause = "WHERE w.id < $2"
args = append(args, *cursor)
}
query := `
SELECT w.id, w.name, COALESCE(w.notes, ''), w.ip_addr, w.active,
COALESCE(w.free_tier, false), COALESCE(w.worker_type, 'shared'),
w.created_at, w.updated_at,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.worker_id = w.id) as connected_emails
FROM workers w
` + whereClause + `
ORDER BY w.created_at DESC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var workers []models.AdminWorkerDetail
for rows.Next() {
var w models.AdminWorkerDetail
err := rows.Scan(
&w.ID, &w.Name, &w.Notes, &w.IPAddr, &w.Active,
&w.FreeTier, &w.WorkerType,
&w.CreatedAt, &w.UpdatedAt,
&w.ConnectedEmails,
)
if err != nil {
return nil, err
}
workers = append(workers, w)
}
result := &models.AdminWorkersResult{
Data: workers,
Pagination: models.Pagination{
HasMore: len(workers) > limit,
},
}
if len(workers) > limit {
result.Data = workers[:limit]
lastID := workers[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// GetWorkerDetail gets detailed worker information
func (r *adminRepository) GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, error) {
query := `
SELECT w.id, w.name, COALESCE(w.notes, ''), w.ip_addr, w.active,
COALESCE(w.free_tier, false), COALESCE(w.worker_type, 'shared'),
w.created_at, w.updated_at,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.worker_id = w.id) as connected_emails,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.worker_id = w.id AND ea.warmup IS NOT NULL) as warmup_emails
FROM workers w
WHERE w.id = $1
`
var w models.AdminWorkerDetail
err := r.db.QueryRow(ctx, query, workerID).Scan(
&w.ID, &w.Name, &w.Notes, &w.IPAddr, &w.Active,
&w.FreeTier, &w.WorkerType,
&w.CreatedAt, &w.UpdatedAt,
&w.ConnectedEmails, &w.WarmupEmails,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &w, nil
}
// UpdateWorker updates a worker
func (r *adminRepository) UpdateWorker(ctx context.Context, workerID uuid.UUID, update *models.AdminUpdateWorker) error {
setClauses := []string{"updated_at = NOW()"}
args := []interface{}{workerID}
argNum := 2
if update.Name != nil {
setClauses = append(setClauses, "name = $"+itoa(argNum))
args = append(args, *update.Name)
argNum++
}
if update.Notes != nil {
setClauses = append(setClauses, "notes = $"+itoa(argNum))
args = append(args, *update.Notes)
argNum++
}
if update.Active != nil {
setClauses = append(setClauses, "active = $"+itoa(argNum))
args = append(args, *update.Active)
argNum++
}
if update.WorkerType != nil {
setClauses = append(setClauses, "worker_type = $"+itoa(argNum))
args = append(args, *update.WorkerType)
argNum++
}
query := "UPDATE workers SET " + joinStrings(setClauses, ", ") + " WHERE id = $1"
_, err := r.db.Exec(ctx, query, args...)
return err
}
// GetWorkerEmails gets emails connected to a worker
func (r *adminRepository) GetWorkerEmails(ctx context.Context, workerID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{workerID, limit + 1}
whereClause := "WHERE ea.worker_id = $1"
if cursor != nil {
whereClause += " AND ea.id < $3"
args = append(args, *cursor)
}
query := `
SELECT ea.id, ea.email, ea.user_id::uuid, ea.organization_id,
ea.status, ea.provider, ea.warmup IS NOT NULL, ea.last_synced_at
FROM email_accounts ea
` + whereClause + `
ORDER BY ea.created_at DESC
LIMIT $2
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var emails []models.AdminWorkerEmail
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
&e.ID, &e.Email, &e.UserID, &e.OrganizationID,
&e.Status, &e.Provider, &e.WarmupEnabled, &e.LastSyncedAt,
)
if err != nil {
return nil, nil, err
}
emails = append(emails, e)
}
pagination := &models.Pagination{
HasMore: len(emails) > limit,
}
if len(emails) > limit {
emails = emails[:limit]
pagination.NextCursor = &emails[limit-1].ID
}
return emails, pagination, nil
}
// GetWorkerStats gets statistics for a worker
func (r *adminRepository) GetWorkerStats(ctx context.Context, workerID uuid.UUID) (*models.WorkerStats, error) {
stats := &models.WorkerStats{
WorkerID: workerID,
}
query := `
SELECT
COUNT(*) FILTER (WHERE t.status = 'completed') AS total_sent,
COUNT(*) FILTER (WHERE t.status = 'completed' AND t.completed_at >= CURRENT_DATE) AS sent_today,
COUNT(*) FILTER (WHERE t.status = 'completed' AND t.completed_at >= date_trunc('week', CURRENT_DATE)) AS sent_this_week,
COALESCE(AVG(EXTRACT(EPOCH FROM (t.completed_at - t.scheduled_at)) * 1000)
FILTER (WHERE t.status = 'completed' AND t.completed_at IS NOT NULL AND t.scheduled_at IS NOT NULL), 0) AS avg_delivery_ms,
CASE
WHEN COUNT(*) FILTER (WHERE t.status IN ('completed', 'failed', 'dead_lettered')) > 0
THEN COUNT(*) FILTER (WHERE t.status = 'completed')::float / COUNT(*) FILTER (WHERE t.status IN ('completed', 'failed', 'dead_lettered'))::float * 100
ELSE 100
END AS success_rate,
COUNT(*) FILTER (WHERE t.status = 'pending') AS queue_depth
FROM tasks t
JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.worker_id = $1
`
err := r.db.QueryRow(ctx, query, workerID).Scan(
&stats.TotalEmailsSent,
&stats.EmailsSentToday,
&stats.EmailsSentThisWeek,
&stats.AverageDeliveryTime,
&stats.SuccessRate,
&stats.QueueDepth,
)
if err != nil {
return nil, err
}
return stats, nil
}
// ReassignEmails reassigns emails to a new worker
func (r *adminRepository) ReassignEmails(ctx context.Context, emailIDs []uuid.UUID, newWorkerID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE email_accounts SET worker_id = $1, updated_at = NOW()
WHERE id = ANY($2)
`, newWorkerID, emailIDs)
return err
}
// ListWarmupPools lists all warmup pools with participant counts
func (r *adminRepository) ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, error) {
query := `
SELECT
wp.pool_type::text,
COUNT(wpp.email_account_id) AS total,
COUNT(wpp.email_account_id) FILTER (
WHERE wpp.health_state IN ('healthy', 'watch', 'throttled')
AND wpp.blocked_at IS NULL
) AS active,
COUNT(wpp.email_account_id) FILTER (
WHERE wpp.health_state IN ('quarantined', 'blocked')
OR wpp.blocked_at IS NOT NULL
) AS blocked
FROM warmup_pools wp
LEFT JOIN warmup_pool_participants wpp ON wpp.pool_id = wp.id
GROUP BY wp.pool_type
ORDER BY wp.pool_type
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var pools []models.WarmupPoolInfo
for rows.Next() {
var p models.WarmupPoolInfo
if err := rows.Scan(&p.Type, &p.TotalParticipants, &p.ActiveParticipants, &p.BlockedCount); err != nil {
return nil, err
}
pools = append(pools, p)
}
return pools, rows.Err()
}
// GetPoolParticipants gets participants in a warmup pool
func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{poolType, limit + 1}
whereClause := "WHERE wp.pool_type = $1::warmup_pool_type"
if cursor != nil {
whereClause += " AND wpp.email_account_id < $3"
args = append(args, *cursor)
}
query := `
SELECT
wpp.email_account_id, ea.email, ea.user_id::uuid,
wpp.joined_at, wpp.spam_score,
wpp.blocked_at IS NOT NULL OR wpp.health_state IN ('quarantined', 'blocked'),
wpp.blocked_at,
COALESCE((SELECT SUM(ws.emails_sent) FROM warmup_statistics ws WHERE ws.email_account_id = wpp.email_account_id), 0),
COALESCE((SELECT COUNT(*) FROM warmup_tokens wt WHERE wt.recipient_account_id = wpp.email_account_id AND wt.consumed_at IS NOT NULL), 0)
FROM warmup_pool_participants wpp
JOIN warmup_pools wp ON wpp.pool_id = wp.id
JOIN email_accounts ea ON ea.id = wpp.email_account_id
` + whereClause + `
ORDER BY wpp.joined_at DESC
LIMIT $2
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var participants []models.WarmupPoolParticipant
for rows.Next() {
var p models.WarmupPoolParticipant
if err := rows.Scan(
&p.ID, &p.Email, &p.UserID,
&p.JoinedAt, &p.ReputationScore,
&p.IsBlocked, &p.BlockedAt,
&p.EmailsSent, &p.EmailsReceived,
); err != nil {
return nil, err
}
participants = append(participants, p)
}
result := &models.WarmupPoolParticipantsResult{
Data: participants,
Pagination: models.Pagination{
HasMore: len(participants) > limit,
},
}
if len(participants) > limit {
result.Data = participants[:limit]
result.Pagination.NextCursor = &participants[limit-1].ID
}
return result, nil
}
// ListBlockedAccounts lists blocked warmup accounts
func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{limit + 1}
whereClause := `WHERE (wpp.health_state IN ('quarantined', 'blocked') OR wpp.blocked_at IS NOT NULL)`
if cursor != nil {
whereClause += " AND wpp.email_account_id < $2"
args = append(args, *cursor)
}
query := `
SELECT
wpp.email_account_id, ea.email, ea.user_id::uuid,
COALESCE(wpp.blocked_at, wpp.last_health_evaluated_at, wpp.joined_at) AS blocked_at,
COALESCE(wpp.blocked_reason, wpp.last_health_reason, '') AS block_reason,
EXISTS(SELECT 1 FROM warmup_appeals wa WHERE wa.email_account_id = wpp.email_account_id AND wa.status = 'pending') AS has_appeal,
(SELECT wa.status FROM warmup_appeals wa WHERE wa.email_account_id = wpp.email_account_id ORDER BY wa.created_at DESC LIMIT 1) AS appeal_status,
u.id, u.first_name, u.last_name, u.email
FROM warmup_pool_participants wpp
JOIN email_accounts ea ON ea.id = wpp.email_account_id
JOIN users u ON u.id = ea.user_id::uuid
` + whereClause + `
ORDER BY blocked_at DESC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var accounts []models.AdminBlockedAccount
for rows.Next() {
var a models.AdminBlockedAccount
var user models.AdminUserSummary
var appealStatus *string
if err := rows.Scan(
&a.ID, &a.Email, &a.UserID,
&a.BlockedAt, &a.BlockReason,
&a.HasAppeal, &appealStatus,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
); err != nil {
return nil, err
}
a.User = &user
if appealStatus != nil {
status := models.WarmupAppealStatus(*appealStatus)
a.AppealStatus = &status
}
accounts = append(accounts, a)
}
result := &models.AdminBlockedAccountsResult{
Data: accounts,
Pagination: models.Pagination{
HasMore: len(accounts) > limit,
},
}
if len(accounts) > limit {
result.Data = accounts[:limit]
result.Pagination.NextCursor = &accounts[limit-1].ID
}
return result, nil
}
// BlockAccount blocks an account from warmup pools
func (r *adminRepository) BlockAccount(ctx context.Context, accountID uuid.UUID, blockedBy uuid.UUID, reason string) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Update health state to blocked in warmup_pool_participants
_, err = tx.Exec(ctx, `
UPDATE warmup_pool_participants
SET blocked_at = NOW(),
blocked_reason = $2,
health_state = 'blocked',
blocked_until = NOW() + INTERVAL '30 days',
last_health_reason = $2,
last_health_evaluated_at = NOW()
WHERE email_account_id = $1
`, accountID, reason)
if err != nil {
return err
}
// Record the admin action
_, err = tx.Exec(ctx, `
INSERT INTO warmup_admin_actions (admin_user_id, email_account_id, action, reason)
VALUES ($1, $2, 'block', $3)
`, blockedBy, accountID, reason)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// UnblockAccount unblocks an account from warmup pools
func (r *adminRepository) UnblockAccount(ctx context.Context, accountID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE warmup_pool_participants
SET blocked_at = NULL,
blocked_reason = NULL,
health_state = 'healthy',
blocked_until = NULL,
last_health_reason = 'unblocked by admin',
last_health_evaluated_at = NOW(),
last_health_score = 0,
spam_score = 0
WHERE email_account_id = $1
`, accountID)
return err
}
// ListAppeals lists warmup appeals
func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{limit + 1}
whereClause := "WHERE 1=1"
argNum := 2
if status != "" {
whereClause += " AND wa.status = $" + itoa(argNum)
args = append(args, status)
argNum++
}
if cursor != nil {
whereClause += " AND wa.id < $" + itoa(argNum)
args = append(args, *cursor)
}
query := `
SELECT wa.id, wa.email_account_id, wa.user_id, wa.reason, wa.status,
wa.reviewed_by, wa.reviewed_at, wa.review_notes, wa.created_at,
u.id, u.first_name, u.last_name, u.email
FROM warmup_appeals wa
JOIN users u ON u.id = wa.user_id
` + whereClause + `
ORDER BY wa.created_at DESC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var appeals []models.WarmupAppeal
for rows.Next() {
var a models.WarmupAppeal
var user models.AdminUserSummary
err := rows.Scan(
&a.ID, &a.EmailAccountID, &a.UserID, &a.Reason, &a.Status,
&a.ReviewedBy, &a.ReviewedAt, &a.ReviewNotes, &a.CreatedAt,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
)
if err != nil {
return nil, err
}
a.User = &user
appeals = append(appeals, a)
}
result := &models.WarmupAppealsResult{
Data: appeals,
Pagination: models.Pagination{
HasMore: len(appeals) > limit,
},
}
if len(appeals) > limit {
result.Data = appeals[:limit]
lastID := appeals[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// GetAppeal gets a specific appeal
func (r *adminRepository) GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, error) {
query := `
SELECT wa.id, wa.email_account_id, wa.user_id, wa.reason, wa.status,
wa.reviewed_by, wa.reviewed_at, wa.review_notes, wa.created_at,
u.id, u.first_name, u.last_name, u.email
FROM warmup_appeals wa
JOIN users u ON u.id = wa.user_id
WHERE wa.id = $1
`
var a models.WarmupAppeal
var user models.AdminUserSummary
err := r.db.QueryRow(ctx, query, appealID).Scan(
&a.ID, &a.EmailAccountID, &a.UserID, &a.Reason, &a.Status,
&a.ReviewedBy, &a.ReviewedAt, &a.ReviewNotes, &a.CreatedAt,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
a.User = &user
return &a, nil
}
// ReviewAppeal reviews a warmup appeal
func (r *adminRepository) ReviewAppeal(ctx context.Context, appealID uuid.UUID, reviewedBy uuid.UUID, approved bool, notes string) error {
status := models.WarmupAppealStatusRejected
if approved {
status = models.WarmupAppealStatusApproved
}
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Update appeal
_, err = tx.Exec(ctx, `
UPDATE warmup_appeals SET
status = $2, reviewed_by = $3, reviewed_at = NOW(), review_notes = $4
WHERE id = $1
`, appealID, status, reviewedBy, notes)
if err != nil {
return err
}
// If approved, unblock the account
if approved {
var accountID uuid.UUID
err = tx.QueryRow(ctx, `SELECT email_account_id FROM warmup_appeals WHERE id = $1`, appealID).Scan(&accountID)
if err == nil {
_, err = tx.Exec(ctx, `
UPDATE warmup_pool_participants
SET blocked_at = NULL,
blocked_reason = NULL,
health_state = 'healthy',
blocked_until = NULL,
last_health_reason = 'appeal approved',
last_health_evaluated_at = NOW(),
last_health_score = 0,
spam_score = 0
WHERE email_account_id = $1
`, accountID)
if err != nil {
return err
}
_, _ = tx.Exec(ctx, `
INSERT INTO warmup_admin_actions (admin_user_id, email_account_id, action, reason)
VALUES ($1, $2, 'unblock', 'appeal approved')
`, reviewedBy, accountID)
}
}
return tx.Commit(ctx)
}
// SearchCampaigns searches for campaigns with pagination
func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.AdminCampaignSearch) (*models.AdminCampaignsResult, error) {
limit := search.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{}
argNum := 1
whereClause := "WHERE 1=1"
if search.Query != "" {
whereClause += " AND c.name ILIKE $" + itoa(argNum)
args = append(args, "%"+search.Query+"%")
argNum++
}
if search.UserID != nil {
whereClause += " AND c.user_id = $" + itoa(argNum)
args = append(args, *search.UserID)
argNum++
}
if search.OrgID != nil {
whereClause += " AND c.organization_id = $" + itoa(argNum)
args = append(args, *search.OrgID)
argNum++
}
if search.Status != "" && search.Status != "all" {
whereClause += " AND c.status = $" + itoa(argNum)
args = append(args, search.Status)
argNum++
}
if search.Cursor != nil {
whereClause += " AND c.id < $" + itoa(argNum)
args = append(args, *search.Cursor)
argNum++
}
args = append(args, limit+1)
query := `
SELECT c.id, c.name, c.user_id, c.organization_id, c.status, c.created_at,
c.started_at, c.stopped_at,
u.id, u.first_name, u.last_name, u.email
FROM campaigns c
JOIN users u ON u.id = c.user_id
` + whereClause + `
ORDER BY c.created_at DESC
LIMIT $` + itoa(argNum)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var campaigns []models.AdminCampaignDetail
for rows.Next() {
var c models.AdminCampaignDetail
var user models.AdminUserSummary
err := rows.Scan(
&c.ID, &c.Name, &c.UserID, &c.OrganizationID, &c.Status, &c.CreatedAt,
&c.StartedAt, &c.StoppedAt,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
)
if err != nil {
return nil, err
}
c.User = &user
campaigns = append(campaigns, c)
}
result := &models.AdminCampaignsResult{
Data: campaigns,
Pagination: models.Pagination{
HasMore: len(campaigns) > limit,
},
}
if len(campaigns) > limit {
result.Data = campaigns[:limit]
lastID := campaigns[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// GetCampaignDetail gets detailed campaign information
func (r *adminRepository) GetCampaignDetail(ctx context.Context, campaignID uuid.UUID) (*models.AdminCampaignDetail, error) {
query := `
SELECT c.id, c.name, c.user_id, c.organization_id, c.status, c.created_at,
c.started_at, c.stopped_at,
u.id, u.first_name, u.last_name, u.email
FROM campaigns c
JOIN users u ON u.id = c.user_id
WHERE c.id = $1
`
var c models.AdminCampaignDetail
var user models.AdminUserSummary
err := r.db.QueryRow(ctx, query, campaignID).Scan(
&c.ID, &c.Name, &c.UserID, &c.OrganizationID, &c.Status, &c.CreatedAt,
&c.StartedAt, &c.StoppedAt,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
c.User = &user
return &c, nil
}
// StopCampaign force-stops a campaign
func (r *adminRepository) StopCampaign(ctx context.Context, campaignID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE campaigns SET status = 'stopped', stopped_at = NOW(), updated_at = NOW()
WHERE id = $1
`, campaignID)
return err
}
// CreateAuditLog creates an audit log entry
func (r *adminRepository) CreateAuditLog(ctx context.Context, log *models.AdminAuditLog) error {
detailsJSON, _ := json.Marshal(log.Details)
query := `
INSERT INTO admin_audit_logs (id, admin_user_id, action, target_type, target_id, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := r.db.Exec(ctx, query,
log.ID, log.AdminUserID, log.Action, log.TargetType, log.TargetID,
detailsJSON, log.IPAddress, log.UserAgent, log.CreatedAt,
)
return err
}
// SearchAuditLogs searches audit logs with filters
func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.AdminAuditLogSearch) (*models.AdminAuditLogsResult, error) {
limit := search.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{}
argNum := 1
whereClause := "WHERE 1=1"
if search.AdminUserID != nil {
whereClause += " AND al.admin_user_id = $" + itoa(argNum)
args = append(args, *search.AdminUserID)
argNum++
}
if search.Action != "" {
whereClause += " AND al.action = $" + itoa(argNum)
args = append(args, search.Action)
argNum++
}
if search.TargetType != "" {
whereClause += " AND al.target_type = $" + itoa(argNum)
args = append(args, search.TargetType)
argNum++
}
if search.TargetID != nil {
whereClause += " AND al.target_id = $" + itoa(argNum)
args = append(args, *search.TargetID)
argNum++
}
if search.StartDate != nil {
whereClause += " AND al.created_at >= $" + itoa(argNum)
args = append(args, *search.StartDate)
argNum++
}
if search.EndDate != nil {
whereClause += " AND al.created_at <= $" + itoa(argNum)
args = append(args, *search.EndDate)
argNum++
}
if search.Cursor != nil {
whereClause += " AND al.id < $" + itoa(argNum)
args = append(args, *search.Cursor)
argNum++
}
args = append(args, limit+1)
query := `
SELECT al.id, al.admin_user_id, al.action, al.target_type, al.target_id,
al.details, al.ip_address, al.user_agent, al.created_at,
u.id, u.first_name, u.last_name, u.email
FROM admin_audit_logs al
JOIN users u ON u.id = al.admin_user_id
` + whereClause + `
ORDER BY al.created_at DESC
LIMIT $` + itoa(argNum)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.AdminAuditLog
for rows.Next() {
var log models.AdminAuditLog
var user models.AdminUserSummary
var detailsJSON []byte
err := rows.Scan(
&log.ID, &log.AdminUserID, &log.Action, &log.TargetType, &log.TargetID,
&detailsJSON, &log.IPAddress, &log.UserAgent, &log.CreatedAt,
&user.ID, &user.FirstName, &user.LastName, &user.Email,
)
if err != nil {
return nil, err
}
if len(detailsJSON) > 0 {
json.Unmarshal(detailsJSON, &log.Details)
}
log.AdminUser = &user
logs = append(logs, log)
}
result := &models.AdminAuditLogsResult{
Data: logs,
Pagination: models.Pagination{
HasMore: len(logs) > limit,
},
}
if len(logs) > limit {
result.Data = logs[:limit]
lastID := logs[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// GetPlatformOverview gets high-level platform statistics
func (r *adminRepository) GetPlatformOverview(ctx context.Context) (*models.PlatformOverview, error) {
overview := &models.PlatformOverview{}
// Total users
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&overview.TotalUsers)
// Active users (last 30 days)
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE updated_at > NOW() - INTERVAL '30 days'`).Scan(&overview.ActiveUsers)
// New users today
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&overview.NewUsersToday)
// New users this week
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&overview.NewUsersThisWeek)
// Total campaigns
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM campaigns`).Scan(&overview.TotalCampaigns)
// Active campaigns
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM campaigns WHERE status = 'active'`).Scan(&overview.ActiveCampaigns)
// Total workers
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM workers`).Scan(&overview.TotalWorkers)
// Active workers
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM workers WHERE active = true`).Scan(&overview.ActiveWorkers)
// Pending appeals
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM warmup_appeals WHERE status = 'pending'`).Scan(&overview.PendingAppeals)
// Active subscriptions
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM subscriptions WHERE status = 'active'`).Scan(&overview.ActiveSubscriptions)
// Trialing users
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM subscriptions WHERE status = 'trialing'`).Scan(&overview.TrialingUsers)
return overview, nil
}
// GetDailyEmailStats gets daily email statistics
func (r *adminRepository) GetDailyEmailStats(ctx context.Context, startDate, endDate time.Time) ([]models.DailyEmailStats, error) {
// This would query actual email statistics tables
// Placeholder implementation
return []models.DailyEmailStats{}, nil
}
// GetHourlyEmailStats gets hourly email statistics
func (r *adminRepository) GetHourlyEmailStats(ctx context.Context, date time.Time) ([]models.HourlyEmailStats, error) {
// This would query actual email statistics tables
// Placeholder implementation
return []models.HourlyEmailStats{}, nil
}
// GetWorkerLoadStats gets worker load statistics
func (r *adminRepository) GetWorkerLoadStats(ctx context.Context) ([]models.WorkerLoadStats, error) {
query := `
SELECT w.id, w.name,
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.worker_id = w.id) as connected_emails
FROM workers w
WHERE w.active = true
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []models.WorkerLoadStats
for rows.Next() {
var s models.WorkerLoadStats
err := rows.Scan(&s.WorkerID, &s.WorkerName, &s.ConnectedEmails)
if err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, nil
}
// GetUserGrowthStats gets user growth statistics
func (r *adminRepository) GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, error) {
query := `
SELECT date_trunc('day', created_at)::date as date, COUNT(*) as new_users
FROM users
WHERE created_at BETWEEN $1 AND $2
GROUP BY date_trunc('day', created_at)
ORDER BY date
`
rows, err := r.db.Query(ctx, query, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []models.UserGrowthStats
for rows.Next() {
var s models.UserGrowthStats
err := rows.Scan(&s.Date, &s.NewUsers)
if err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, nil
}
// GetAnalyticsTrends gets trend data
func (r *adminRepository) GetAnalyticsTrends(ctx context.Context) (*models.AnalyticsTrends, error) {
trends := &models.AnalyticsTrends{}
// Calculate week-over-week growth
var usersThisWeek, usersLastWeek int64
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&usersThisWeek)
r.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '14 days' AND created_at < CURRENT_DATE - INTERVAL '7 days'`).Scan(&usersLastWeek)
if usersLastWeek > 0 {
trends.UsersGrowthPercent = float64(usersThisWeek-usersLastWeek) / float64(usersLastWeek) * 100
}
return trends, nil
}
// GetEmailDistribution gets email distribution across workers
func (r *adminRepository) GetEmailDistribution(ctx context.Context) ([]models.EmailDistribution, error) {
query := `
SELECT w.id, w.name, COUNT(ea.id) as email_count
FROM workers w
LEFT JOIN email_accounts ea ON ea.worker_id = w.id
GROUP BY w.id, w.name
ORDER BY email_count DESC
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var total int64
var distributions []models.EmailDistribution
for rows.Next() {
var d models.EmailDistribution
err := rows.Scan(&d.WorkerID, &d.WorkerName, &d.EmailCount)
if err != nil {
return nil, err
}
total += d.EmailCount
distributions = append(distributions, d)
}
// Calculate percentages
for i := range distributions {
if total > 0 {
distributions[i].Percentage = float64(distributions[i].EmailCount) / float64(total) * 100
}
}
return distributions, nil
}
// ListPlans lists all plans
func (r *adminRepository) ListPlans(ctx context.Context, includePrivate bool) ([]models.Plan, error) {
whereClause := ""
if !includePrivate {
whereClause = "WHERE public = true"
}
query := `
SELECT id, name, max_contacts, daily_emails, ai_generation, account_limit,
price, discounted_price, duration, savings, public,
stripe_price_id, stripe_product_id, dedicated_workers, daily_campaign_limit,
max_campaigns, max_active_campaigns, max_team_members, max_email_accounts,
updated_at, created_at
FROM plans
` + whereClause + `
ORDER BY price ASC
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var plans []models.Plan
for rows.Next() {
var p models.Plan
err := rows.Scan(
&p.ID, &p.Name, &p.MaxContacts, &p.DailyEmails, &p.AIGeneration, &p.AccountLimit,
&p.Price, &p.DiscountedPrice, &p.Duration, &p.Savings, &p.Public,
&p.StripePriceID, &p.StripeProductID, &p.DedicatedWorkers, &p.DailyCampaignLimit,
&p.MaxCampaigns, &p.MaxActiveCampaigns, &p.MaxTeamMembers, &p.MaxEmailAccounts,
&p.UpdatedAt, &p.CreatedAt,
)
if err != nil {
return nil, err
}
plans = append(plans, p)
}
return plans, nil
}
// CreatePlan creates a new plan
func (r *adminRepository) CreatePlan(ctx context.Context, plan *models.Plan) error {
query := `
INSERT INTO plans (id, name, max_contacts, daily_emails, ai_generation, account_limit,
price, discounted_price, duration, savings, public, dedicated_workers, daily_campaign_limit,
max_campaigns, max_active_campaigns, max_team_members, max_email_accounts,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
`
now := time.Now()
_, err := r.db.Exec(ctx, query,
plan.ID, plan.Name, plan.MaxContacts, plan.DailyEmails, plan.AIGeneration, plan.AccountLimit,
plan.Price, plan.DiscountedPrice, plan.Duration, plan.Savings, plan.Public,
plan.DedicatedWorkers, plan.DailyCampaignLimit,
plan.MaxCampaigns, plan.MaxActiveCampaigns, plan.MaxTeamMembers, plan.MaxEmailAccounts,
now, now,
)
return err
}
// GetPlan gets a plan by ID
func (r *adminRepository) GetPlan(ctx context.Context, planID uuid.UUID) (*models.Plan, error) {
query := `
SELECT id, name, max_contacts, daily_emails, ai_generation, account_limit,
price, discounted_price, duration, savings, public,
stripe_price_id, stripe_product_id, dedicated_workers, daily_campaign_limit,
max_campaigns, max_active_campaigns, max_team_members, max_email_accounts,
updated_at, created_at
FROM plans WHERE id = $1
`
var p models.Plan
err := r.db.QueryRow(ctx, query, planID).Scan(
&p.ID, &p.Name, &p.MaxContacts, &p.DailyEmails, &p.AIGeneration, &p.AccountLimit,
&p.Price, &p.DiscountedPrice, &p.Duration, &p.Savings, &p.Public,
&p.StripePriceID, &p.StripeProductID, &p.DedicatedWorkers, &p.DailyCampaignLimit,
&p.MaxCampaigns, &p.MaxActiveCampaigns, &p.MaxTeamMembers, &p.MaxEmailAccounts,
&p.UpdatedAt, &p.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// UpdatePlan updates a plan
func (r *adminRepository) UpdatePlan(ctx context.Context, plan *models.Plan) error {
query := `
UPDATE plans SET name = $2, max_contacts = $3, daily_emails = $4, ai_generation = $5,
account_limit = $6, price = $7, discounted_price = $8, duration = $9, public = $10,
dedicated_workers = $11, daily_campaign_limit = $12, max_campaigns = $13,
max_active_campaigns = $14, max_team_members = $15, max_email_accounts = $16,
updated_at = $17
WHERE id = $1
`
_, err := r.db.Exec(ctx, query,
plan.ID, plan.Name, plan.MaxContacts, plan.DailyEmails, plan.AIGeneration,
plan.AccountLimit, plan.Price, plan.DiscountedPrice, plan.Duration, plan.Public,
plan.DedicatedWorkers, plan.DailyCampaignLimit, plan.MaxCampaigns,
plan.MaxActiveCampaigns, plan.MaxTeamMembers, plan.MaxEmailAccounts,
time.Now(),
)
return err
}
// DeletePlan deletes a plan
func (r *adminRepository) DeletePlan(ctx context.Context, planID uuid.UUID) error {
_, err := r.db.Exec(ctx, `DELETE FROM plans WHERE id = $1`, planID)
return err
}
// IsPlanInUse checks if a plan is being used by any subscription
func (r *adminRepository) IsPlanInUse(ctx context.Context, planID uuid.UUID) (bool, error) {
var count int
err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM subscriptions WHERE plan_id = $1`, planID).Scan(&count)
return count > 0, err
}
// ListEnterpriseInquiries lists enterprise inquiries
func (r *adminRepository) ListEnterpriseInquiries(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.AdminEnterpriseInquiriesResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
args := []interface{}{limit + 1}
argNum := 2
whereClause := "WHERE 1=1"
if status != "" && status != "all" {
whereClause += " AND ei.status = $" + itoa(argNum)
args = append(args, status)
argNum++
}
if cursor != nil {
whereClause += " AND ei.id < $" + itoa(argNum)
args = append(args, *cursor)
}
query := `
SELECT ei.id, ei.user_id, ei.company_name, ei.contact_name, ei.contact_email,
ei.phone, ei.team_size, ei.notes, ei.status, ei.assigned_to,
ei.created_at, COALESCE(ei.updated_at, ei.created_at) as updated_at,
u.id, u.first_name, u.last_name, u.email,
au.id, au.first_name, au.last_name, au.email
FROM enterprise_inquiries ei
LEFT JOIN users u ON u.id = ei.user_id
LEFT JOIN users au ON au.id = ei.assigned_to
` + whereClause + `
ORDER BY ei.created_at DESC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var inquiries []models.AdminEnterpriseInquiry
for rows.Next() {
var inq models.AdminEnterpriseInquiry
var userID, assignedUserID *uuid.UUID
var userFirstName, userLastName, userEmail *string
var assignedFirstName, assignedLastName, assignedEmail *string
err := rows.Scan(
&inq.ID, &inq.UserID, &inq.CompanyName, &inq.ContactName, &inq.ContactEmail,
&inq.Phone, &inq.TeamSize, &inq.Notes, &inq.Status, &inq.AssignedTo,
&inq.CreatedAt, &inq.UpdatedAt,
&userID, &userFirstName, &userLastName, &userEmail,
&assignedUserID, &assignedFirstName, &assignedLastName, &assignedEmail,
)
if err != nil {
return nil, err
}
if userID != nil {
inq.User = &models.AdminUserSummary{
ID: *userID,
FirstName: *userFirstName,
LastName: *userLastName,
Email: *userEmail,
}
}
if assignedUserID != nil {
inq.AssignedAdmin = &models.AdminUserSummary{
ID: *assignedUserID,
FirstName: *assignedFirstName,
LastName: *assignedLastName,
Email: *assignedEmail,
}
}
inquiries = append(inquiries, inq)
}
result := &models.AdminEnterpriseInquiriesResult{
Data: inquiries,
Pagination: models.Pagination{
HasMore: len(inquiries) > limit,
},
}
if len(inquiries) > limit {
result.Data = inquiries[:limit]
lastID := inquiries[limit-1].ID
result.Pagination.NextCursor = &lastID
}
return result, nil
}
// GetEnterpriseInquiry gets a specific enterprise inquiry
func (r *adminRepository) GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) (*models.AdminEnterpriseInquiry, error) {
query := `
SELECT ei.id, ei.user_id, ei.company_name, ei.contact_name, ei.contact_email,
ei.phone, ei.team_size, ei.notes, ei.status, ei.assigned_to,
ei.created_at, COALESCE(ei.updated_at, ei.created_at) as updated_at
FROM enterprise_inquiries ei
WHERE ei.id = $1
`
var inq models.AdminEnterpriseInquiry
err := r.db.QueryRow(ctx, query, id).Scan(
&inq.ID, &inq.UserID, &inq.CompanyName, &inq.ContactName, &inq.ContactEmail,
&inq.Phone, &inq.TeamSize, &inq.Notes, &inq.Status, &inq.AssignedTo,
&inq.CreatedAt, &inq.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &inq, nil
}
// UpdateEnterpriseInquiry updates an enterprise inquiry
func (r *adminRepository) UpdateEnterpriseInquiry(ctx context.Context, id uuid.UUID, update *models.UpdateEnterpriseInquiryRequest) error {
setClauses := []string{"updated_at = NOW()"}
args := []interface{}{id}
argNum := 2
if update.Status != nil {
setClauses = append(setClauses, "status = $"+itoa(argNum))
args = append(args, *update.Status)
argNum++
}
if update.AssignedTo != nil {
setClauses = append(setClauses, "assigned_to = $"+itoa(argNum))
args = append(args, *update.AssignedTo)
argNum++
}
if update.Notes != nil {
setClauses = append(setClauses, "notes = $"+itoa(argNum))
args = append(args, *update.Notes)
argNum++
}
query := "UPDATE enterprise_inquiries SET " + joinStrings(setClauses, ", ") + " WHERE id = $1"
_, err := r.db.Exec(ctx, query, args...)
return err
}
// GetUserRateLimits gets rate limits for a user
func (r *adminRepository) GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, error) {
query := `
SELECT user_id, limit_ws_message_pm, limit_ws_join_pm, limit_ws_event_pm,
max_connections, daily_email_limit, updated_at
FROM user_rate_limits
WHERE user_id = $1
`
var limits models.AdminUserRateLimits
err := r.db.QueryRow(ctx, query, userID).Scan(
&limits.UserID, &limits.LimitWSMessagePM, &limits.LimitWSJoinPM, &limits.LimitWSEventPM,
&limits.MaxConnections, &limits.DailyEmailLimit, &limits.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &limits, nil
}
// UpdateUserRateLimits updates rate limits for a user
func (r *adminRepository) UpdateUserRateLimits(ctx context.Context, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest) error {
query := `
INSERT INTO user_rate_limits (user_id, limit_ws_message_pm, limit_ws_join_pm, limit_ws_event_pm,
max_connections, daily_email_limit, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id) DO UPDATE SET
limit_ws_message_pm = COALESCE($2, user_rate_limits.limit_ws_message_pm),
limit_ws_join_pm = COALESCE($3, user_rate_limits.limit_ws_join_pm),
limit_ws_event_pm = COALESCE($4, user_rate_limits.limit_ws_event_pm),
max_connections = COALESCE($5, user_rate_limits.max_connections),
daily_email_limit = COALESCE($6, user_rate_limits.daily_email_limit),
updated_at = NOW()
`
_, err := r.db.Exec(ctx, query, userID,
update.LimitWSMessagePM, update.LimitWSJoinPM, update.LimitWSEventPM,
update.MaxConnections, update.DailyEmailLimit,
)
return err
}
// Helper functions
func itoa(i int) string {
return strconv.Itoa(i)
}
func joinStrings(strs []string, sep string) string {
return strings.Join(strs, sep)
}