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