mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
720 lines
22 KiB
Go
720 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/MicahParks/keyfunc/v3"
|
|
awsconf "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/meszmate/apple-go"
|
|
"github.com/meszmate/google-go"
|
|
"github.com/warmbly/warmbly/internal/api"
|
|
"github.com/warmbly/warmbly/internal/api/handler"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/admin"
|
|
"github.com/warmbly/warmbly/internal/app/advanced"
|
|
"github.com/warmbly/warmbly/internal/app/apikey"
|
|
"github.com/warmbly/warmbly/internal/app/audit"
|
|
"github.com/warmbly/warmbly/internal/app/auth"
|
|
"github.com/warmbly/warmbly/internal/app/campaign"
|
|
"github.com/warmbly/warmbly/internal/app/cipher"
|
|
"github.com/warmbly/warmbly/internal/app/contact"
|
|
"github.com/warmbly/warmbly/internal/app/crm"
|
|
"github.com/warmbly/warmbly/internal/app/dangerzone"
|
|
"github.com/warmbly/warmbly/internal/app/email"
|
|
"github.com/warmbly/warmbly/internal/app/emailsend"
|
|
"github.com/warmbly/warmbly/internal/app/feature"
|
|
"github.com/warmbly/warmbly/internal/app/group"
|
|
"github.com/warmbly/warmbly/internal/app/organization"
|
|
"github.com/warmbly/warmbly/internal/app/releases"
|
|
"github.com/warmbly/warmbly/internal/app/sequence"
|
|
"github.com/warmbly/warmbly/internal/app/socket"
|
|
"github.com/warmbly/warmbly/internal/app/stripe"
|
|
"github.com/warmbly/warmbly/internal/app/subscription"
|
|
"github.com/warmbly/warmbly/internal/app/template"
|
|
"github.com/warmbly/warmbly/internal/app/token"
|
|
"github.com/warmbly/warmbly/internal/app/trial"
|
|
"github.com/warmbly/warmbly/internal/app/tz"
|
|
"github.com/warmbly/warmbly/internal/app/unibox"
|
|
"github.com/warmbly/warmbly/internal/app/user"
|
|
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
|
|
"github.com/warmbly/warmbly/internal/app/worker"
|
|
"github.com/warmbly/warmbly/internal/app/worker_orchestrator"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/events"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/dynamo"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/gtasks"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/kafka"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/kms"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/storage"
|
|
"github.com/warmbly/warmbly/internal/jobs"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/notify"
|
|
"github.com/warmbly/warmbly/internal/observability"
|
|
"github.com/warmbly/warmbly/internal/pkg/captcha"
|
|
"github.com/warmbly/warmbly/internal/pkg/geo"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
"github.com/warmbly/warmbly/internal/scheduler"
|
|
"github.com/warmbly/warmbly/internal/tasks"
|
|
)
|
|
|
|
func main() {
|
|
var addr string
|
|
var ginMode string
|
|
var websocketURI string
|
|
var allowedOrigins []string
|
|
|
|
var tzService tz.TzService
|
|
|
|
var serviceAccount string
|
|
var keySet keyfunc.Keyfunc
|
|
|
|
var tokenService token.TokenService
|
|
var authService auth.AuthService
|
|
var userService user.UserService
|
|
var emailService email.EmailService
|
|
var campaignService campaign.CampaignService
|
|
var sequenceService sequence.SequenceService
|
|
var contactService contact.ContactService
|
|
var socketService socket.SocketService
|
|
var uniboxService unibox.UniboxService
|
|
var cipherService cipher.CipherService
|
|
var tasksService tasks.TasksService
|
|
var advancedService advanced.Service
|
|
|
|
var folderService group.GroupService
|
|
var tagService group.GroupService
|
|
var categoryService group.GroupService
|
|
var crmService crm.CRMService
|
|
var apiKeyService apikey.APIKeyService
|
|
|
|
// New services for trial, feature gates, and worker assignment
|
|
var trialService trial.TrialService
|
|
var featureGateService feature.FeatureGateService
|
|
var workerAssignmentService worker.WorkerAssignmentService
|
|
var subscriptionService subscription.SubscriptionService
|
|
var stripeService stripe.StripeService
|
|
var organizationService organization.OrganizationService
|
|
|
|
// Email send & templates
|
|
var templateService template.TemplateService
|
|
var emailSendService emailsend.EmailSendService
|
|
|
|
// Admin
|
|
var adminService admin.AdminService
|
|
|
|
// Worker orchestrator (SSH-driven admin worker lifecycle)
|
|
var workerOrchestrator *worker_orchestrator.Orchestrator
|
|
var workerRepoForHandler repository.WorkerRepository
|
|
var credentialsRepository repository.CredentialsRepository
|
|
var releasesService *releases.Service
|
|
|
|
// Notifications
|
|
var emailNotificationService notify.EmailNotificationService
|
|
|
|
// Warmup
|
|
var warmupService warmupapp.Service
|
|
|
|
// Danger zone (delayed deletions)
|
|
var dangerZoneService dangerzone.Service
|
|
|
|
// Pub/Sub for realtime streaming
|
|
var streamingPublisher *pubsub.StreamingPublisher
|
|
|
|
// Surfaced into the handler for avatar uploads and other direct
|
|
// repository / object-storage needs. Declared up here so they
|
|
// survive the config block where they're initialized.
|
|
var s3ForHandler *storage.Client
|
|
var userRepoForHandler repository.UserRepository
|
|
var organizationRepoForHandler repository.OrganizationRepository
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
{
|
|
|
|
// Load config with env-first approach
|
|
cfg, err := config.NewConfig(ctx)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err := observability.InitSentry(ctx, cfg, "backend"); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
serviceAccount, err = cfg.LoadGoogleServiceAccount(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
keySet, err = keyfunc.NewDefaultCtx(ctx, []string{"https://www.googleapis.com/oauth2/v3/certs"})
|
|
if err != nil {
|
|
if cfg.Env == "dev" {
|
|
log.Printf("Warning: Failed to fetch Google OIDC keys: %v", err)
|
|
} else {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
apiCfg, err := cfg.LoadApiConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// AWS config for services that need it (KMS, S3, DynamoDB)
|
|
awscfg, err := awsconf.LoadDefaultConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var masterKey string = "alias/master-key"
|
|
if cfg.Env != "prod" {
|
|
masterKey += "-dev"
|
|
}
|
|
|
|
kms, err := kms.New(ctx, awscfg, masterKey)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
geoPath, err := cfg.LoadGeoDBPath(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var geoloc *geo.Client
|
|
geoloc, err = geo.New(geoPath)
|
|
if err != nil {
|
|
if cfg.Env == "dev" {
|
|
log.Printf("Warning: GeoIP database not found at %s, geo lookups disabled", geoPath)
|
|
} else {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
s3, err := storage.NewClient(ctx, awscfg, "main")
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
s3ForHandler = s3
|
|
|
|
primaryDBEndpoint, err := cfg.LoadPrimaryDBEndpoint(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
primaryDB, err := db.New(ctx, primaryDBEndpoint)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Run database migrations
|
|
log.Println("Running database migrations...")
|
|
if err := db.RunMigrations(primaryDBEndpoint); err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal("Failed to run migrations: ", err)
|
|
}
|
|
log.Println("Database migrations completed")
|
|
|
|
dynamoDB, err := dynamo.NewClient(ctx, awscfg)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
primaryRedis, err := cfg.LoadPrimaryRedisEndpoint(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
cache, err := cache.New(primaryRedis)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Google Pub/Sub for realtime streaming (optional)
|
|
gcpProjectID := os.Getenv("GCP_PROJECT_ID")
|
|
if gcpProjectID != "" {
|
|
pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Printf("Warning: Failed to initialize Pub/Sub client: %v", err)
|
|
} else {
|
|
streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient)
|
|
}
|
|
}
|
|
|
|
emailCfg, err := cfg.LoadEmailConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
smtpCfg := cfg.LoadSMTPConfig(ctx)
|
|
if smtpCfg != nil {
|
|
emailNotificationService = notify.NewSMTPEmailNotificationService(
|
|
emailCfg.EmailName,
|
|
emailCfg.EmailAddress,
|
|
smtpCfg.Host,
|
|
smtpCfg.Port,
|
|
)
|
|
} else {
|
|
emailNotificationService, err = notify.NewEmailNotficiationService(
|
|
ctx,
|
|
emailCfg.EmailName,
|
|
emailCfg.EmailAddress,
|
|
)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
authCfg, err := cfg.LoadAuthConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
googleAuth := google.NewAuth(
|
|
authCfg.GoogleClientID,
|
|
authCfg.GoogleClientSecret,
|
|
authCfg.GoogleRedirectURI,
|
|
nil,
|
|
)
|
|
|
|
var appleAuthClient apple.AppleAuth
|
|
appleAuthInstance, appleErr := apple.NewB64(
|
|
authCfg.AppleAppID,
|
|
authCfg.AppleTeamID,
|
|
authCfg.AppleKeyID,
|
|
authCfg.AppleKeySecret,
|
|
)
|
|
if appleErr != nil {
|
|
if cfg.Env == "dev" {
|
|
log.Printf("Warning: Apple auth initialization failed (expected in dev): %v", appleErr)
|
|
} else {
|
|
sentry.CaptureException(appleErr)
|
|
log.Fatal(appleErr)
|
|
}
|
|
} else {
|
|
appleAuthClient = appleAuthInstance
|
|
}
|
|
|
|
kafkaBootstrapServers, err := cfg.LoadKafkaBootstrapServers(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
kafkaSaslConfig, err := cfg.LoadKafkaConfigSasl(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
schemaEndpoint, schemaKey, schemaSecret, err := cfg.LoadSchemaRegistryConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
avrov2Client, err := kafka.NewAvrov2Client(schemaEndpoint, schemaKey, schemaSecret)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
kafkaProducerConfig := kafka.NewProducer(kafkaBootstrapServers)
|
|
if kafkaSaslConfig != nil {
|
|
kafkaProducerConfig.WithSASL(kafkaSaslConfig)
|
|
}
|
|
|
|
kafkaProducer, err := kafkaProducerConfig.Connect()
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
kafkaProducer.WithAvrov2(avrov2Client)
|
|
|
|
turnstileBypassToken := ""
|
|
if cfg.Env == "dev" {
|
|
turnstileBypassToken = authCfg.TurnstileBypass
|
|
if turnstileBypassToken == "" {
|
|
turnstileBypassToken = "warmbly-local-turnstile-bypass"
|
|
}
|
|
}
|
|
|
|
captcha := captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{
|
|
Secret: authCfg.TurnstileSecret,
|
|
BypassToken: turnstileBypassToken,
|
|
})
|
|
|
|
userRepostory := repository.NewUserRepostory(primaryDB, kms)
|
|
userRepoForHandler = userRepostory
|
|
authRepostory := repository.NewAuthRepostory(primaryDB)
|
|
tokenRepostory := repository.NewTokenRepostory(primaryDB)
|
|
emailRepostory := repository.NewEmailRepostory(primaryDB)
|
|
campaignRepostory := repository.NewCampaignRepostory(primaryDB)
|
|
sequenceRepostory := repository.NewSequenceRepostory(primaryDB)
|
|
contactRepostory := repository.NewContactRepostory(primaryDB)
|
|
uniboxRepository := repository.NewUniboxRepository(primaryDB)
|
|
userEncryptedKeysRepository := repository.NewUserEncryptedKeysRepository(kms, dynamoDB)
|
|
|
|
folderRepostory := repository.NewGroupRepostory(primaryDB, models.Folders)
|
|
tagRepostory := repository.NewGroupRepostory(primaryDB, models.Tags)
|
|
categoryRepostory := repository.NewGroupRepostory(primaryDB, models.Categories)
|
|
|
|
// New repositories for subscription & worker management
|
|
subscriptionRepository := repository.NewSubscriptionRepository(primaryDB.Pool)
|
|
planRepository := repository.NewPlanRepository(primaryDB.Pool)
|
|
workerRepository := repository.NewWorkerRepository(primaryDB.Pool)
|
|
organizationRepository := repository.NewOrganizationRepository(primaryDB.Pool)
|
|
organizationRepoForHandler = organizationRepository
|
|
taskRepository := repository.NewTaskRepository(primaryDB.Pool)
|
|
apiKeyRepository := repository.NewAPIKeyRepository(primaryDB)
|
|
crmRepository := repository.NewCRMRepository(primaryDB.Pool)
|
|
advancedRepository := repository.NewAdvancedOutreachRepository(primaryDB.Pool)
|
|
templateRepository := repository.NewTemplateRepository(primaryDB.Pool)
|
|
warmupRepository := repository.NewWarmupRepository(primaryDB.Pool)
|
|
campaignProgressRepository := repository.NewCampaignProgressRepository(primaryDB.Pool)
|
|
campaignLogRepository := repository.NewCampaignLogRepository(primaryDB)
|
|
warmupService = warmupapp.NewService(warmupRepository)
|
|
|
|
tzService = tz.NewService()
|
|
|
|
// Initialize new services for trial, feature gates, and worker assignment
|
|
trialService = trial.NewService(subscriptionRepository, userRepostory)
|
|
featureGateService = feature.NewService(subscriptionRepository, planRepository)
|
|
workerAssignmentService = worker.NewAssignmentService(workerRepository, subscriptionRepository, planRepository)
|
|
subscriptionService = subscription.NewService(subscriptionRepository, planRepository)
|
|
organizationService = organization.NewService(organizationRepository, subscriptionRepository, userRepostory)
|
|
|
|
// Load Stripe config and initialize service
|
|
stripeCfg, err := cfg.LoadStripeConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
stripeService = stripe.NewService(stripeCfg, subscriptionRepository, planRepository, workerAssignmentService)
|
|
|
|
tokenService = token.NewService(primaryDB, tokenRepostory, cache, geoloc, authCfg.AuthSecret)
|
|
userService = user.NewService(userRepostory, cache)
|
|
authService = auth.NewService(
|
|
authRepostory,
|
|
cache,
|
|
captcha,
|
|
tokenService,
|
|
emailNotificationService,
|
|
&models.ExternalAuth{
|
|
GoogleAuth: googleAuth,
|
|
AppleAuth: appleAuthClient,
|
|
},
|
|
trialService,
|
|
organizationService,
|
|
userRepostory,
|
|
userService,
|
|
)
|
|
cipherService = cipher.NewService(kms, cache, userEncryptedKeysRepository)
|
|
|
|
// Worker orchestrator. The env config below is the FALLBACK that gets
|
|
// written into /etc/warmbly/worker.env when a worker has no profile
|
|
// assigned. Production workers should reference a worker_profile row;
|
|
// dev/sim can rely on the fallback so docker-compose still works.
|
|
workerRepoForHandler = workerRepository
|
|
credentialsRepository = repository.NewCredentialsRepository(primaryDB.Pool)
|
|
workerOrchestrator = worker_orchestrator.New(
|
|
workerRepository,
|
|
credentialsRepository,
|
|
cipherService,
|
|
worker_orchestrator.WorkerEnvConfig{
|
|
AppEnv: os.Getenv("APP_ENV"),
|
|
WorkerImage: getenvDefault("WORKER_IMAGE", "ghcr.io/warmbly/worker:latest"),
|
|
KafkaBootstrap: os.Getenv("KAFKA_BOOTSTRAP_SERVERS"),
|
|
KafkaSASLUsername: os.Getenv("KAFKA_SASL_USERNAME"),
|
|
KafkaSASLPassword: os.Getenv("KAFKA_SASL_PASSWORD"),
|
|
SchemaRegistryURL: os.Getenv("SCHEMA_REGISTRY_URL"),
|
|
SchemaRegistryKey: os.Getenv("SCHEMA_REGISTRY_KEY"),
|
|
SchemaRegistrySecret: os.Getenv("SCHEMA_REGISTRY_SECRET"),
|
|
RedisURL: os.Getenv("REDIS"),
|
|
AWSRegion: os.Getenv("AWS_REGION"),
|
|
AWSAccessKeyID: os.Getenv("WORKER_AWS_ACCESS_KEY_ID"),
|
|
AWSSecretAccessKey: os.Getenv("WORKER_AWS_SECRET_ACCESS_KEY"),
|
|
},
|
|
getenvDefault("WORKER_INSTALLER_PATH", "/app/scripts/install-worker.sh"),
|
|
)
|
|
|
|
// Releases service. Env-configurable so self-hosters can point at their
|
|
// own repo/registry, or disable the feature entirely.
|
|
releasesService = releases.New(
|
|
releases.Config{
|
|
Enabled: getenvDefault("RELEASES_ENABLED", "true") == "true",
|
|
GithubRepo: getenvDefault("RELEASES_GITHUB_REPO", "warmbly/warmbly"),
|
|
WorkerImageRepo: getenvDefault("RELEASES_WORKER_IMAGE_REPO", "ghcr.io/warmbly/warmbly/worker"),
|
|
WebhookSecret: os.Getenv("RELEASES_WEBHOOK_SECRET"),
|
|
GithubToken: os.Getenv("RELEASES_GITHUB_TOKEN"),
|
|
},
|
|
credentialsRepository,
|
|
workerRepository,
|
|
workerOrchestrator,
|
|
)
|
|
releasesService.RunBootCheck(ctx)
|
|
|
|
eventsPublisher := events.NewPublisher(kafkaProducer, s3, avrov2Client, cipherService)
|
|
|
|
oauth2Cfg := config.LoadOauth2(apiCfg.Hostname)
|
|
emailService = email.NewServiceWithKafka(
|
|
emailRepostory,
|
|
cipherService,
|
|
featureGateService,
|
|
warmupService,
|
|
eventsPublisher,
|
|
kafkaProducer,
|
|
cache,
|
|
&oauth2Cfg.InboxAuthorization,
|
|
workerAssignmentService,
|
|
)
|
|
campaignService = campaign.NewService(campaignRepostory, taskRepository, emailRepostory, campaignLogRepository, featureGateService, streamingPublisher)
|
|
sequenceService = sequence.NewService(sequenceRepostory)
|
|
contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository)
|
|
apiKeyService = apikey.NewService(cache, apiKeyRepository)
|
|
crmService = crm.NewService(crmRepository)
|
|
socketService = socket.NewService(cache, tokenService)
|
|
uniboxService = unibox.NewService(cache, s3, uniboxRepository)
|
|
|
|
// Cloud Tasks client
|
|
cloudTasksCfg, err := cfg.LoadCloudTasksConfig(ctx)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tasksClient, err := gtasks.NewClient(ctx, cloudTasksCfg.QueueName, cloudTasksCfg.WebhookURL, serviceAccount, cloudTasksCfg.EmulatorHost)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Template & email send services
|
|
templateService = template.NewService(templateRepository)
|
|
schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory)
|
|
emailSendService = emailsend.NewService(taskRepository, emailRepostory, schedulerService, tasksClient, featureGateService)
|
|
advancedService = advanced.NewService(
|
|
advancedRepository,
|
|
campaignRepostory,
|
|
emailRepostory,
|
|
taskRepository,
|
|
contactRepostory,
|
|
campaignProgressRepository,
|
|
crmRepository,
|
|
tasksClient,
|
|
warmupService,
|
|
)
|
|
emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher)
|
|
tasksService = tasks.NewService(
|
|
tasksClient,
|
|
kafkaProducer,
|
|
nil, // AI generation client is optional for task execution
|
|
streamingPublisher,
|
|
eventsPublisher,
|
|
schedulerService,
|
|
cipherService,
|
|
emailSender,
|
|
featureGateService,
|
|
warmupService,
|
|
taskRepository,
|
|
warmupRepository,
|
|
campaignProgressRepository,
|
|
emailRepostory,
|
|
campaignRepostory,
|
|
contactRepostory,
|
|
campaignLogRepository,
|
|
advancedService,
|
|
)
|
|
|
|
// Admin service
|
|
adminRepository := repository.NewAdminRepository(primaryDB.Pool)
|
|
adminService = admin.NewService(adminRepository)
|
|
|
|
folderService = group.NewService(folderRepostory)
|
|
tagService = group.NewService(tagRepostory)
|
|
categoryService = group.NewService(categoryRepostory)
|
|
|
|
// Start trial expiration job in background
|
|
trialExpirationJob := jobs.NewTrialExpirationJobWithDB(subscriptionRepository, primaryDB.Pool, emailNotificationService)
|
|
trialScheduler := jobs.NewTrialExpirationScheduler(trialExpirationJob, 1*time.Hour)
|
|
go trialScheduler.Start(ctx)
|
|
|
|
// Danger zone: schedule + execute delayed deletions (orgs, accounts).
|
|
dangerZoneRepository := repository.NewDangerZoneRepository(primaryDB.Pool)
|
|
dangerZoneService = dangerzone.NewService(
|
|
dangerZoneRepository,
|
|
organizationRepository,
|
|
userRepostory,
|
|
emailNotificationService,
|
|
os.Getenv("FRONTEND_BASE_URL"),
|
|
)
|
|
dangerZoneJob := jobs.NewDangerZoneJob(dangerZoneService)
|
|
dangerZoneScheduler := jobs.NewDangerZoneScheduler(dangerZoneJob, 1*time.Hour)
|
|
go dangerZoneScheduler.Start(ctx)
|
|
|
|
addr = apiCfg.Hostname
|
|
ginMode = apiCfg.GinMode
|
|
websocketURI = apiCfg.WebsocketURI
|
|
allowedOrigins = apiCfg.AllowedOrigins
|
|
}
|
|
|
|
h := &handler.Handler{
|
|
AuthService: authService,
|
|
TokenService: tokenService,
|
|
UserService: userService,
|
|
EmailService: emailService,
|
|
CampaignService: campaignService,
|
|
ContactService: contactService,
|
|
SequenceService: sequenceService,
|
|
UniboxService: uniboxService,
|
|
|
|
FolderService: folderService,
|
|
TagService: tagService,
|
|
CategoryService: categoryService,
|
|
|
|
TzService: tzService,
|
|
SocketService: socketService,
|
|
TasksService: tasksService,
|
|
|
|
// API Keys
|
|
APIKeyService: apiKeyService,
|
|
|
|
// Subscription & billing
|
|
SubscriptionService: subscriptionService,
|
|
StripeService: stripeService,
|
|
|
|
// Trial & feature gates
|
|
TrialService: trialService,
|
|
FeatureGateService: featureGateService,
|
|
WorkerAssignmentService: workerAssignmentService,
|
|
|
|
// Organization & IAM
|
|
OrganizationService: organizationService,
|
|
|
|
// CRM
|
|
CRMService: crmService,
|
|
|
|
// Email send & templates
|
|
TemplateService: templateService,
|
|
EmailSendService: emailSendService,
|
|
|
|
// Admin
|
|
AdminService: adminService,
|
|
|
|
// SSH-managed worker lifecycle
|
|
WorkerOrchestrator: workerOrchestrator,
|
|
WorkerRepo: workerRepoForHandler,
|
|
CredentialsRepo: credentialsRepository,
|
|
ReleasesService: releasesService,
|
|
|
|
// Notifications
|
|
EmailNotificationService: emailNotificationService,
|
|
|
|
// Advanced outreach controls
|
|
AdvancedService: advancedService,
|
|
|
|
// Warmup health
|
|
WarmupService: warmupService,
|
|
|
|
WebsocketURI: websocketURI,
|
|
|
|
// Object storage + direct repository handles for handlers
|
|
// without a dedicated service layer (avatars, etc.).
|
|
Storage: s3ForHandler,
|
|
UserRepo: userRepoForHandler,
|
|
OrgRepo: organizationRepoForHandler,
|
|
|
|
// Danger zone
|
|
DangerZoneService: dangerZoneService,
|
|
|
|
// Audit logs aren't persisted yet; install a no-op so the
|
|
// many h.AuditService.LogAction sites don't panic on a nil
|
|
// interface. Swap for audit.NewService(repo) when wiring the
|
|
// real repository.
|
|
AuditService: audit.NewNoOpService(),
|
|
}
|
|
|
|
m := &middleware.Handler{
|
|
TokenService: tokenService,
|
|
APIKeyService: apiKeyService,
|
|
OrganizationService: organizationService,
|
|
}
|
|
|
|
oidcH := &middleware.OidcHandler{
|
|
ServiceAccount: serviceAccount,
|
|
KeySet: keySet,
|
|
AppEnv: os.Getenv("APP_ENV"),
|
|
}
|
|
|
|
sentry.CaptureMessage("Starting the backend on " + addr)
|
|
|
|
router := api.Run(h, m, oidcH, addr, ginMode, allowedOrigins)
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: router,
|
|
}
|
|
|
|
// Start server in a goroutine
|
|
go func() {
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("Server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
log.Println("Backend started on", addr)
|
|
|
|
// Wait for interrupt signal for graceful shutdown
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigCh
|
|
|
|
log.Println("Shutting down backend...")
|
|
cancel()
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Printf("Server shutdown error: %v", err)
|
|
}
|
|
|
|
log.Println("Backend stopped")
|
|
}
|
|
|
|
func getenvDefault(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|