package app import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "qctextbuilder/internal/buildsvc" "qctextbuilder/internal/config" "qctextbuilder/internal/domain" "qctextbuilder/internal/draftsvc" "qctextbuilder/internal/httpserver" "qctextbuilder/internal/httpserver/handlers" "qctextbuilder/internal/httpserver/views" "qctextbuilder/internal/logging" "qctextbuilder/internal/mapping" "qctextbuilder/internal/onboarding" "qctextbuilder/internal/polling" "qctextbuilder/internal/qcclient" "qctextbuilder/internal/store" "qctextbuilder/internal/store/memory" "qctextbuilder/internal/store/sqlite" "qctextbuilder/internal/templatesvc" ) type App struct { server *httpserver.Server pollingSvc *polling.Service } func New(cfg config.Config) (*App, error) { logger := logging.New() var ( templateStore store.TemplateStore manifestStore store.ManifestStore buildStore store.BuildStore draftStore store.DraftStore settingsStore store.SettingsStore ) driver := strings.ToLower(strings.TrimSpace(cfg.DBDriver)) switch driver { case "", "sqlite": sqliteStore, err := sqlite.New(cfg.DBURL) if err != nil { return nil, fmt.Errorf("init sqlite store: %w", err) } templateStore = sqliteStore manifestStore = sqliteStore buildStore = sqliteStore draftStore = sqliteStore settingsStore = sqliteStore default: memStore := memory.New() templateStore = memStore manifestStore = memStore buildStore = memStore draftStore = memStore settingsStore = memStore } qc := qcclient.New(cfg.QCBaseURL, cfg.QCToken, 15*time.Second, logger) templateSvc := templatesvc.New(qc, templateStore, manifestStore) onboardSvc := onboarding.New(qc, templateStore, manifestStore) draftSvc := draftsvc.New(draftStore, templateStore, manifestStore) mappingSvc := mapping.New() buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) suggestionGenerator := mapping.NewCompositeSuggestionGenerator( mapping.NewLLMSuggestionGenerator(qc), mapping.NewRuleBasedSuggestionGenerator(), ) pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc) baseSettings := domain.AppSettings{ QCBaseURL: cfg.QCBaseURL, QCBearerTokenEncrypted: cfg.QCToken, LanguageOutputMode: "EN", JobPollIntervalSeconds: cfg.PollIntervalSeconds, JobPollTimeoutSeconds: cfg.PollTimeoutSeconds, LLMActiveProvider: domain.DefaultLLMProvider(), LLMActiveModel: domain.NormalizeLLMModel(domain.DefaultLLMProvider(), ""), MasterPrompt: domain.SeedMasterPrompt, PromptBlocks: domain.DefaultPromptBlocks(), } if existing, err := settingsStore.GetSettings(context.Background()); err == nil && existing != nil { baseSettings.LLMActiveProvider = existing.LLMActiveProvider baseSettings.LLMActiveModel = existing.LLMActiveModel baseSettings.LLMBaseURL = existing.LLMBaseURL baseSettings.OpenAIAPIKeyEncrypted = existing.OpenAIAPIKeyEncrypted baseSettings.AnthropicAPIKeyEncrypted = existing.AnthropicAPIKeyEncrypted baseSettings.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted baseSettings.MasterPrompt = existing.MasterPrompt baseSettings.PromptBlocks = existing.PromptBlocks } _ = settingsStore.UpsertSettings(context.Background(), baseSettings) renderer, err := views.NewRenderer("web/templates/*.gohtml") if err != nil { return nil, fmt.Errorf("init renderer: %w", err) } ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, settingsStore, suggestionGenerator, cfg, renderer) server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { r.Get("/", ui.Home) r.Get("/settings", ui.Settings) r.Post("/settings/llm", ui.SaveLLMSettings) r.Post("/settings/prompt", ui.SavePromptSettings) r.Get("/templates", ui.Templates) r.Post("/templates/sync", ui.SyncTemplates) r.Get("/templates/{id}", ui.TemplateDetail) r.Post("/templates/{id}/onboard", ui.OnboardTemplate) r.Post("/templates/{id}/fields", ui.UpdateTemplateFields) r.Get("/builds/new", ui.BuildNew) r.Post("/builds/drafts", ui.SaveDraft) r.Post("/builds/drafts/autofill", ui.AutofillDraft) r.Post("/builds", ui.CreateBuild) r.Get("/builds/{id}", ui.BuildDetail) r.Post("/builds/{id}/poll", ui.PollBuild) r.Post("/builds/{id}/fetch-editor-url", ui.FetchEditorURL) r.Get("/healthz", api.Health) r.Route("/api", func(r chi.Router) { r.Post("/templates/sync", api.SyncTemplates) r.Get("/templates", api.ListTemplates) r.Get("/templates/{id}", api.GetTemplateDetail) r.Post("/templates/{id}/onboard", api.OnboardTemplate) r.Put("/templates/{id}/fields", api.UpdateTemplateFields) r.Post("/drafts/intake", api.IntakeDraft) r.Get("/drafts", api.ListDrafts) r.Get("/drafts/{id}", api.GetDraft) r.Put("/drafts/{id}", api.UpdateDraft) r.Post("/site-builds", api.StartBuild) r.Get("/site-builds/{id}", api.GetBuild) r.Post("/site-builds/{id}/poll", api.PollBuildOnce) r.Post("/site-builds/{id}/fetch-editor-url", api.FetchBuildEditorURL) }) }) return &App{server: server, pollingSvc: pollingSvc}, nil } func (a *App) Run(ctx context.Context) error { go func() { if err := a.pollingSvc.Start(ctx); err != nil { // polling is best-effort in milestone-2; request flow works without supervisor } }() errCh := make(chan error, 1) go func() { if err := a.server.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- fmt.Errorf("http run: %w", err) } close(errCh) }() select { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return a.server.Shutdown(shutdownCtx) case err := <-errCh: return err } }