mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 16:04:41 +00:00
Add richer backend models, handlers, services, and repository queries for admin campaigns, discounts, outreach, organizations, limit requests, and provisioning surfaces.
2676 lines
81 KiB
Go
2676 lines
81 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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, scope uint32) 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)
|
|
SearchPlansForAdmin(ctx context.Context, search *models.AdminPlanSearch) (*models.AdminPlansResult, 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, search *models.AdminEnterpriseInquirySearch) (*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
|
|
|
|
// Mailboxes (platform-wide). Joins email_accounts → users → orgs
|
|
// so the admin table can answer "whose mailbox is this and where
|
|
// does it live" without N+1 fetches.
|
|
SearchMailboxesForAdmin(ctx context.Context, search *models.AdminMailboxSearch) (*models.AdminMailboxesResult, 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.HasOverrides {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM user_rate_limits url WHERE url.user_id = u.id)`
|
|
}
|
|
|
|
if search.FreeTrialUsed {
|
|
whereClause += ` AND u.free_trial_used = TRUE`
|
|
}
|
|
|
|
if search.CreatedWithin > 0 {
|
|
whereClause += ` AND u.created_at >= NOW() - ($` + itoa(argNum) + `::int * INTERVAL '1 day')`
|
|
args = append(args, search.CreatedWithin)
|
|
argNum++
|
|
}
|
|
|
|
// Local helpers keep the many optional clauses in the established
|
|
// whereClause/argNum/itoa style. `frag` carries a single %d placeholder
|
|
// for the bind position; date "before" bounds are made inclusive of the
|
|
// whole day by comparing against the next midnight.
|
|
addInt := func(frag string, v *int) {
|
|
if v != nil {
|
|
whereClause += " AND " + fmt.Sprintf(frag, argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addAfter := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " >= $" + itoa(argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addBefore := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
// Plan / subscription
|
|
if search.PlanID != nil {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id AND s.plan_id = $` + itoa(argNum) + `)`
|
|
args = append(args, *search.PlanID)
|
|
argNum++
|
|
}
|
|
if search.SubscriptionStatus != "" {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id AND s.status = $` + itoa(argNum) + `)`
|
|
args = append(args, search.SubscriptionStatus)
|
|
argNum++
|
|
}
|
|
if search.IsEnterprise {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id AND s.is_enterprise = TRUE)`
|
|
}
|
|
if search.HasSubscription {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id)`
|
|
}
|
|
if search.HasActiveSubscription {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id AND s.status IN ('active','trialing'))`
|
|
}
|
|
|
|
// Account state
|
|
if search.OnboardingCompleted {
|
|
whereClause += ` AND u.onboarding_completed_at IS NOT NULL`
|
|
}
|
|
if search.DeletionScheduled {
|
|
whereClause += ` AND u.deletion_scheduled_at IS NOT NULL`
|
|
}
|
|
if search.HasAvatar {
|
|
whereClause += ` AND u.avatar_url IS NOT NULL`
|
|
}
|
|
if search.HasActiveCampaign {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM campaigns c WHERE c.user_id = u.id AND c.status = 'active')`
|
|
}
|
|
if search.HasBanRecord {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM user_bans ub WHERE ub.user_id = u.id)`
|
|
}
|
|
if search.HasDedicatedWorker {
|
|
whereClause += ` AND EXISTS (SELECT 1 FROM dedicated_worker_assignments dwa WHERE dwa.user_id = u.id AND dwa.released_at IS NULL)`
|
|
}
|
|
|
|
// Count / numeric ranges
|
|
addInt(`(SELECT COUNT(*) FROM organization_members om WHERE om.user_id = u.id) >= $%d`, search.OrgCountMin)
|
|
addInt(`(SELECT COUNT(*) FROM organization_members om WHERE om.user_id = u.id) <= $%d`, search.OrgCountMax)
|
|
addInt(`(SELECT COUNT(*) FROM email_accounts ea WHERE ea.user_id = u.id) >= $%d`, search.EmailAccountCountMin)
|
|
addInt(`(SELECT COUNT(*) FROM email_accounts ea WHERE ea.user_id = u.id) <= $%d`, search.EmailAccountCountMax)
|
|
addInt(`(SELECT COUNT(*) FROM campaigns c WHERE c.user_id = u.id) >= $%d`, search.CampaignCountMin)
|
|
addInt(`(SELECT COUNT(*) FROM campaigns c WHERE c.user_id = u.id) <= $%d`, search.CampaignCountMax)
|
|
addInt(`u.max_organizations >= $%d`, search.MaxOrganizationsMin)
|
|
addInt(`u.max_organizations <= $%d`, search.MaxOrganizationsMax)
|
|
|
|
// Date ranges
|
|
addAfter("u.created_at", search.CreatedAfter)
|
|
addBefore("u.created_at", search.CreatedBefore)
|
|
addAfter("u.admin_granted_at", search.AdminGrantedAfter)
|
|
addBefore("u.admin_granted_at", search.AdminGrantedBefore)
|
|
addAfter("u.banned_at", search.BannedAfter)
|
|
addBefore("u.banned_at", search.BannedBefore)
|
|
addAfter("u.updated_at", search.UpdatedAfter)
|
|
addBefore("u.updated_at", search.UpdatedBefore)
|
|
|
|
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) 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) 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. Scope is the BanScope bitmask describing which
|
|
// actions are blocked while banned. Callers should pass a non-zero
|
|
// scope; the handler defaults to BanScopeLogin if missing.
|
|
func (r *adminRepository) BanUser(ctx context.Context, userID, bannedBy uuid.UUID, reason string, scope uint32) error {
|
|
tx, err := r.db.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Update user — stamp banned_at and the scope together so any
|
|
// caller that only checks banned_at sees the existing "fully banned"
|
|
// behavior unless they explicitly read ban_scope.
|
|
_, err = tx.Exec(ctx, `UPDATE users SET banned_at = NOW(), ban_scope = $2, updated_at = NOW() WHERE id = $1`, userID, scope)
|
|
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 — clear both banned_at and ban_scope so subsequent
|
|
// auth + send checks see a clean account.
|
|
_, err = tx.Exec(ctx, `UPDATE users SET banned_at = NULL, ban_scope = 0, 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)
|
|
}
|
|
|
|
// Health lives on warmup_pool_participants; an account can be in more than
|
|
// one pool, so we pick its WORST state via a CASE rank (same ordering as the
|
|
// risk rebalancer). risk_band is the mailbox's resolved reputation tier.
|
|
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,
|
|
COALESCE(ea.risk_band, 'clean'::email_risk_band)::text,
|
|
ea.risk_evaluated_at,
|
|
COALESCE(wh.health_state, '')::text,
|
|
wh.spam_score,
|
|
wh.blocked_until
|
|
FROM email_accounts ea
|
|
LEFT JOIN LATERAL (
|
|
SELECT health_state, spam_score, blocked_until
|
|
FROM warmup_pool_participants
|
|
WHERE email_account_id = ea.id
|
|
ORDER BY CASE health_state
|
|
WHEN 'blocked' THEN 0
|
|
WHEN 'quarantined' THEN 1
|
|
WHEN 'throttled' THEN 2
|
|
WHEN 'watch' THEN 3
|
|
WHEN 'healthy' THEN 4
|
|
ELSE 5
|
|
END
|
|
LIMIT 1
|
|
) wh ON true
|
|
` + 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,
|
|
&e.RiskBand, &e.RiskEvaluatedAt, &e.WarmupHealth, &e.SpamScore, &e.BlockedUntil,
|
|
)
|
|
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) + " OR u.email 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::text = $" + itoa(argNum)
|
|
args = append(args, search.Status)
|
|
argNum++
|
|
}
|
|
|
|
// Boolean flags.
|
|
if search.OpenTracking {
|
|
whereClause += " AND c.open_tracking = TRUE"
|
|
}
|
|
if search.LinkTracking {
|
|
whereClause += " AND c.link_tracking = TRUE"
|
|
}
|
|
if search.StopOnReply {
|
|
whereClause += " AND c.stop_on_reply = TRUE"
|
|
}
|
|
if search.TextOnly {
|
|
whereClause += " AND c.text_only = TRUE"
|
|
}
|
|
if search.UnsubscribeHeader {
|
|
whereClause += " AND c.unsubscribe_header = TRUE"
|
|
}
|
|
|
|
// Relationship existence.
|
|
if search.HasContacts {
|
|
whereClause += " AND EXISTS (SELECT 1 FROM campaign_leads cl WHERE cl.campaign_id = c.id)"
|
|
}
|
|
if search.HasBounces {
|
|
whereClause += " AND EXISTS (SELECT 1 FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.bounced_at IS NOT NULL)"
|
|
}
|
|
|
|
addInt := func(frag string, v *int) {
|
|
if v != nil {
|
|
whereClause += " AND " + fmt.Sprintf(frag, argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addAfter := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " >= $" + itoa(argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addBefore := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
addInt(`c.daily_limit >= $%d`, search.DailyLimitMin)
|
|
addInt(`c.daily_limit <= $%d`, search.DailyLimitMax)
|
|
addInt(`(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.campaign_id = c.id) >= $%d`, search.ContactCountMin)
|
|
addInt(`(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.campaign_id = c.id) <= $%d`, search.ContactCountMax)
|
|
addInt(`(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.sent_at IS NOT NULL) >= $%d`, search.SentCountMin)
|
|
addInt(`(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.sent_at IS NOT NULL) <= $%d`, search.SentCountMax)
|
|
|
|
if search.CreatedWithin > 0 {
|
|
whereClause += " AND c.created_at >= NOW() - ($" + itoa(argNum) + "::int * INTERVAL '1 day')"
|
|
args = append(args, search.CreatedWithin)
|
|
argNum++
|
|
}
|
|
addAfter("c.created_at", search.CreatedAfter)
|
|
addBefore("c.created_at", search.CreatedBefore)
|
|
addAfter("c.start_date", search.StartDateAfter)
|
|
addBefore("c.start_date", search.StartDateBefore)
|
|
addAfter("c.updated_at", search.UpdatedAfter)
|
|
addBefore("c.updated_at", search.UpdatedBefore)
|
|
|
|
if search.Cursor != nil {
|
|
whereClause += " AND c.id < $" + itoa(argNum)
|
|
args = append(args, *search.Cursor)
|
|
argNum++
|
|
}
|
|
|
|
orderCol := "c.created_at"
|
|
switch search.SortBy {
|
|
case "name":
|
|
orderCol = "c.name"
|
|
case "status":
|
|
orderCol = "c.status::text"
|
|
case "updated_at":
|
|
orderCol = "c.updated_at"
|
|
case "daily_limit":
|
|
orderCol = "c.daily_limit"
|
|
case "owner_email":
|
|
orderCol = "u.email"
|
|
case "contact_count":
|
|
orderCol = "(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.campaign_id = c.id)"
|
|
case "sent_count":
|
|
orderCol = "(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.sent_at IS NOT NULL)"
|
|
}
|
|
orderDir := "DESC"
|
|
if search.SortBy != "" && !search.SortDesc {
|
|
orderDir = "ASC"
|
|
}
|
|
orderBy := "ORDER BY " + orderCol + " " + orderDir + ", c.id DESC"
|
|
|
|
args = append(args, limit+1)
|
|
|
|
query := `
|
|
SELECT c.id, c.name, c.user_id, c.organization_id, c.status, c.created_at,
|
|
c.start_date, c.end_date,
|
|
u.id, u.first_name, u.last_name, u.email,
|
|
o.id, o.name, o.slug,
|
|
(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.campaign_id = c.id),
|
|
(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.sent_at IS NOT NULL),
|
|
(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.opened_at IS NOT NULL),
|
|
(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.clicked_at IS NOT NULL),
|
|
(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.replied_at IS NOT NULL),
|
|
(SELECT COUNT(*) FROM campaign_contact_progress ccp WHERE ccp.campaign_id = c.id AND ccp.bounced_at IS NOT NULL)
|
|
FROM campaigns c
|
|
JOIN users u ON u.id = c.user_id
|
|
LEFT JOIN organizations o ON o.id = c.organization_id
|
|
` + whereClause + `
|
|
` + orderBy + `
|
|
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
|
|
var orgID *uuid.UUID
|
|
var orgName *string
|
|
var orgSlug *string
|
|
|
|
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,
|
|
&orgID, &orgName, &orgSlug,
|
|
&c.TotalContacts, &c.EmailsSent, &c.EmailsOpened, &c.EmailsClicked, &c.EmailsReplied, &c.EmailsBounced,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.User = &user
|
|
if orgID != nil {
|
|
c.Organization = &models.Organization{ID: *orgID}
|
|
if orgName != nil {
|
|
c.Organization.Name = *orgName
|
|
}
|
|
c.Organization.Slug = orgSlug
|
|
}
|
|
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
|
|
}
|
|
|
|
// Total count for the same filter — drop the trailing LIMIT arg.
|
|
countQuery := `SELECT COUNT(*) FROM campaigns c JOIN users u ON u.id = c.user_id LEFT JOIN organizations o ON o.id = c.organization_id ` + 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
|
|
}
|
|
|
|
// 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.start_date, c.end_date,
|
|
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 p.public = true"
|
|
}
|
|
|
|
query := `
|
|
SELECT p.id, p.name, p.max_contacts, p.daily_emails, p.ai_generation, p.account_limit,
|
|
p.price, p.discounted_price, d.title, p.savings, p.public,
|
|
p.stripe_price_id, p.stripe_product_id, p.dedicated_workers, p.daily_campaign_limit,
|
|
p.max_campaigns, p.max_active_campaigns, p.max_team_members, p.max_email_accounts,
|
|
p.updated_at, p.created_at
|
|
FROM plans p
|
|
LEFT JOIN durations d ON d.id = p.duration_id
|
|
` + whereClause + `
|
|
ORDER BY p.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
|
|
var duration *string
|
|
err := rows.Scan(
|
|
&p.ID, &p.Name, &p.MaxContacts, &p.DailyEmails, &p.AIGeneration, &p.AccountLimit,
|
|
&p.Price, &p.DiscountedPrice, &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
|
|
}
|
|
if duration != nil {
|
|
p.Duration = models.Duration(*duration)
|
|
}
|
|
plans = append(plans, p)
|
|
}
|
|
|
|
return plans, nil
|
|
}
|
|
|
|
// SearchPlansForAdmin is the faceted + cursor-paginated plan catalog query.
|
|
// Mirrors SearchOrganizationsForAdmin. Plans is a small table so the pager is
|
|
// usually inert, but the {data,pagination} envelope keeps the Explorer stack
|
|
// uniform.
|
|
func (r *adminRepository) SearchPlansForAdmin(ctx context.Context, search *models.AdminPlanSearch) (*models.AdminPlansResult, error) {
|
|
limit := search.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
args := []interface{}{}
|
|
argNum := 1
|
|
where := "WHERE 1=1"
|
|
|
|
if search.Query != "" {
|
|
where += ` AND (p.name ILIKE $` + itoa(argNum) + ` OR p.stripe_price_id ILIKE $` + itoa(argNum) + ` OR p.stripe_product_id ILIKE $` + itoa(argNum) + `)`
|
|
args = append(args, "%"+search.Query+"%")
|
|
argNum++
|
|
}
|
|
switch search.Visibility {
|
|
case "public":
|
|
where += ` AND p.public = TRUE`
|
|
case "private":
|
|
where += ` AND p.public = FALSE`
|
|
}
|
|
if search.Duration != "" {
|
|
where += ` AND d.title = $` + itoa(argNum)
|
|
args = append(args, search.Duration)
|
|
argNum++
|
|
}
|
|
if search.AIGeneration {
|
|
where += ` AND p.ai_generation = TRUE`
|
|
}
|
|
if search.HasStripe {
|
|
where += ` AND p.stripe_price_id IS NOT NULL AND p.stripe_price_id <> ''`
|
|
}
|
|
if search.HasSubscribers {
|
|
where += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.plan_id = p.id)`
|
|
}
|
|
|
|
addInt := func(frag string, v *int) {
|
|
if v != nil {
|
|
where += " AND " + fmt.Sprintf(frag, argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addAfter := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
where += " AND " + col + " >= $" + itoa(argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addBefore := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
addInt(`p.price >= $%d`, search.PriceMin)
|
|
addInt(`p.price <= $%d`, search.PriceMax)
|
|
addInt(`p.daily_emails >= $%d`, search.DailyEmailsMin)
|
|
addInt(`p.daily_emails <= $%d`, search.DailyEmailsMax)
|
|
addInt(`p.account_limit >= $%d`, search.AccountLimitMin)
|
|
addInt(`p.account_limit <= $%d`, search.AccountLimitMax)
|
|
|
|
if search.CreatedWithin > 0 {
|
|
where += ` AND p.created_at >= NOW() - ($` + itoa(argNum) + `::int * INTERVAL '1 day')`
|
|
args = append(args, search.CreatedWithin)
|
|
argNum++
|
|
}
|
|
addAfter("p.created_at", search.CreatedAfter)
|
|
addBefore("p.created_at", search.CreatedBefore)
|
|
|
|
if search.Cursor != nil {
|
|
where += ` AND p.id < $` + itoa(argNum)
|
|
args = append(args, *search.Cursor)
|
|
argNum++
|
|
}
|
|
|
|
orderCol := "p.price"
|
|
switch search.SortBy {
|
|
case "name":
|
|
orderCol = "p.name"
|
|
case "daily_emails":
|
|
orderCol = "p.daily_emails"
|
|
case "account_limit":
|
|
orderCol = "p.account_limit"
|
|
case "created_at":
|
|
orderCol = "p.created_at"
|
|
}
|
|
// Catalog defaults to cheapest-first (ASC); explicit sorts honor sort_desc.
|
|
orderDir := "ASC"
|
|
if search.SortBy != "" && search.SortDesc {
|
|
orderDir = "DESC"
|
|
}
|
|
orderBy := "ORDER BY " + orderCol + " " + orderDir + ", p.id DESC"
|
|
|
|
args = append(args, limit+1)
|
|
|
|
query := `
|
|
SELECT p.id, p.name, p.max_contacts, p.daily_emails, p.ai_generation, p.account_limit,
|
|
p.price, p.discounted_price, d.title, p.savings, p.public,
|
|
p.stripe_price_id, p.stripe_product_id, p.dedicated_workers, p.daily_campaign_limit,
|
|
p.max_campaigns, p.max_active_campaigns, p.max_team_members, p.max_email_accounts,
|
|
p.updated_at, p.created_at
|
|
FROM plans p
|
|
LEFT JOIN durations d ON d.id = p.duration_id
|
|
` + where + `
|
|
` + orderBy + `
|
|
LIMIT $` + itoa(argNum)
|
|
|
|
rows, err := r.db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
plans := []models.Plan{}
|
|
for rows.Next() {
|
|
var p models.Plan
|
|
var duration *string
|
|
if err := rows.Scan(
|
|
&p.ID, &p.Name, &p.MaxContacts, &p.DailyEmails, &p.AIGeneration, &p.AccountLimit,
|
|
&p.Price, &p.DiscountedPrice, &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,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if duration != nil {
|
|
p.Duration = models.Duration(*duration)
|
|
}
|
|
plans = append(plans, p)
|
|
}
|
|
|
|
result := &models.AdminPlansResult{
|
|
Data: plans,
|
|
Pagination: models.Pagination{HasMore: len(plans) > limit},
|
|
}
|
|
if len(plans) > limit {
|
|
result.Data = plans[:limit]
|
|
last := plans[limit-1].ID
|
|
result.Pagination.NextCursor = &last
|
|
}
|
|
|
|
countQuery := `SELECT COUNT(*) FROM plans p LEFT JOIN durations d ON d.id = p.duration_id ` + where
|
|
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
|
|
}
|
|
|
|
// 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 with the shared faceted
|
|
// search params; mirrors SearchOrganizationsForAdmin (incremental WHERE builder,
|
|
// id keyset, LIMIT+1 has_more, separate COUNT).
|
|
func (r *adminRepository) ListEnterpriseInquiries(ctx context.Context, search *models.AdminEnterpriseInquirySearch) (*models.AdminEnterpriseInquiriesResult, error) {
|
|
limit := search.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
args := []interface{}{}
|
|
argNum := 1
|
|
whereClause := "WHERE 1=1"
|
|
|
|
if search.Query != "" {
|
|
whereClause += " AND (ei.company_name ILIKE $" + itoa(argNum) + " OR ei.contact_name ILIKE $" + itoa(argNum) + " OR ei.contact_email ILIKE $" + itoa(argNum) + " OR COALESCE(u.email,'') ILIKE $" + itoa(argNum) + ")"
|
|
args = append(args, "%"+search.Query+"%")
|
|
argNum++
|
|
}
|
|
if search.Status != "" && search.Status != "all" {
|
|
whereClause += " AND ei.status = $" + itoa(argNum)
|
|
args = append(args, search.Status)
|
|
argNum++
|
|
}
|
|
switch search.Assignment {
|
|
case "assigned":
|
|
whereClause += " AND ei.assigned_to IS NOT NULL"
|
|
case "unassigned":
|
|
whereClause += " AND ei.assigned_to IS NULL"
|
|
}
|
|
switch search.Linkage {
|
|
case "linked":
|
|
whereClause += " AND ei.user_id IS NOT NULL"
|
|
case "anonymous":
|
|
whereClause += " AND ei.user_id IS NULL"
|
|
}
|
|
if search.HasNotes {
|
|
whereClause += " AND ei.notes IS NOT NULL AND ei.notes <> ''"
|
|
}
|
|
if search.HasPhone {
|
|
whereClause += " AND ei.phone IS NOT NULL AND ei.phone <> ''"
|
|
}
|
|
if search.Processed {
|
|
whereClause += " AND ei.processed_at IS NOT NULL"
|
|
}
|
|
|
|
addInt := func(frag string, v *int) {
|
|
if v != nil {
|
|
whereClause += " AND " + fmt.Sprintf(frag, argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addAfter := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " >= $" + itoa(argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addBefore := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
addInt(`ei.team_size >= $%d`, search.TeamSizeMin)
|
|
addInt(`ei.team_size <= $%d`, search.TeamSizeMax)
|
|
addInt(`ei.estimated_volume >= $%d`, search.EstimatedVolumeMin)
|
|
addInt(`ei.estimated_volume <= $%d`, search.EstimatedVolumeMax)
|
|
|
|
if search.CreatedWithin > 0 {
|
|
whereClause += " AND ei.created_at >= NOW() - ($" + itoa(argNum) + "::int * INTERVAL '1 day')"
|
|
args = append(args, search.CreatedWithin)
|
|
argNum++
|
|
}
|
|
addAfter("ei.created_at", search.CreatedAfter)
|
|
addBefore("ei.created_at", search.CreatedBefore)
|
|
addAfter("COALESCE(ei.updated_at, ei.created_at)", search.UpdatedAfter)
|
|
addBefore("COALESCE(ei.updated_at, ei.created_at)", search.UpdatedBefore)
|
|
|
|
if search.Cursor != nil {
|
|
whereClause += " AND ei.id < $" + itoa(argNum)
|
|
args = append(args, *search.Cursor)
|
|
argNum++
|
|
}
|
|
|
|
orderCol := "ei.created_at"
|
|
switch search.SortBy {
|
|
case "updated_at":
|
|
orderCol = "COALESCE(ei.updated_at, ei.created_at)"
|
|
case "company_name":
|
|
orderCol = "ei.company_name"
|
|
case "contact_email":
|
|
orderCol = "ei.contact_email"
|
|
case "status":
|
|
orderCol = "ei.status"
|
|
case "team_size":
|
|
orderCol = "ei.team_size"
|
|
case "estimated_volume":
|
|
orderCol = "ei.estimated_volume"
|
|
}
|
|
orderDir := "DESC"
|
|
if search.SortBy != "" && !search.SortDesc {
|
|
orderDir = "ASC"
|
|
}
|
|
orderBy := "ORDER BY " + orderCol + " " + orderDir + ", ei.id DESC"
|
|
|
|
args = append(args, limit+1)
|
|
|
|
query := `
|
|
SELECT ei.id, ei.user_id, ei.company_name, ei.contact_name, ei.contact_email,
|
|
ei.phone, ei.team_size, ei.estimated_volume, ei.monthly_email_volume, ei.message,
|
|
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 + `
|
|
` + orderBy + `
|
|
LIMIT $` + itoa(argNum)
|
|
|
|
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 teamSize *int // team_size is an integer column; the DTO exposes it as a string
|
|
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, &teamSize, &inq.EstimatedVolume, &inq.MonthlyEmailVolume, &inq.Message,
|
|
&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 teamSize != nil {
|
|
s := fmt.Sprintf("%d", *teamSize)
|
|
inq.TeamSize = &s
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Total count for the same filter — drop the trailing LIMIT arg.
|
|
countQuery := `SELECT COUNT(*) 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
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SearchMailboxesForAdmin lists connected mailboxes platform-wide with
|
|
// owner + workspace joined. Cursor pagination by ea.id descending —
|
|
// admin tables don't sort by anything more interesting most of the
|
|
// time and that keeps the query plan trivial.
|
|
func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *models.AdminMailboxSearch) (*models.AdminMailboxesResult, error) {
|
|
limit := search.Limit
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
args := []interface{}{}
|
|
argNum := 1
|
|
where := "WHERE 1=1"
|
|
|
|
if search.Query != "" {
|
|
where += ` AND (ea.email ILIKE $` + itoa(argNum) +
|
|
` OR u.email ILIKE $` + itoa(argNum) +
|
|
` OR o.name ILIKE $` + itoa(argNum) + `)`
|
|
args = append(args, "%"+search.Query+"%")
|
|
argNum++
|
|
}
|
|
switch search.Status {
|
|
case "", "active":
|
|
where += " AND ea.status = 'active'"
|
|
case "inactive":
|
|
where += " AND ea.status <> 'active'"
|
|
case "all":
|
|
// no-op
|
|
default:
|
|
where += " AND ea.status = $" + itoa(argNum)
|
|
args = append(args, search.Status)
|
|
argNum++
|
|
}
|
|
if search.Provider != "" {
|
|
where += " AND ea.provider = $" + itoa(argNum)
|
|
args = append(args, search.Provider)
|
|
argNum++
|
|
}
|
|
switch search.Warmup {
|
|
case "on":
|
|
where += " AND ea.warmup IS NOT NULL"
|
|
case "off":
|
|
where += " AND ea.warmup IS NULL"
|
|
}
|
|
if search.CreatedWithin > 0 {
|
|
where += " AND ea.created_at >= NOW() - ($" + itoa(argNum) + "::int * INTERVAL '1 day')"
|
|
args = append(args, search.CreatedWithin)
|
|
argNum++
|
|
}
|
|
if search.OrgID != nil {
|
|
where += " AND ea.organization_id = $" + itoa(argNum)
|
|
args = append(args, *search.OrgID)
|
|
argNum++
|
|
}
|
|
|
|
// Local clause helpers in the established argNum/itoa style.
|
|
addInt := func(frag string, v *int) {
|
|
if v != nil {
|
|
where += " AND " + fmt.Sprintf(frag, argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addAfter := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
where += " AND " + col + " >= $" + itoa(argNum)
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
addBefore := func(col string, v *time.Time) {
|
|
if v != nil {
|
|
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
|
args = append(args, *v)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
// Ownership / placement
|
|
if search.UserID != nil {
|
|
where += " AND ea.user_id = $" + itoa(argNum)
|
|
args = append(args, *search.UserID)
|
|
argNum++
|
|
}
|
|
if search.WorkerID != nil {
|
|
where += " AND ea.worker_id = $" + itoa(argNum)
|
|
args = append(args, *search.WorkerID)
|
|
argNum++
|
|
}
|
|
|
|
// Classification
|
|
if search.RiskBand != "" {
|
|
where += " AND ea.risk_band::text = $" + itoa(argNum)
|
|
args = append(args, search.RiskBand)
|
|
argNum++
|
|
}
|
|
if search.WarmupPoolType != "" {
|
|
where += " AND ea.warmup_pool_type = $" + itoa(argNum)
|
|
args = append(args, search.WarmupPoolType)
|
|
argNum++
|
|
}
|
|
switch search.SyncedStatus {
|
|
case "never":
|
|
where += " AND ea.last_synced_at IS NULL"
|
|
case "stale":
|
|
where += " AND ea.last_synced_at IS NOT NULL AND NOW() - ea.last_synced_at > INTERVAL '24 hours'"
|
|
case "recent":
|
|
where += " AND ea.last_synced_at IS NOT NULL AND NOW() - ea.last_synced_at <= INTERVAL '24 hours'"
|
|
}
|
|
|
|
// Flags
|
|
if search.WarmupPaused {
|
|
where += " AND ea.warmup_paused_at IS NOT NULL"
|
|
}
|
|
if search.TrackingDomainVerified {
|
|
where += " AND ea.tracking_domain_verified = TRUE"
|
|
}
|
|
if search.HasTrackingDomain {
|
|
where += " AND ea.tracking_domain IS NOT NULL AND ea.tracking_domain <> ''"
|
|
}
|
|
if search.HasOrganization {
|
|
where += " AND ea.organization_id IS NOT NULL"
|
|
}
|
|
if search.SignatureSync {
|
|
where += " AND ea.signature_sync = TRUE"
|
|
}
|
|
if search.HasOAuth {
|
|
where += " AND EXISTS (SELECT 1 FROM email_accounts_oauth eao WHERE eao.email_account_id = ea.id)"
|
|
}
|
|
if search.HasSMTPImap {
|
|
where += " AND EXISTS (SELECT 1 FROM email_accounts_smtp_imap easi WHERE easi.email_account_id = ea.id)"
|
|
}
|
|
|
|
// Numeric ranges
|
|
addInt("ea.campaign_limit >= $%d", search.CampaignLimitMin)
|
|
addInt("ea.campaign_limit <= $%d", search.CampaignLimitMax)
|
|
addInt("ea.min_wait_time >= $%d", search.MinWaitTimeMin)
|
|
addInt("ea.min_wait_time <= $%d", search.MinWaitTimeMax)
|
|
|
|
// Date ranges
|
|
addAfter("ea.created_at", search.CreatedAfter)
|
|
addBefore("ea.created_at", search.CreatedBefore)
|
|
addAfter("ea.last_synced_at", search.LastSyncedAfter)
|
|
addBefore("ea.last_synced_at", search.LastSyncedBefore)
|
|
|
|
if search.Cursor != nil {
|
|
where += " AND ea.id < $" + itoa(argNum)
|
|
args = append(args, *search.Cursor)
|
|
argNum++
|
|
}
|
|
|
|
orderCol := "ea.id"
|
|
switch search.SortBy {
|
|
case "email":
|
|
orderCol = "ea.email"
|
|
case "created_at":
|
|
orderCol = "ea.created_at"
|
|
case "last_synced_at":
|
|
orderCol = "ea.last_synced_at"
|
|
case "campaign_limit":
|
|
orderCol = "ea.campaign_limit"
|
|
}
|
|
orderDir := "DESC"
|
|
if search.SortBy != "" && !search.SortDesc {
|
|
orderDir = "ASC"
|
|
}
|
|
orderBy := "ORDER BY " + orderCol + " " + orderDir
|
|
if orderCol != "ea.id" {
|
|
orderBy += ", ea.id DESC"
|
|
}
|
|
|
|
args = append(args, limit+1)
|
|
limitParam := "$" + itoa(argNum)
|
|
|
|
query := `
|
|
SELECT ea.id, ea.email, ea.provider::text, ea.status::text,
|
|
ea.user_id, u.email,
|
|
ea.organization_id, o.name,
|
|
ea.worker_id,
|
|
(ea.warmup IS NOT NULL) AS warmup_enabled,
|
|
ea.risk_band::text, ea.warmup_pool_type,
|
|
ea.campaign_limit, ea.last_synced_at, ea.created_at
|
|
FROM email_accounts ea
|
|
JOIN users u ON u.id = ea.user_id
|
|
LEFT JOIN organizations o ON o.id = ea.organization_id
|
|
` + where + `
|
|
` + orderBy + `
|
|
LIMIT ` + limitParam
|
|
|
|
rows, err := r.db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []models.AdminMailboxRow{}
|
|
for rows.Next() {
|
|
var m models.AdminMailboxRow
|
|
if err := rows.Scan(
|
|
&m.ID, &m.Email, &m.Provider, &m.Status,
|
|
&m.UserID, &m.OwnerEmail,
|
|
&m.OrganizationID, &m.OrgName,
|
|
&m.WorkerID,
|
|
&m.WarmupEnabled,
|
|
&m.RiskBand, &m.WarmupPoolType,
|
|
&m.CampaignLimit, &m.LastSyncedAt, &m.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, m)
|
|
}
|
|
|
|
result := &models.AdminMailboxesResult{
|
|
Data: items,
|
|
Pagination: models.Pagination{HasMore: len(items) > limit},
|
|
}
|
|
if len(items) > limit {
|
|
result.Data = items[:limit]
|
|
last := items[limit-1].ID
|
|
result.Pagination.NextCursor = &last
|
|
}
|
|
|
|
// Total count for the same filter (drop the trailing LIMIT arg).
|
|
countQuery := `
|
|
SELECT COUNT(*) FROM email_accounts ea
|
|
JOIN users u ON u.id = ea.user_id
|
|
LEFT JOIN organizations o ON o.id = ea.organization_id ` + where
|
|
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
|
|
}
|
|
|
|
// Helper functions
|
|
func itoa(i int) string {
|
|
return strconv.Itoa(i)
|
|
}
|
|
|
|
func joinStrings(strs []string, sep string) string {
|
|
return strings.Join(strs, sep)
|
|
}
|