mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
2698 lines
82 KiB
Go
2698 lines
82 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"
|
|
"github.com/warmbly/warmbly/internal/utils/paging"
|
|
)
|
|
|
|
// AdminRepository defines the interface for admin data access
|
|
type AdminRepository interface {
|
|
// GrantBootstrapAdmin grants admin permissions to the first owner of a
|
|
// fresh install, where there is no existing admin to attribute the grant to.
|
|
GrantBootstrapAdmin(ctx context.Context, userID uuid.UUID, permissions uint32) error
|
|
|
|
// 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 JOIN organizations o ON o.id = dwa.organization_id WHERE o.owner_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 = paging.UUIDString(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
|
|
}
|
|
|
|
// GrantBootstrapAdmin grants admin permissions with no granter. It exists for
|
|
// the first owner of a fresh install, where by definition no admin exists to
|
|
// do the granting, and admin_granted_by has a foreign key to users so it must
|
|
// be NULL rather than a zero UUID.
|
|
func (r *adminRepository) GrantBootstrapAdmin(ctx context.Context, userID uuid.UUID, permissions uint32) error {
|
|
const query = `
|
|
UPDATE users SET
|
|
admin_permissions = $2,
|
|
admin_granted_at = NOW(),
|
|
admin_granted_by = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
_, err := r.db.Exec(ctx, query, userID, permissions)
|
|
return err
|
|
}
|
|
|
|
// 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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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 = paging.UUIDString(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)
|
|
}
|