| @@ -0,0 +1,6 @@ | |||||
| HTTP_ADDR=:8080 | |||||
| QC_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1 | |||||
| QC_TOKEN=6bcb61e064f2f058833a93a45d0505ac | |||||
| POLL_INTERVAL_SECONDS=5 | |||||
| POLL_TIMEOUT_SECONDS=300 | |||||
| POLL_MAX_CONCURRENT=4 | |||||
| @@ -0,0 +1,46 @@ | |||||
| # QC Text Builder (Go) | |||||
| Milestone 2 status: | |||||
| - bootstrap app/server/config | |||||
| - Quick Creator client contracts (Bearer token only) | |||||
| - AI template sync endpoint | |||||
| - onboarding/discovery endpoint with local manifest flattening | |||||
| - site build flow via `POST /sites` using local manifest + own text (`content.aiData`) | |||||
| - build persistence in memory incl. `qcJobId`, `qcSiteId`, `previewUrl`, `editorUrl` | |||||
| - build polling (`POST /api/site-builds/{id}/poll`) and background polling supervisor | |||||
| - strict MVP scope: no ACP login flow, no DCM/EFL, no image payload handling | |||||
| ## Run | |||||
| 1. Set env vars: | |||||
| - `HTTP_ADDR=:8080` | |||||
| - `QC_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1` | |||||
| - `QC_TOKEN=<your bearer token>` | |||||
| 2. Start: | |||||
| - `go run ./cmd/qctextbuilder` | |||||
| ## API (Milestone 3) | |||||
| - `GET /healthz` | |||||
| - `POST /api/templates/sync` | |||||
| - `GET /api/templates` | |||||
| - `GET /api/templates/{id}` | |||||
| - `POST /api/templates/{id}/onboard` | |||||
| - `PUT /api/templates/{id}/fields` | |||||
| - `POST /api/site-builds` | |||||
| - `GET /api/site-builds/{id}` | |||||
| - `POST /api/site-builds/{id}/poll` | |||||
| - `POST /api/site-builds/{id}/fetch-editor-url` | |||||
| Build request payload (`POST /api/site-builds`) expects: | |||||
| - `templateId` (AI template only, onboarded/reviewed) | |||||
| - `requestName` | |||||
| - `globalData` (`companyName`, `email`, `username` required; all other documented fields optional) | |||||
| - `fieldValues` keyed by manifest paths (`section.keyName`) | |||||
| Documented `globalData` scope supported by UI/API mapping: | |||||
| - `companyName`, `businessType`, `username`, `email`, `phone` | |||||
| - `orgNumber`, `startDate`, `mission`, `descriptionShort`, `descriptionLong`, `siteLanguage` | |||||
| - `address.line1`, `address.line2`, `address.city`, `address.region`, `address.zip`, `address.country` | |||||
| Current persistence is in-memory for bootstrap speed; postgres/sqlite stores are scaffolded for next milestones. | |||||
| @@ -0,0 +1,36 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| $projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path | |||||
| $envFile = Join-Path $projectRoot '.env.local' | |||||
| $distDir = Join-Path $projectRoot 'dist' | |||||
| $outFile = Join-Path $distDir 'qctextbuilder.exe' | |||||
| if (-not (Test-Path $envFile)) { | |||||
| throw "Missing .env.local at $envFile" | |||||
| } | |||||
| Get-Content $envFile | ForEach-Object { | |||||
| $line = $_.Trim() | |||||
| if (-not $line -or $line.StartsWith('#')) { return } | |||||
| $parts = $line -split '=', 2 | |||||
| if ($parts.Count -ne 2) { return } | |||||
| $name = $parts[0].Trim() | |||||
| $value = $parts[1] | |||||
| [System.Environment]::SetEnvironmentVariable($name, $value, 'Process') | |||||
| } | |||||
| if (-not (Test-Path $distDir)) { | |||||
| New-Item -ItemType Directory -Path $distDir | Out-Null | |||||
| } | |||||
| Set-Location $projectRoot | |||||
| Write-Host 'Running go test ./...' -ForegroundColor Cyan | |||||
| go test ./... | |||||
| Write-Host "Building $outFile" -ForegroundColor Green | |||||
| go build -o $outFile ./cmd/qctextbuilder | |||||
| Write-Host "Build complete: $outFile" -ForegroundColor Green | |||||
| @@ -0,0 +1,27 @@ | |||||
| package main | |||||
| import ( | |||||
| "context" | |||||
| "log" | |||||
| "os/signal" | |||||
| "syscall" | |||||
| "qctextbuilder/internal/app" | |||||
| "qctextbuilder/internal/config" | |||||
| ) | |||||
| func main() { | |||||
| cfg := config.Load() | |||||
| application, err := app.New(cfg) | |||||
| if err != nil { | |||||
| log.Fatalf("init app: %v", err) | |||||
| } | |||||
| ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | |||||
| defer stop() | |||||
| if err := application.Run(ctx); err != nil { | |||||
| log.Fatalf("run app: %v", err) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| module qctextbuilder | |||||
| go 1.24 | |||||
| require github.com/go-chi/chi/v5 v5.2.3 | |||||
| @@ -0,0 +1,2 @@ | |||||
| github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= | |||||
| github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= | |||||
| @@ -0,0 +1,103 @@ | |||||
| package app | |||||
| import ( | |||||
| "context" | |||||
| "errors" | |||||
| "fmt" | |||||
| "net/http" | |||||
| "time" | |||||
| "github.com/go-chi/chi/v5" | |||||
| "qctextbuilder/internal/buildsvc" | |||||
| "qctextbuilder/internal/config" | |||||
| "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/memory" | |||||
| "qctextbuilder/internal/templatesvc" | |||||
| ) | |||||
| type App struct { | |||||
| server *httpserver.Server | |||||
| pollingSvc *polling.Service | |||||
| } | |||||
| func New(cfg config.Config) (*App, error) { | |||||
| logger := logging.New() | |||||
| memStore := memory.New() | |||||
| qc := qcclient.New(cfg.QCBaseURL, cfg.QCToken, 15*time.Second, logger) | |||||
| templateSvc := templatesvc.New(qc, memStore, memStore) | |||||
| onboardSvc := onboarding.New(qc, memStore, memStore) | |||||
| mappingSvc := mapping.New() | |||||
| buildSvc := buildsvc.New(qc, memStore, memStore, memStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) | |||||
| pollingSvc := polling.New(buildSvc, memStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) | |||||
| api := handlers.NewAPI(templateSvc, onboardSvc, buildSvc) | |||||
| renderer, err := views.NewRenderer("web/templates/*.gohtml") | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("init renderer: %w", err) | |||||
| } | |||||
| ui := handlers.NewUI(templateSvc, onboardSvc, buildSvc, cfg, renderer) | |||||
| server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | |||||
| r.Get("/", ui.Home) | |||||
| r.Get("/settings", ui.Settings) | |||||
| 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", 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("/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 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,103 @@ | |||||
| package buildsvc | |||||
| import "strings" | |||||
| type GlobalDataInput struct { | |||||
| CompanyName string | |||||
| BusinessType string | |||||
| Username string | |||||
| Email string | |||||
| Phone string | |||||
| OrgNumber string | |||||
| StartDate string | |||||
| Mission string | |||||
| DescriptionShort string | |||||
| DescriptionLong string | |||||
| SiteLanguage string | |||||
| AddressLine1 string | |||||
| AddressLine2 string | |||||
| AddressCity string | |||||
| AddressRegion string | |||||
| AddressZIP string | |||||
| AddressCountry string | |||||
| } | |||||
| func BuildGlobalData(input GlobalDataInput) map[string]any { | |||||
| globalData := map[string]any{} | |||||
| setIfNotEmpty(globalData, "companyName", input.CompanyName) | |||||
| setIfNotEmpty(globalData, "businessType", input.BusinessType) | |||||
| setIfNotEmpty(globalData, "username", input.Username) | |||||
| setIfNotEmpty(globalData, "email", input.Email) | |||||
| setIfNotEmpty(globalData, "phone", input.Phone) | |||||
| setIfNotEmpty(globalData, "orgNumber", input.OrgNumber) | |||||
| setIfNotEmpty(globalData, "startDate", input.StartDate) | |||||
| setIfNotEmpty(globalData, "mission", input.Mission) | |||||
| setIfNotEmpty(globalData, "descriptionShort", input.DescriptionShort) | |||||
| setIfNotEmpty(globalData, "descriptionLong", input.DescriptionLong) | |||||
| setIfNotEmpty(globalData, "siteLanguage", input.SiteLanguage) | |||||
| address := map[string]any{} | |||||
| setIfNotEmpty(address, "line1", input.AddressLine1) | |||||
| setIfNotEmpty(address, "line2", input.AddressLine2) | |||||
| setIfNotEmpty(address, "city", input.AddressCity) | |||||
| setIfNotEmpty(address, "region", input.AddressRegion) | |||||
| setIfNotEmpty(address, "zip", input.AddressZIP) | |||||
| setIfNotEmpty(address, "country", input.AddressCountry) | |||||
| if len(address) > 0 { | |||||
| globalData["address"] = address | |||||
| } | |||||
| return globalData | |||||
| } | |||||
| func FilterGlobalData(input map[string]any) map[string]any { | |||||
| if len(input) == 0 { | |||||
| return map[string]any{} | |||||
| } | |||||
| globalData := map[string]any{} | |||||
| setStringIfPresent(globalData, "companyName", input["companyName"]) | |||||
| setStringIfPresent(globalData, "businessType", input["businessType"]) | |||||
| setStringIfPresent(globalData, "username", input["username"]) | |||||
| setStringIfPresent(globalData, "email", input["email"]) | |||||
| setStringIfPresent(globalData, "phone", input["phone"]) | |||||
| setStringIfPresent(globalData, "orgNumber", input["orgNumber"]) | |||||
| setStringIfPresent(globalData, "startDate", input["startDate"]) | |||||
| setStringIfPresent(globalData, "mission", input["mission"]) | |||||
| setStringIfPresent(globalData, "descriptionShort", input["descriptionShort"]) | |||||
| setStringIfPresent(globalData, "descriptionLong", input["descriptionLong"]) | |||||
| setStringIfPresent(globalData, "siteLanguage", input["siteLanguage"]) | |||||
| address := map[string]any{} | |||||
| if rawAddress, ok := input["address"]; ok { | |||||
| if sourceAddress, ok := rawAddress.(map[string]any); ok { | |||||
| setStringIfPresent(address, "line1", sourceAddress["line1"]) | |||||
| setStringIfPresent(address, "line2", sourceAddress["line2"]) | |||||
| setStringIfPresent(address, "city", sourceAddress["city"]) | |||||
| setStringIfPresent(address, "region", sourceAddress["region"]) | |||||
| setStringIfPresent(address, "zip", sourceAddress["zip"]) | |||||
| setStringIfPresent(address, "country", sourceAddress["country"]) | |||||
| } | |||||
| } | |||||
| if len(address) > 0 { | |||||
| globalData["address"] = address | |||||
| } | |||||
| return globalData | |||||
| } | |||||
| func setIfNotEmpty(m map[string]any, key, value string) { | |||||
| if strings.TrimSpace(value) == "" { | |||||
| return | |||||
| } | |||||
| m[key] = strings.TrimSpace(value) | |||||
| } | |||||
| func setStringIfPresent(target map[string]any, key string, raw any) { | |||||
| value, ok := raw.(string) | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| setIfNotEmpty(target, key, value) | |||||
| } | |||||
| @@ -0,0 +1,132 @@ | |||||
| package buildsvc | |||||
| import "testing" | |||||
| func TestBuildGlobalData_OptionalFieldsAreOmittedWhenEmpty(t *testing.T) { | |||||
| got := BuildGlobalData(GlobalDataInput{ | |||||
| CompanyName: "Acme AG", | |||||
| Email: "hello@acme.test", | |||||
| Username: "acme-admin", | |||||
| AddressLine1: " ", | |||||
| }) | |||||
| if got["companyName"] != "Acme AG" { | |||||
| t.Fatalf("companyName mismatch: got=%v", got["companyName"]) | |||||
| } | |||||
| if got["email"] != "hello@acme.test" { | |||||
| t.Fatalf("email mismatch: got=%v", got["email"]) | |||||
| } | |||||
| if got["username"] != "acme-admin" { | |||||
| t.Fatalf("username mismatch: got=%v", got["username"]) | |||||
| } | |||||
| if _, ok := got["phone"]; ok { | |||||
| t.Fatalf("phone should be omitted when empty") | |||||
| } | |||||
| if _, ok := got["address"]; ok { | |||||
| t.Fatalf("address should be omitted when empty") | |||||
| } | |||||
| } | |||||
| func TestBuildGlobalData_AddressAndOptionalFields(t *testing.T) { | |||||
| got := BuildGlobalData(GlobalDataInput{ | |||||
| CompanyName: "Acme AG", | |||||
| BusinessType: "Healthcare", | |||||
| Username: "acme-admin", | |||||
| Email: "hello@acme.test", | |||||
| Phone: "+41 79 000 00 00", | |||||
| OrgNumber: "CHE-123.456.789", | |||||
| StartDate: "2019-08-01", | |||||
| Mission: "Make dental care simple.", | |||||
| DescriptionShort: "Modern clinic.", | |||||
| DescriptionLong: "Full-service clinic in central Zurich.", | |||||
| SiteLanguage: "de", | |||||
| AddressLine1: "Main Street 1", | |||||
| AddressLine2: "2nd Floor", | |||||
| AddressCity: "Zurich", | |||||
| AddressRegion: "ZH", | |||||
| AddressZIP: "8000", | |||||
| AddressCountry: "Switzerland", | |||||
| }) | |||||
| addressRaw, ok := got["address"] | |||||
| if !ok { | |||||
| t.Fatalf("address should be present") | |||||
| } | |||||
| address, ok := addressRaw.(map[string]any) | |||||
| if !ok { | |||||
| t.Fatalf("address type mismatch: %T", addressRaw) | |||||
| } | |||||
| if address["line1"] != "Main Street 1" { | |||||
| t.Fatalf("line1 mismatch: got=%v", address["line1"]) | |||||
| } | |||||
| if address["line2"] != "2nd Floor" { | |||||
| t.Fatalf("line2 mismatch: got=%v", address["line2"]) | |||||
| } | |||||
| if address["region"] != "ZH" { | |||||
| t.Fatalf("region mismatch: got=%v", address["region"]) | |||||
| } | |||||
| if got["businessType"] != "Healthcare" { | |||||
| t.Fatalf("businessType mismatch: got=%v", got["businessType"]) | |||||
| } | |||||
| if got["phone"] != "+41 79 000 00 00" { | |||||
| t.Fatalf("phone mismatch: got=%v", got["phone"]) | |||||
| } | |||||
| if got["siteLanguage"] != "de" { | |||||
| t.Fatalf("siteLanguage mismatch: got=%v", got["siteLanguage"]) | |||||
| } | |||||
| if _, ok := got["business_category"]; ok { | |||||
| t.Fatalf("business_category must not be present") | |||||
| } | |||||
| } | |||||
| func TestFilterGlobalData_RemovesUnsupportedKeys(t *testing.T) { | |||||
| got := FilterGlobalData(map[string]any{ | |||||
| "companyName": " Acme AG ", | |||||
| "username": " admin ", | |||||
| "email": " hi@example.test ", | |||||
| "mobile": "+41 79 000 00 00", | |||||
| "business_category": "Healthcare", | |||||
| "siteLanguage": " de ", | |||||
| "address": map[string]any{ | |||||
| "line1": " Main Street 1 ", | |||||
| "line2": "", | |||||
| "city": " Zurich ", | |||||
| "region": " ZH ", | |||||
| "zip": " 8000 ", | |||||
| "country": " Switzerland ", | |||||
| "country_code": "CH", | |||||
| "street": "Unsupported Street Key", | |||||
| }, | |||||
| }) | |||||
| if got["companyName"] != "Acme AG" { | |||||
| t.Fatalf("companyName mismatch: got=%v", got["companyName"]) | |||||
| } | |||||
| if got["siteLanguage"] != "de" { | |||||
| t.Fatalf("siteLanguage mismatch: got=%v", got["siteLanguage"]) | |||||
| } | |||||
| if _, ok := got["mobile"]; ok { | |||||
| t.Fatalf("mobile must not be present") | |||||
| } | |||||
| if _, ok := got["business_category"]; ok { | |||||
| t.Fatalf("business_category must not be present") | |||||
| } | |||||
| addressRaw, ok := got["address"] | |||||
| if !ok { | |||||
| t.Fatalf("address should be present") | |||||
| } | |||||
| address, ok := addressRaw.(map[string]any) | |||||
| if !ok { | |||||
| t.Fatalf("address type mismatch: %T", addressRaw) | |||||
| } | |||||
| if address["line1"] != "Main Street 1" { | |||||
| t.Fatalf("line1 mismatch: got=%v", address["line1"]) | |||||
| } | |||||
| if _, ok := address["country_code"]; ok { | |||||
| t.Fatalf("address.country_code must not be present") | |||||
| } | |||||
| if _, ok := address["street"]; ok { | |||||
| t.Fatalf("address.street must not be present") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,329 @@ | |||||
| package buildsvc | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "errors" | |||||
| "fmt" | |||||
| "strconv" | |||||
| "strings" | |||||
| "sync" | |||||
| "time" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/mapping" | |||||
| "qctextbuilder/internal/qcclient" | |||||
| "qctextbuilder/internal/store" | |||||
| "qctextbuilder/internal/validation" | |||||
| ) | |||||
| type StartBuildRequest struct { | |||||
| TemplateID int64 `json:"templateId"` | |||||
| RequestName string `json:"requestName"` | |||||
| GlobalData map[string]any `json:"globalData"` | |||||
| FieldValues map[string]string `json:"fieldValues"` | |||||
| } | |||||
| type BuildResult struct { | |||||
| BuildID string `json:"buildId"` | |||||
| QCJobID int64 `json:"qcJobId"` | |||||
| Status string `json:"status"` | |||||
| } | |||||
| type Service interface { | |||||
| StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) | |||||
| PollOnce(ctx context.Context, buildID string) error | |||||
| FetchEditorURL(ctx context.Context, buildID string) error | |||||
| GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) | |||||
| } | |||||
| type BuildService struct { | |||||
| qc qcclient.Client | |||||
| templateStore store.TemplateStore | |||||
| manifestStore store.ManifestStore | |||||
| buildStore store.BuildStore | |||||
| mapping mapping.Service | |||||
| pollTimeout time.Duration | |||||
| mu sync.Mutex | |||||
| inFlightPolls map[string]struct{} | |||||
| } | |||||
| func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore, buildStore store.BuildStore, mappingSvc mapping.Service, pollTimeout time.Duration) *BuildService { | |||||
| if pollTimeout <= 0 { | |||||
| pollTimeout = 5 * time.Minute | |||||
| } | |||||
| return &BuildService{ | |||||
| qc: qc, | |||||
| templateStore: templateStore, | |||||
| manifestStore: manifestStore, | |||||
| buildStore: buildStore, | |||||
| mapping: mappingSvc, | |||||
| pollTimeout: pollTimeout, | |||||
| inFlightPolls: make(map[string]struct{}), | |||||
| } | |||||
| } | |||||
| func (s *BuildService) StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) { | |||||
| template, err := s.templateStore.GetTemplateByID(ctx, req.TemplateID) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("get template: %w", err) | |||||
| } | |||||
| if !template.IsAITemplate { | |||||
| return nil, errors.New("only ai templates are allowed") | |||||
| } | |||||
| manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, req.TemplateID) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("get active manifest: %w", err) | |||||
| } | |||||
| if !isBuildAllowed(template.ManifestStatus) { | |||||
| return nil, fmt.Errorf("template manifest status must be reviewed or validated, got %q", template.ManifestStatus) | |||||
| } | |||||
| filteredGlobalData := FilterGlobalData(req.GlobalData) | |||||
| if err := validation.ValidateBuildGlobalData(filteredGlobalData); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("list manifest fields: %w", err) | |||||
| } | |||||
| aiData, err := s.mapping.AssembleAIData(fields, req.FieldValues) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("assemble aiData: %w", err) | |||||
| } | |||||
| if len(aiData) == 0 { | |||||
| return nil, errors.New("at least one enabled text field value is required") | |||||
| } | |||||
| qcReq := qcclient.CreateSiteRequest{ | |||||
| TemplateID: req.TemplateID, | |||||
| GlobalData: filteredGlobalData, | |||||
| Content: qcclient.CreateSiteContent{ | |||||
| AIData: aiData, | |||||
| }, | |||||
| } | |||||
| requestName := strings.TrimSpace(req.RequestName) | |||||
| if requestName == "" { | |||||
| requestName = "build-" + strconv.FormatInt(time.Now().Unix(), 10) | |||||
| } | |||||
| buildID := strconv.FormatInt(time.Now().UnixNano(), 10) | |||||
| globalJSON, _ := json.Marshal(filteredGlobalData) | |||||
| aiDataJSON, _ := json.Marshal(aiData) | |||||
| finalPayload, _ := json.Marshal(qcReq) | |||||
| now := time.Now().UTC() | |||||
| build := domain.SiteBuild{ | |||||
| ID: buildID, | |||||
| TemplateID: req.TemplateID, | |||||
| ManifestID: manifest.ID, | |||||
| RequestName: requestName, | |||||
| GlobalDataJSON: globalJSON, | |||||
| AIDataJSON: aiDataJSON, | |||||
| FinalSitesPayload: finalPayload, | |||||
| QCStatus: "draft", | |||||
| } | |||||
| if err := s.buildStore.CreateBuild(ctx, build); err != nil { | |||||
| return nil, fmt.Errorf("create build: %w", err) | |||||
| } | |||||
| siteResp, siteRaw, err := s.qc.CreateSite(ctx, qcReq) | |||||
| if err != nil { | |||||
| _ = s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) | |||||
| return nil, fmt.Errorf("post /sites: %w", err) | |||||
| } | |||||
| if err := s.buildStore.MarkBuildSubmitted(ctx, buildID, siteResp.JobID, normalizeQCStatus(siteResp.Status), siteRaw, now); err != nil { | |||||
| return nil, fmt.Errorf("save site submission: %w", err) | |||||
| } | |||||
| return &BuildResult{ | |||||
| BuildID: buildID, | |||||
| QCJobID: siteResp.JobID, | |||||
| Status: normalizeQCStatus(siteResp.Status), | |||||
| }, nil | |||||
| } | |||||
| func (s *BuildService) PollOnce(ctx context.Context, buildID string) error { | |||||
| if !s.acquirePollLease(buildID) { | |||||
| return nil | |||||
| } | |||||
| defer s.releasePollLease(buildID) | |||||
| build, err := s.buildStore.GetBuildByID(ctx, buildID) | |||||
| if err != nil { | |||||
| return fmt.Errorf("get build: %w", err) | |||||
| } | |||||
| if isTerminalStatus(build.QCStatus) { | |||||
| return nil | |||||
| } | |||||
| if build.QCJobID == nil { | |||||
| return errors.New("build has no qcJobId") | |||||
| } | |||||
| now := time.Now().UTC() | |||||
| if hasExceededTimeout(build, now, s.pollTimeout) { | |||||
| return s.buildStore.UpdateBuildFromJob( | |||||
| ctx, | |||||
| buildID, | |||||
| "timeout", | |||||
| build.QCSiteID, | |||||
| preservePreviewURL(build.QCPreviewURL, ""), | |||||
| nil, | |||||
| wrapErrorMessage("poll timeout exceeded"), | |||||
| &now, | |||||
| ) | |||||
| } | |||||
| job, jobRaw, err := s.qc.GetJob(ctx, *build.QCJobID) | |||||
| now = time.Now().UTC() | |||||
| if err != nil { | |||||
| return s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) | |||||
| } | |||||
| status := normalizeQCStatus(job.Status) | |||||
| if isTerminalStatus(build.QCStatus) { | |||||
| status = build.QCStatus | |||||
| } | |||||
| var finishedAt *time.Time | |||||
| var siteID *int64 | |||||
| previewURL := strings.TrimSpace(job.Result.PreviewURL) | |||||
| if previewURL == "" { | |||||
| previewURL = build.QCPreviewURL | |||||
| } | |||||
| if job.Result.SiteID > 0 { | |||||
| id := job.Result.SiteID | |||||
| siteID = &id | |||||
| } else if build.QCSiteID != nil { | |||||
| siteID = build.QCSiteID | |||||
| } | |||||
| if isTerminalStatus(status) { | |||||
| finishedAt = &now | |||||
| } | |||||
| if err := s.buildStore.UpdateBuildFromJob(ctx, buildID, status, siteID, previewURL, jobRaw, nil, finishedAt); err != nil { | |||||
| return err | |||||
| } | |||||
| if status == "done" && siteID != nil && strings.TrimSpace(build.QCEditorURL) == "" { | |||||
| _ = s.fetchAndStoreEditorURL(ctx, buildID, *siteID) | |||||
| } | |||||
| return nil | |||||
| } | |||||
| func (s *BuildService) FetchEditorURL(ctx context.Context, buildID string) error { | |||||
| build, err := s.buildStore.GetBuildByID(ctx, buildID) | |||||
| if err != nil { | |||||
| return fmt.Errorf("get build: %w", err) | |||||
| } | |||||
| if !isTerminalStatus(build.QCStatus) { | |||||
| return fmt.Errorf("editor url can only be fetched for terminal build status, got %q", build.QCStatus) | |||||
| } | |||||
| if build.QCSiteID == nil { | |||||
| return errors.New("build has no qcSiteId") | |||||
| } | |||||
| if strings.TrimSpace(build.QCEditorURL) != "" { | |||||
| return nil | |||||
| } | |||||
| return s.fetchAndStoreEditorURL(ctx, buildID, *build.QCSiteID) | |||||
| } | |||||
| func (s *BuildService) GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) { | |||||
| return s.buildStore.GetBuildByID(ctx, buildID) | |||||
| } | |||||
| func isBuildAllowed(status string) bool { | |||||
| switch strings.ToLower(strings.TrimSpace(status)) { | |||||
| case "reviewed", "validated": | |||||
| return true | |||||
| default: | |||||
| return false | |||||
| } | |||||
| } | |||||
| func normalizeQCStatus(status string) string { | |||||
| s := strings.ToLower(strings.TrimSpace(status)) | |||||
| switch s { | |||||
| case "", "unknown": | |||||
| return "queued" | |||||
| case "in_progress", "running": | |||||
| return "processing" | |||||
| case "success", "succeeded", "completed": | |||||
| return "done" | |||||
| case "error": | |||||
| return "failed" | |||||
| case "queued", "processing", "done", "failed", "timeout": | |||||
| return s | |||||
| default: | |||||
| return "processing" | |||||
| } | |||||
| } | |||||
| func wrapErrorRaw(err error) json.RawMessage { | |||||
| return wrapErrorMessage(err.Error()) | |||||
| } | |||||
| func wrapErrorMessage(msg string) json.RawMessage { | |||||
| payload, marshalErr := json.Marshal(map[string]any{"error": msg}) | |||||
| if marshalErr != nil { | |||||
| return nil | |||||
| } | |||||
| return payload | |||||
| } | |||||
| func preservePreviewURL(existing, next string) string { | |||||
| if strings.TrimSpace(next) != "" { | |||||
| return next | |||||
| } | |||||
| return existing | |||||
| } | |||||
| func isTerminalStatus(status string) bool { | |||||
| switch strings.ToLower(strings.TrimSpace(status)) { | |||||
| case "done", "failed", "timeout": | |||||
| return true | |||||
| default: | |||||
| return false | |||||
| } | |||||
| } | |||||
| func hasExceededTimeout(build *domain.SiteBuild, now time.Time, pollTimeout time.Duration) bool { | |||||
| if pollTimeout <= 0 || build.StartedAt == nil { | |||||
| return false | |||||
| } | |||||
| if isTerminalStatus(build.QCStatus) { | |||||
| return false | |||||
| } | |||||
| return now.Sub(*build.StartedAt) > pollTimeout | |||||
| } | |||||
| func (s *BuildService) fetchAndStoreEditorURL(ctx context.Context, buildID string, siteID int64) error { | |||||
| editor, raw, err := s.qc.GetEditorURL(ctx, siteID) | |||||
| if err != nil { | |||||
| return fmt.Errorf("get editor url: %w", err) | |||||
| } | |||||
| loginURL := strings.TrimSpace(editor.LoginURL) | |||||
| if loginURL == "" { | |||||
| return errors.New("empty editor login url") | |||||
| } | |||||
| return s.buildStore.UpdateBuildEditorURL(ctx, buildID, loginURL, raw) | |||||
| } | |||||
| func (s *BuildService) acquirePollLease(buildID string) bool { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| if _, ok := s.inFlightPolls[buildID]; ok { | |||||
| return false | |||||
| } | |||||
| s.inFlightPolls[buildID] = struct{}{} | |||||
| return true | |||||
| } | |||||
| func (s *BuildService) releasePollLease(buildID string) { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| delete(s.inFlightPolls, buildID) | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| package config | |||||
| import ( | |||||
| "os" | |||||
| "strconv" | |||||
| ) | |||||
| type Config struct { | |||||
| HTTPAddr string | |||||
| DBDriver string | |||||
| DBURL string | |||||
| AppSecret string | |||||
| QCBaseURL string | |||||
| QCToken string | |||||
| PollIntervalSeconds int | |||||
| PollTimeoutSeconds int | |||||
| PollMaxConcurrent int | |||||
| } | |||||
| func Load() Config { | |||||
| return Config{ | |||||
| HTTPAddr: getenv("HTTP_ADDR", ":8080"), | |||||
| DBDriver: getenv("DB_DRIVER", "postgres"), | |||||
| DBURL: os.Getenv("DB_URL"), | |||||
| AppSecret: os.Getenv("APP_SECRET"), | |||||
| QCBaseURL: getenv("QC_BASE_URL", "https://qc-api.yggdrasil.dev-mono.net/api/v1"), | |||||
| QCToken: os.Getenv("QC_TOKEN"), | |||||
| PollIntervalSeconds: getenvInt("POLL_INTERVAL_SECONDS", 5), | |||||
| PollTimeoutSeconds: getenvInt("POLL_TIMEOUT_SECONDS", 300), | |||||
| PollMaxConcurrent: getenvInt("POLL_MAX_CONCURRENT", 4), | |||||
| } | |||||
| } | |||||
| func getenv(key, fallback string) string { | |||||
| v := os.Getenv(key) | |||||
| if v == "" { | |||||
| return fallback | |||||
| } | |||||
| return v | |||||
| } | |||||
| func getenvInt(key string, fallback int) int { | |||||
| v := os.Getenv(key) | |||||
| if v == "" { | |||||
| return fallback | |||||
| } | |||||
| n, err := strconv.Atoi(v) | |||||
| if err != nil { | |||||
| return fallback | |||||
| } | |||||
| return n | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| package crypto | |||||
| import "errors" | |||||
| var ErrMissingAppSecret = errors.New("missing app secret") | |||||
| func Encrypt(secret, plaintext string) (string, error) { | |||||
| if secret == "" { | |||||
| return "", ErrMissingAppSecret | |||||
| } | |||||
| // TODO(milestone-6): replace with AES-GCM encryption. | |||||
| return plaintext, nil | |||||
| } | |||||
| func Decrypt(secret, ciphertext string) (string, error) { | |||||
| if secret == "" { | |||||
| return "", ErrMissingAppSecret | |||||
| } | |||||
| // TODO(milestone-6): replace with AES-GCM decryption. | |||||
| return ciphertext, nil | |||||
| } | |||||
| @@ -0,0 +1,79 @@ | |||||
| package domain | |||||
| import ( | |||||
| "encoding/json" | |||||
| "time" | |||||
| ) | |||||
| type Template struct { | |||||
| ID int64 `json:"id"` | |||||
| Name string `json:"name"` | |||||
| Description string `json:"description"` | |||||
| Locale string `json:"locale"` | |||||
| ThumbnailURL string `json:"thumbnailUrl"` | |||||
| TemplatePreviewURL string `json:"templatePreviewUrl"` | |||||
| Type string `json:"type"` | |||||
| PaletteReady bool `json:"paletteReady"` | |||||
| RawJSON json.RawMessage `json:"rawTemplateJson"` | |||||
| IsAITemplate bool `json:"isAiTemplate"` | |||||
| IsOnboarded bool `json:"isOnboarded"` | |||||
| ManifestStatus string `json:"manifestStatus"` | |||||
| LastDiscoveredAt *time.Time `json:"lastDiscoveredAt,omitempty"` | |||||
| } | |||||
| type TemplateManifest struct { | |||||
| ID string `json:"id"` | |||||
| TemplateID int64 `json:"templateId"` | |||||
| Version int `json:"version"` | |||||
| Source string `json:"source"` | |||||
| LanguageUsedDiscovery string `json:"languageUsedForDiscovery"` | |||||
| DiscoveryPayloadJSON json.RawMessage `json:"discoveryPayloadJson"` | |||||
| DiscoveryResponseJSON json.RawMessage `json:"discoveryResponseJson"` | |||||
| FlattenedManifestJSON json.RawMessage `json:"flattenedManifestJson"` | |||||
| IsActive bool `json:"isActive"` | |||||
| CreatedAt time.Time `json:"createdAt"` | |||||
| UpdatedAt time.Time `json:"updatedAt"` | |||||
| } | |||||
| type TemplateField struct { | |||||
| ID string `json:"id"` | |||||
| TemplateID int64 `json:"templateId"` | |||||
| ManifestID string `json:"manifestId"` | |||||
| Section string `json:"section"` | |||||
| KeyName string `json:"keyName"` | |||||
| Path string `json:"path"` | |||||
| FieldKind string `json:"fieldKind"` | |||||
| SampleValue string `json:"sampleValue"` | |||||
| IsEnabled bool `json:"isEnabled"` | |||||
| IsRequiredByUs bool `json:"isRequiredByUs"` | |||||
| DisplayLabel string `json:"displayLabel"` | |||||
| DisplayOrder int `json:"displayOrder"` | |||||
| Notes string `json:"notes"` | |||||
| } | |||||
| type SiteBuild struct { | |||||
| ID string `json:"id"` | |||||
| TemplateID int64 `json:"templateId"` | |||||
| ManifestID string `json:"manifestId"` | |||||
| RequestName string `json:"requestName"` | |||||
| GlobalDataJSON json.RawMessage `json:"globalDataJson"` | |||||
| AIDataJSON json.RawMessage `json:"aiDataJson"` | |||||
| FinalSitesPayload json.RawMessage `json:"finalSitesPayloadJson"` | |||||
| QCJobID *int64 `json:"qcJobId,omitempty"` | |||||
| QCSiteID *int64 `json:"qcSiteId,omitempty"` | |||||
| QCStatus string `json:"qcStatus"` | |||||
| QCPreviewURL string `json:"qcPreviewUrl"` | |||||
| QCEditorURL string `json:"qcEditorUrl"` | |||||
| QCResultJSON json.RawMessage `json:"qcResultJson"` | |||||
| QCErrorJSON json.RawMessage `json:"qcErrorJson"` | |||||
| StartedAt *time.Time `json:"startedAt,omitempty"` | |||||
| FinishedAt *time.Time `json:"finishedAt,omitempty"` | |||||
| } | |||||
| type AppSettings struct { | |||||
| QCBaseURL string `json:"qcBaseUrl"` | |||||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | |||||
| LanguageOutputMode string `json:"languageOutputMode"` | |||||
| JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` | |||||
| JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` | |||||
| } | |||||
| @@ -0,0 +1,204 @@ | |||||
| package handlers | |||||
| import ( | |||||
| "encoding/json" | |||||
| "net/http" | |||||
| "strconv" | |||||
| "github.com/go-chi/chi/v5" | |||||
| "qctextbuilder/internal/buildsvc" | |||||
| "qctextbuilder/internal/onboarding" | |||||
| "qctextbuilder/internal/templatesvc" | |||||
| ) | |||||
| type API struct { | |||||
| templateSvc *templatesvc.Service | |||||
| onboardSvc *onboarding.Service | |||||
| buildSvc buildsvc.Service | |||||
| } | |||||
| func NewAPI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, buildSvc buildsvc.Service) *API { | |||||
| return &API{ | |||||
| templateSvc: templateSvc, | |||||
| onboardSvc: onboardSvc, | |||||
| buildSvc: buildSvc, | |||||
| } | |||||
| } | |||||
| func (a *API) Health(w http.ResponseWriter, _ *http.Request) { | |||||
| writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) | |||||
| } | |||||
| func (a *API) SyncTemplates(w http.ResponseWriter, r *http.Request) { | |||||
| templates, err := a.templateSvc.SyncAITemplates(r.Context()) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates}) | |||||
| } | |||||
| func (a *API) ListTemplates(w http.ResponseWriter, r *http.Request) { | |||||
| templates, err := a.templateSvc.ListTemplates(r.Context()) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates}) | |||||
| } | |||||
| func (a *API) GetTemplateDetail(w http.ResponseWriter, r *http.Request) { | |||||
| rawID := chi.URLParam(r, "id") | |||||
| templateID, err := strconv.ParseInt(rawID, 10, 64) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) | |||||
| return | |||||
| } | |||||
| detail, err := a.templateSvc.GetTemplateDetail(r.Context(), templateID) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, detail) | |||||
| } | |||||
| func (a *API) OnboardTemplate(w http.ResponseWriter, r *http.Request) { | |||||
| rawID := chi.URLParam(r, "id") | |||||
| templateID, err := strconv.ParseInt(rawID, 10, 64) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) | |||||
| return | |||||
| } | |||||
| manifest, fields, err := a.onboardSvc.OnboardTemplate(r.Context(), templateID) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, map[string]any{ | |||||
| "manifestId": manifest.ID, | |||||
| "fieldCount": len(fields), | |||||
| "status": "reviewed", | |||||
| }) | |||||
| } | |||||
| type updateTemplateFieldsRequest struct { | |||||
| ManifestID string `json:"manifestId"` | |||||
| Fields []updateTemplateFieldItem `json:"fields"` | |||||
| } | |||||
| type updateTemplateFieldItem struct { | |||||
| Path string `json:"path"` | |||||
| IsEnabled *bool `json:"isEnabled,omitempty"` | |||||
| IsRequiredByUs *bool `json:"isRequiredByUs,omitempty"` | |||||
| DisplayLabel *string `json:"displayLabel,omitempty"` | |||||
| DisplayOrder *int `json:"displayOrder,omitempty"` | |||||
| Notes *string `json:"notes,omitempty"` | |||||
| } | |||||
| func (a *API) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { | |||||
| rawID := chi.URLParam(r, "id") | |||||
| templateID, err := strconv.ParseInt(rawID, 10, 64) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) | |||||
| return | |||||
| } | |||||
| var req updateTemplateFieldsRequest | |||||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) | |||||
| return | |||||
| } | |||||
| if len(req.Fields) == 0 { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "fields is required"}) | |||||
| return | |||||
| } | |||||
| patches := make([]onboarding.FieldPatch, 0, len(req.Fields)) | |||||
| for _, f := range req.Fields { | |||||
| patches = append(patches, onboarding.FieldPatch{ | |||||
| Path: f.Path, | |||||
| IsEnabled: f.IsEnabled, | |||||
| IsRequiredByUs: f.IsRequiredByUs, | |||||
| DisplayLabel: f.DisplayLabel, | |||||
| DisplayOrder: f.DisplayOrder, | |||||
| Notes: f.Notes, | |||||
| }) | |||||
| } | |||||
| manifest, fields, err := a.onboardSvc.UpdateTemplateFields(r.Context(), templateID, req.ManifestID, patches) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, map[string]any{ | |||||
| "templateId": templateID, | |||||
| "manifestId": manifest.ID, | |||||
| "fieldCount": len(fields), | |||||
| "fields": fields, | |||||
| }) | |||||
| } | |||||
| func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) { | |||||
| var req buildsvc.StartBuildRequest | |||||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) | |||||
| return | |||||
| } | |||||
| result, err := a.buildSvc.StartBuild(r.Context(), req) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusAccepted, result) | |||||
| } | |||||
| func (a *API) GetBuild(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := chi.URLParam(r, "id") | |||||
| build, err := a.buildSvc.GetBuild(r.Context(), buildID) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, build) | |||||
| } | |||||
| func (a *API) PollBuildOnce(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := chi.URLParam(r, "id") | |||||
| if err := a.buildSvc.PollOnce(r.Context(), buildID); err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| build, err := a.buildSvc.GetBuild(r.Context(), buildID) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, build) | |||||
| } | |||||
| func (a *API) FetchBuildEditorURL(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := chi.URLParam(r, "id") | |||||
| if err := a.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { | |||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| build, err := a.buildSvc.GetBuild(r.Context(), buildID) | |||||
| if err != nil { | |||||
| writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) | |||||
| return | |||||
| } | |||||
| writeJSON(w, http.StatusOK, build) | |||||
| } | |||||
| func writeJSON(w http.ResponseWriter, status int, v any) { | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| w.WriteHeader(status) | |||||
| _ = json.NewEncoder(w).Encode(v) | |||||
| } | |||||
| @@ -0,0 +1,474 @@ | |||||
| package handlers | |||||
| import ( | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "net/http" | |||||
| "net/url" | |||||
| "strconv" | |||||
| "strings" | |||||
| "github.com/go-chi/chi/v5" | |||||
| "qctextbuilder/internal/buildsvc" | |||||
| "qctextbuilder/internal/config" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/onboarding" | |||||
| "qctextbuilder/internal/templatesvc" | |||||
| ) | |||||
| type UI struct { | |||||
| templateSvc *templatesvc.Service | |||||
| onboardSvc *onboarding.Service | |||||
| buildSvc buildsvc.Service | |||||
| cfg config.Config | |||||
| render htmlRenderer | |||||
| } | |||||
| type htmlRenderer interface { | |||||
| Render(w http.ResponseWriter, name string, data any) | |||||
| } | |||||
| type pageData struct { | |||||
| Title string | |||||
| Msg string | |||||
| Err string | |||||
| Current string | |||||
| } | |||||
| type homePageData struct { | |||||
| pageData | |||||
| TemplateCount int | |||||
| } | |||||
| type settingsPageData struct { | |||||
| pageData | |||||
| QCBaseURL string | |||||
| PollIntervalSeconds int | |||||
| PollTimeoutSeconds int | |||||
| PollMaxConcurrent int | |||||
| TokenConfigured bool | |||||
| LanguageOutputMode string | |||||
| } | |||||
| type templatesPageData struct { | |||||
| pageData | |||||
| Templates []domain.Template | |||||
| } | |||||
| type templateFieldView struct { | |||||
| Path string | |||||
| FieldKind string | |||||
| IsEnabled bool | |||||
| IsRequiredByUs bool | |||||
| DisplayLabel string | |||||
| DisplayOrder int | |||||
| Notes string | |||||
| SampleValue string | |||||
| } | |||||
| type templateDetailPageData struct { | |||||
| pageData | |||||
| Detail *templatesvc.TemplateDetail | |||||
| Fields []templateFieldView | |||||
| } | |||||
| type buildFieldView struct { | |||||
| Path string | |||||
| DisplayLabel string | |||||
| SampleValue string | |||||
| Value string | |||||
| } | |||||
| type buildNewPageData struct { | |||||
| pageData | |||||
| Templates []domain.Template | |||||
| SelectedTemplateID int64 | |||||
| SelectedManifestID string | |||||
| EnabledFields []buildFieldView | |||||
| Form buildFormInput | |||||
| } | |||||
| type buildFormInput struct { | |||||
| RequestName string | |||||
| CompanyName string | |||||
| BusinessType string | |||||
| Username string | |||||
| Email string | |||||
| Phone string | |||||
| OrgNumber string | |||||
| StartDate string | |||||
| Mission string | |||||
| DescriptionShort string | |||||
| DescriptionLong string | |||||
| SiteLanguage string | |||||
| AddressLine1 string | |||||
| AddressLine2 string | |||||
| AddressCity string | |||||
| AddressRegion string | |||||
| AddressZIP string | |||||
| AddressCountry string | |||||
| } | |||||
| type buildDetailPageData struct { | |||||
| pageData | |||||
| Build *domain.SiteBuild | |||||
| EffectiveGlobal []byte | |||||
| CanPoll bool | |||||
| CanFetchEditorURL bool | |||||
| AutoRefreshSeconds int | |||||
| } | |||||
| func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI { | |||||
| return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, buildSvc: buildSvc, cfg: cfg, render: render} | |||||
| } | |||||
| func (u *UI) Home(w http.ResponseWriter, r *http.Request) { | |||||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | |||||
| if err != nil { | |||||
| u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0}) | |||||
| return | |||||
| } | |||||
| u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)}) | |||||
| } | |||||
| func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | |||||
| u.render.Render(w, "settings", settingsPageData{ | |||||
| pageData: basePageData(r, "Settings", "/settings"), | |||||
| QCBaseURL: u.cfg.QCBaseURL, | |||||
| PollIntervalSeconds: u.cfg.PollIntervalSeconds, | |||||
| PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | |||||
| PollMaxConcurrent: u.cfg.PollMaxConcurrent, | |||||
| TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", | |||||
| LanguageOutputMode: "EN", | |||||
| }) | |||||
| } | |||||
| func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { | |||||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates}) | |||||
| } | |||||
| func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) { | |||||
| if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil { | |||||
| http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) { | |||||
| templateID, ok := parseTemplateID(w, r) | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID) | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusNotFound) | |||||
| return | |||||
| } | |||||
| fields := make([]templateFieldView, 0, len(detail.Fields)) | |||||
| for _, f := range detail.Fields { | |||||
| fields = append(fields, templateFieldView{ | |||||
| Path: f.Path, | |||||
| FieldKind: f.FieldKind, | |||||
| IsEnabled: f.IsEnabled, | |||||
| IsRequiredByUs: f.IsRequiredByUs, | |||||
| DisplayLabel: f.DisplayLabel, | |||||
| DisplayOrder: f.DisplayOrder, | |||||
| Notes: f.Notes, | |||||
| SampleValue: f.SampleValue, | |||||
| }) | |||||
| } | |||||
| u.render.Render(w, "template_detail", templateDetailPageData{pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields}) | |||||
| } | |||||
| func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) { | |||||
| templateID, ok := parseTemplateID(w, r) | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { | |||||
| templateID, ok := parseTemplateID(w, r) | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| if err := r.ParseForm(); err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| count, _ := strconv.Atoi(r.FormValue("field_count")) | |||||
| patches := make([]onboarding.FieldPatch, 0, count) | |||||
| for i := 0; i < count; i++ { | |||||
| path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) | |||||
| if path == "" { | |||||
| continue | |||||
| } | |||||
| enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on" | |||||
| required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on" | |||||
| label := r.FormValue(fmt.Sprintf("field_label_%d", i)) | |||||
| notes := r.FormValue(fmt.Sprintf("field_notes_%d", i)) | |||||
| order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i)))) | |||||
| if err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| patches = append(patches, onboarding.FieldPatch{ | |||||
| Path: path, | |||||
| IsEnabled: boolPtr(enabled), | |||||
| IsRequiredByUs: boolPtr(required), | |||||
| DisplayLabel: strPtr(label), | |||||
| DisplayOrder: intPtr(order), | |||||
| Notes: strPtr(notes), | |||||
| }) | |||||
| } | |||||
| manifestID := r.FormValue("manifest_id") | |||||
| if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { | |||||
| selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64) | |||||
| data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedTemplateID, buildFormInput{}, nil) | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| u.render.Render(w, "build_new", data) | |||||
| } | |||||
| func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| if err := r.ParseForm(); err != nil { | |||||
| http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| form := buildFormInputFromRequest(r) | |||||
| fieldValues := parseBuildFieldValues(r) | |||||
| templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | |||||
| if err != nil || templateID <= 0 { | |||||
| http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{ | |||||
| TemplateID: templateID, | |||||
| RequestName: form.RequestName, | |||||
| GlobalData: buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ | |||||
| CompanyName: form.CompanyName, | |||||
| BusinessType: form.BusinessType, | |||||
| Username: form.Username, | |||||
| Email: form.Email, | |||||
| Phone: form.Phone, | |||||
| OrgNumber: form.OrgNumber, | |||||
| StartDate: form.StartDate, | |||||
| Mission: form.Mission, | |||||
| DescriptionShort: form.DescriptionShort, | |||||
| DescriptionLong: form.DescriptionLong, | |||||
| SiteLanguage: form.SiteLanguage, | |||||
| AddressLine1: form.AddressLine1, | |||||
| AddressLine2: form.AddressLine2, | |||||
| AddressCity: form.AddressCity, | |||||
| AddressRegion: form.AddressRegion, | |||||
| AddressZIP: form.AddressZIP, | |||||
| AddressCountry: form.AddressCountry, | |||||
| }), | |||||
| FieldValues: fieldValues, | |||||
| }) | |||||
| if err != nil { | |||||
| data, loadErr := u.loadBuildNewPageData(r, pageData{ | |||||
| Title: "New Build", | |||||
| Err: err.Error(), | |||||
| Current: "/builds/new", | |||||
| }, templateID, form, fieldValues) | |||||
| if loadErr != nil { | |||||
| http.Error(w, loadErr.Error(), http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| u.render.Render(w, "build_new", data) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := strings.TrimSpace(chi.URLParam(r, "id")) | |||||
| build, err := u.buildSvc.GetBuild(r.Context(), buildID) | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusNotFound) | |||||
| return | |||||
| } | |||||
| status := strings.ToLower(strings.TrimSpace(build.QCStatus)) | |||||
| canPoll := status == "queued" || status == "processing" | |||||
| canFetchEditor := (status == "done" || status == "failed" || status == "timeout") && | |||||
| build.QCSiteID != nil && | |||||
| strings.TrimSpace(build.QCEditorURL) == "" | |||||
| autoRefresh := 0 | |||||
| if canPoll && u.cfg.PollIntervalSeconds > 0 { | |||||
| autoRefresh = u.cfg.PollIntervalSeconds | |||||
| } | |||||
| effectiveGlobal := build.GlobalDataJSON | |||||
| if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 { | |||||
| effectiveGlobal = payloadGlobal | |||||
| } | |||||
| u.render.Render(w, "build_detail", buildDetailPageData{ | |||||
| pageData: basePageData(r, "Build Detail", "/builds"), | |||||
| Build: build, | |||||
| EffectiveGlobal: effectiveGlobal, | |||||
| CanPoll: canPoll, | |||||
| CanFetchEditorURL: canFetchEditor, | |||||
| AutoRefreshSeconds: autoRefresh, | |||||
| }) | |||||
| } | |||||
| func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := strings.TrimSpace(chi.URLParam(r, "id")) | |||||
| if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) { | |||||
| buildID := strings.TrimSpace(chi.URLParam(r, "id")) | |||||
| if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { | |||||
| http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther) | |||||
| } | |||||
| func basePageData(r *http.Request, title, current string) pageData { | |||||
| q := r.URL.Query() | |||||
| return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current} | |||||
| } | |||||
| func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) { | |||||
| rawID := chi.URLParam(r, "id") | |||||
| templateID, err := strconv.ParseInt(rawID, 10, 64) | |||||
| if err != nil { | |||||
| http.Error(w, "invalid template id", http.StatusBadRequest) | |||||
| return 0, false | |||||
| } | |||||
| return templateID, true | |||||
| } | |||||
| func urlQuery(s string) string { | |||||
| return url.QueryEscape(s) | |||||
| } | |||||
| func boolPtr(v bool) *bool { return &v } | |||||
| func intPtr(v int) *int { return &v } | |||||
| func strPtr(v string) *string { | |||||
| return &v | |||||
| } | |||||
| func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) { | |||||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | |||||
| if err != nil { | |||||
| return buildNewPageData{}, err | |||||
| } | |||||
| data := buildNewPageData{ | |||||
| pageData: page, | |||||
| Templates: templates, | |||||
| SelectedTemplateID: selectedTemplateID, | |||||
| Form: form, | |||||
| } | |||||
| if selectedTemplateID <= 0 { | |||||
| return data, nil | |||||
| } | |||||
| detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID) | |||||
| if err != nil || detail.Manifest == nil { | |||||
| return data, nil | |||||
| } | |||||
| data.SelectedManifestID = detail.Manifest.ID | |||||
| for _, f := range detail.Fields { | |||||
| if !f.IsEnabled || f.FieldKind != "text" { | |||||
| continue | |||||
| } | |||||
| data.EnabledFields = append(data.EnabledFields, buildFieldView{ | |||||
| Path: f.Path, | |||||
| DisplayLabel: f.DisplayLabel, | |||||
| SampleValue: f.SampleValue, | |||||
| Value: strings.TrimSpace(fieldValues[f.Path]), | |||||
| }) | |||||
| } | |||||
| return data, nil | |||||
| } | |||||
| func buildFormInputFromRequest(r *http.Request) buildFormInput { | |||||
| return buildFormInput{ | |||||
| RequestName: strings.TrimSpace(r.FormValue("request_name")), | |||||
| CompanyName: strings.TrimSpace(r.FormValue("company_name")), | |||||
| BusinessType: strings.TrimSpace(r.FormValue("business_type")), | |||||
| Username: strings.TrimSpace(r.FormValue("username")), | |||||
| Email: strings.TrimSpace(r.FormValue("email")), | |||||
| Phone: strings.TrimSpace(r.FormValue("phone")), | |||||
| OrgNumber: strings.TrimSpace(r.FormValue("org_number")), | |||||
| StartDate: strings.TrimSpace(r.FormValue("start_date")), | |||||
| Mission: strings.TrimSpace(r.FormValue("mission")), | |||||
| DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), | |||||
| DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), | |||||
| SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), | |||||
| AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), | |||||
| AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), | |||||
| AddressCity: strings.TrimSpace(r.FormValue("address_city")), | |||||
| AddressRegion: strings.TrimSpace(r.FormValue("address_region")), | |||||
| AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), | |||||
| AddressCountry: strings.TrimSpace(r.FormValue("address_country")), | |||||
| } | |||||
| } | |||||
| func parseBuildFieldValues(r *http.Request) map[string]string { | |||||
| fieldValues := map[string]string{} | |||||
| count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) | |||||
| for i := 0; i < count; i++ { | |||||
| path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) | |||||
| value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i))) | |||||
| if path != "" { | |||||
| fieldValues[path] = value | |||||
| } | |||||
| } | |||||
| return fieldValues | |||||
| } | |||||
| func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) { | |||||
| if len(raw) == 0 { | |||||
| return nil, nil | |||||
| } | |||||
| var payload struct { | |||||
| GlobalData map[string]any `json:"globalData"` | |||||
| } | |||||
| if err := json.Unmarshal(raw, &payload); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if len(payload.GlobalData) == 0 { | |||||
| return nil, nil | |||||
| } | |||||
| data, err := json.Marshal(payload.GlobalData) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return data, nil | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| package middleware | |||||
| import ( | |||||
| "log/slog" | |||||
| "net/http" | |||||
| "time" | |||||
| ) | |||||
| func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler { | |||||
| return func(next http.Handler) http.Handler { | |||||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||||
| start := time.Now() | |||||
| next.ServeHTTP(w, r) | |||||
| logger.Info("http request", | |||||
| "method", r.Method, | |||||
| "path", r.URL.Path, | |||||
| "duration_ms", time.Since(start).Milliseconds(), | |||||
| ) | |||||
| }) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| package httpserver | |||||
| import ( | |||||
| "context" | |||||
| "log/slog" | |||||
| "net/http" | |||||
| "time" | |||||
| "github.com/go-chi/chi/v5" | |||||
| chimw "github.com/go-chi/chi/v5/middleware" | |||||
| appmw "qctextbuilder/internal/httpserver/middleware" | |||||
| ) | |||||
| type Server struct { | |||||
| httpServer *http.Server | |||||
| } | |||||
| func New(addr string, logger *slog.Logger, registerRoutes func(r chi.Router)) *Server { | |||||
| r := chi.NewRouter() | |||||
| r.Use(chimw.RequestID) | |||||
| r.Use(chimw.Recoverer) | |||||
| r.Use(chimw.Timeout(30 * time.Second)) | |||||
| r.Use(appmw.RequestLogger(logger)) | |||||
| registerRoutes(r) | |||||
| return &Server{ | |||||
| httpServer: &http.Server{ | |||||
| Addr: addr, | |||||
| Handler: r, | |||||
| }, | |||||
| } | |||||
| } | |||||
| func (s *Server) Run() error { | |||||
| return s.httpServer.ListenAndServe() | |||||
| } | |||||
| func (s *Server) Shutdown(ctx context.Context) error { | |||||
| return s.httpServer.Shutdown(ctx) | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| package views | |||||
| // Package views contains server-rendered templates for the admin UI. | |||||
| @@ -0,0 +1,40 @@ | |||||
| package views | |||||
| import ( | |||||
| "bytes" | |||||
| "encoding/json" | |||||
| "html/template" | |||||
| "net/http" | |||||
| ) | |||||
| type Renderer struct { | |||||
| tpl *template.Template | |||||
| } | |||||
| func NewRenderer(pattern string) (*Renderer, error) { | |||||
| tpl, err := template.New("ui").Funcs(template.FuncMap{ | |||||
| "prettyJSON": prettyJSON, | |||||
| }).ParseGlob(pattern) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return &Renderer{tpl: tpl}, nil | |||||
| } | |||||
| func (r *Renderer) Render(w http.ResponseWriter, name string, data any) { | |||||
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | |||||
| if err := r.tpl.ExecuteTemplate(w, name, data); err != nil { | |||||
| http.Error(w, "template rendering failed", http.StatusInternalServerError) | |||||
| } | |||||
| } | |||||
| func prettyJSON(raw []byte) string { | |||||
| if len(raw) == 0 { | |||||
| return "" | |||||
| } | |||||
| var out bytes.Buffer | |||||
| if err := json.Indent(&out, raw, "", " "); err != nil { | |||||
| return string(raw) | |||||
| } | |||||
| return out.String() | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| package logging | |||||
| import ( | |||||
| "log/slog" | |||||
| "os" | |||||
| ) | |||||
| func New() *slog.Logger { | |||||
| return slog.New(slog.NewJSONHandler(os.Stdout, nil)) | |||||
| } | |||||
| @@ -0,0 +1,44 @@ | |||||
| package mapping | |||||
| import ( | |||||
| "fmt" | |||||
| "strings" | |||||
| "qctextbuilder/internal/domain" | |||||
| ) | |||||
| type Service interface { | |||||
| AssembleAIData(fields []domain.TemplateField, values map[string]string) (map[string]map[string]any, error) | |||||
| } | |||||
| type RawKeyService struct{} | |||||
| func New() *RawKeyService { | |||||
| return &RawKeyService{} | |||||
| } | |||||
| func (s *RawKeyService) AssembleAIData(fields []domain.TemplateField, values map[string]string) (map[string]map[string]any, error) { | |||||
| allowed := make(map[string]domain.TemplateField, len(fields)) | |||||
| for _, f := range fields { | |||||
| if !f.IsEnabled || f.FieldKind != "text" { | |||||
| continue | |||||
| } | |||||
| allowed[f.Path] = f | |||||
| } | |||||
| out := make(map[string]map[string]any) | |||||
| for path, value := range values { | |||||
| if strings.TrimSpace(value) == "" { | |||||
| continue | |||||
| } | |||||
| f, ok := allowed[path] | |||||
| if !ok { | |||||
| return nil, fmt.Errorf("unknown or disabled field path: %s", path) | |||||
| } | |||||
| if out[f.Section] == nil { | |||||
| out[f.Section] = make(map[string]any) | |||||
| } | |||||
| out[f.Section][f.KeyName] = value | |||||
| } | |||||
| return out, nil | |||||
| } | |||||
| @@ -0,0 +1,262 @@ | |||||
| package onboarding | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "regexp" | |||||
| "sort" | |||||
| "strconv" | |||||
| "strings" | |||||
| "time" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/qcclient" | |||||
| "qctextbuilder/internal/store" | |||||
| ) | |||||
| var imagePlaceholderPattern = regexp.MustCompile(`^\[\s*(image|img|photo|picture)(\s+(image|img|photo|picture))*\s*\]$`) | |||||
| var imageLikePathHints = []string{ | |||||
| "image", | |||||
| "img", | |||||
| "photo", | |||||
| "picture", | |||||
| "thumbnail", | |||||
| "gallery", | |||||
| "logo", | |||||
| "icon", | |||||
| "avatar", | |||||
| "background", | |||||
| "banner", | |||||
| } | |||||
| type Service struct { | |||||
| qc qcclient.Client | |||||
| templateStore store.TemplateStore | |||||
| manifestStore store.ManifestStore | |||||
| } | |||||
| type FieldPatch struct { | |||||
| Path string | |||||
| IsEnabled *bool | |||||
| IsRequiredByUs *bool | |||||
| DisplayLabel *string | |||||
| DisplayOrder *int | |||||
| Notes *string | |||||
| } | |||||
| func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service { | |||||
| return &Service{ | |||||
| qc: qc, | |||||
| templateStore: templateStore, | |||||
| manifestStore: manifestStore, | |||||
| } | |||||
| } | |||||
| func (s *Service) OnboardTemplate(ctx context.Context, templateID int64) (*domain.TemplateManifest, []domain.TemplateField, error) { | |||||
| template, err := s.templateStore.GetTemplateByID(ctx, templateID) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("get template: %w", err) | |||||
| } | |||||
| if !template.IsAITemplate { | |||||
| return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed") | |||||
| } | |||||
| req := defaultDiscoveryRequest(templateID) | |||||
| data, raw, err := s.qc.GenerateContent(ctx, req) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("generate discovery content: %w", err) | |||||
| } | |||||
| manifestID := strconv.FormatInt(time.Now().UnixNano(), 10) | |||||
| now := time.Now().UTC() | |||||
| fields := flattenDiscovery(templateID, manifestID, data) | |||||
| flattened, _ := json.Marshal(fields) | |||||
| reqRaw, _ := json.Marshal(req) | |||||
| manifest := domain.TemplateManifest{ | |||||
| ID: manifestID, | |||||
| TemplateID: templateID, | |||||
| Version: 1, | |||||
| Source: "generate-content", | |||||
| LanguageUsedDiscovery: "EN", | |||||
| DiscoveryPayloadJSON: reqRaw, | |||||
| DiscoveryResponseJSON: raw, | |||||
| FlattenedManifestJSON: flattened, | |||||
| IsActive: true, | |||||
| CreatedAt: now, | |||||
| UpdatedAt: now, | |||||
| } | |||||
| if err := s.manifestStore.CreateManifest(ctx, manifest, fields); err != nil { | |||||
| return nil, nil, fmt.Errorf("save manifest: %w", err) | |||||
| } | |||||
| if err := s.templateStore.SetTemplateManifestStatus(ctx, templateID, "reviewed", true); err != nil { | |||||
| return nil, nil, fmt.Errorf("update template status: %w", err) | |||||
| } | |||||
| return &manifest, fields, nil | |||||
| } | |||||
| func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, manifestID string, patches []FieldPatch) (*domain.TemplateManifest, []domain.TemplateField, error) { | |||||
| template, err := s.templateStore.GetTemplateByID(ctx, templateID) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("get template: %w", err) | |||||
| } | |||||
| if !template.IsAITemplate { | |||||
| return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed") | |||||
| } | |||||
| manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("get active manifest: %w", err) | |||||
| } | |||||
| if strings.TrimSpace(manifestID) != "" && manifest.ID != manifestID { | |||||
| return nil, nil, fmt.Errorf("manifest mismatch: active=%s requested=%s", manifest.ID, manifestID) | |||||
| } | |||||
| fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("list fields: %w", err) | |||||
| } | |||||
| byPath := make(map[string]int, len(fields)) | |||||
| for i := range fields { | |||||
| byPath[fields[i].Path] = i | |||||
| } | |||||
| for _, patch := range patches { | |||||
| path := strings.TrimSpace(patch.Path) | |||||
| if path == "" { | |||||
| return nil, nil, fmt.Errorf("field patch path is required") | |||||
| } | |||||
| idx, ok := byPath[path] | |||||
| if !ok { | |||||
| return nil, nil, fmt.Errorf("unknown field path: %s", path) | |||||
| } | |||||
| if patch.IsEnabled != nil { | |||||
| if *patch.IsEnabled && fields[idx].FieldKind != "text" { | |||||
| return nil, nil, fmt.Errorf("field %s cannot be enabled for kind %s", path, fields[idx].FieldKind) | |||||
| } | |||||
| fields[idx].IsEnabled = *patch.IsEnabled | |||||
| } | |||||
| if patch.IsRequiredByUs != nil { | |||||
| fields[idx].IsRequiredByUs = *patch.IsRequiredByUs | |||||
| } | |||||
| if patch.DisplayLabel != nil { | |||||
| fields[idx].DisplayLabel = strings.TrimSpace(*patch.DisplayLabel) | |||||
| } | |||||
| if patch.DisplayOrder != nil { | |||||
| fields[idx].DisplayOrder = *patch.DisplayOrder | |||||
| } | |||||
| if patch.Notes != nil { | |||||
| fields[idx].Notes = strings.TrimSpace(*patch.Notes) | |||||
| } | |||||
| } | |||||
| if err := s.manifestStore.UpdateFields(ctx, manifest.ID, fields); err != nil { | |||||
| return nil, nil, fmt.Errorf("update fields: %w", err) | |||||
| } | |||||
| sort.Slice(fields, func(i, j int) bool { | |||||
| if fields[i].DisplayOrder == fields[j].DisplayOrder { | |||||
| return fields[i].Path < fields[j].Path | |||||
| } | |||||
| return fields[i].DisplayOrder < fields[j].DisplayOrder | |||||
| }) | |||||
| return manifest, fields, nil | |||||
| } | |||||
| func defaultDiscoveryRequest(templateID int64) qcclient.GenerateContentRequest { | |||||
| return qcclient.GenerateContentRequest{ | |||||
| TemplateID: templateID, | |||||
| GlobalData: map[string]any{ | |||||
| "companyName": "Discovery Company", | |||||
| "businessType": "dentist", | |||||
| "siteLanguage": "EN", | |||||
| "email": "discovery@example.com", | |||||
| "phone": "+41 44 000 00 00", | |||||
| "address": map[string]any{ | |||||
| "line1": "Discovery Street 1", | |||||
| "line2": "", | |||||
| "city": "Zurich", | |||||
| "region": "ZH", | |||||
| "postalCode": "8000", | |||||
| "country": "CH", | |||||
| }, | |||||
| }, | |||||
| Empty: false, | |||||
| ToneOfVoice: "Professional", | |||||
| TargetAudience: "B2B", | |||||
| } | |||||
| } | |||||
| func flattenDiscovery(templateID int64, manifestID string, data qcclient.GenerateContentData) []domain.TemplateField { | |||||
| fields := make([]domain.TemplateField, 0) | |||||
| order := 0 | |||||
| for section, kv := range data { | |||||
| for key, value := range kv { | |||||
| sample := fmt.Sprint(value) | |||||
| path := section + "." + key | |||||
| kind := detectFieldKind(path, sample) | |||||
| enabled := kind == "text" | |||||
| fields = append(fields, domain.TemplateField{ | |||||
| ID: fmt.Sprintf("%s-%d", manifestID, order+1), | |||||
| TemplateID: templateID, | |||||
| ManifestID: manifestID, | |||||
| Section: section, | |||||
| KeyName: key, | |||||
| Path: path, | |||||
| FieldKind: kind, | |||||
| SampleValue: sample, | |||||
| IsEnabled: enabled, | |||||
| DisplayLabel: path, | |||||
| DisplayOrder: order, | |||||
| Notes: "", | |||||
| }) | |||||
| order++ | |||||
| } | |||||
| } | |||||
| return fields | |||||
| } | |||||
| func detectFieldKind(path string, sample string) string { | |||||
| sampleTrim := strings.TrimSpace(sample) | |||||
| if sampleTrim == "" { | |||||
| return "unknown" | |||||
| } | |||||
| if strings.EqualFold(sampleTrim, "#IMAGE#") { | |||||
| return "image" | |||||
| } | |||||
| if isLikelyImagePlaceholder(sampleTrim) { | |||||
| return "image" | |||||
| } | |||||
| if isLikelyImagePath(path) { | |||||
| return "image" | |||||
| } | |||||
| return "text" | |||||
| } | |||||
| func isLikelyImagePlaceholder(sample string) bool { | |||||
| normalized := strings.ToLower(strings.TrimSpace(sample)) | |||||
| if imagePlaceholderPattern.MatchString(normalized) { | |||||
| return true | |||||
| } | |||||
| if normalized == "image" || normalized == "img" || normalized == "photo" || normalized == "picture" { | |||||
| return true | |||||
| } | |||||
| return strings.Contains(normalized, " image image ") | |||||
| } | |||||
| func isLikelyImagePath(path string) bool { | |||||
| normalized := strings.ToLower(strings.TrimSpace(path)) | |||||
| for _, hint := range imageLikePathHints { | |||||
| if strings.Contains(normalized, hint) { | |||||
| return true | |||||
| } | |||||
| } | |||||
| return false | |||||
| } | |||||
| @@ -0,0 +1,95 @@ | |||||
| package onboarding | |||||
| import ( | |||||
| "testing" | |||||
| "qctextbuilder/internal/qcclient" | |||||
| ) | |||||
| func TestDetectFieldKind(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| path string | |||||
| sample string | |||||
| want string | |||||
| }{ | |||||
| { | |||||
| name: "exact image token", | |||||
| path: "hero.image", | |||||
| sample: "#IMAGE#", | |||||
| want: "image", | |||||
| }, | |||||
| { | |||||
| name: "image placeholder bracketed", | |||||
| path: "gallery.galleryImage_m4178_15", | |||||
| sample: "[image image image image]", | |||||
| want: "image", | |||||
| }, | |||||
| { | |||||
| name: "image path hint even with text sample", | |||||
| path: "section.thumbnailUrl", | |||||
| sample: "headline", | |||||
| want: "image", | |||||
| }, | |||||
| { | |||||
| name: "empty sample is unknown", | |||||
| path: "hero.title", | |||||
| sample: "", | |||||
| want: "unknown", | |||||
| }, | |||||
| { | |||||
| name: "plain text field", | |||||
| path: "hero.title", | |||||
| sample: "This is a headline", | |||||
| want: "text", | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| got := detectFieldKind(tt.path, tt.sample) | |||||
| if got != tt.want { | |||||
| t.Fatalf("detectFieldKind() = %q, want %q", got, tt.want) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) { | |||||
| data := qcclient.GenerateContentData{ | |||||
| "gallery": { | |||||
| "galleryImage_m4178_15": "[image image image image]", | |||||
| }, | |||||
| "hero": { | |||||
| "title": "Welcome", | |||||
| }, | |||||
| } | |||||
| fields := flattenDiscovery(99, "manifest-1", data) | |||||
| if len(fields) != 2 { | |||||
| t.Fatalf("flattenDiscovery() field count = %d, want 2", len(fields)) | |||||
| } | |||||
| byPath := map[string]string{} | |||||
| enabled := map[string]bool{} | |||||
| for _, f := range fields { | |||||
| byPath[f.Path] = f.FieldKind | |||||
| enabled[f.Path] = f.IsEnabled | |||||
| } | |||||
| imagePath := "gallery.galleryImage_m4178_15" | |||||
| if byPath[imagePath] != "image" { | |||||
| t.Fatalf("field %s kind = %q, want image", imagePath, byPath[imagePath]) | |||||
| } | |||||
| if enabled[imagePath] { | |||||
| t.Fatalf("field %s should be disabled by default", imagePath) | |||||
| } | |||||
| textPath := "hero.title" | |||||
| if byPath[textPath] != "text" { | |||||
| t.Fatalf("field %s kind = %q, want text", textPath, byPath[textPath]) | |||||
| } | |||||
| if !enabled[textPath] { | |||||
| t.Fatalf("field %s should be enabled by default", textPath) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,84 @@ | |||||
| package polling | |||||
| import ( | |||||
| "context" | |||||
| "log/slog" | |||||
| "sync" | |||||
| "time" | |||||
| "qctextbuilder/internal/buildsvc" | |||||
| "qctextbuilder/internal/store" | |||||
| ) | |||||
| type Service struct { | |||||
| buildSvc buildsvc.Service | |||||
| store store.BuildStore | |||||
| interval time.Duration | |||||
| maxPolls int | |||||
| logger *slog.Logger | |||||
| } | |||||
| func New(buildSvc buildsvc.Service, buildStore store.BuildStore, interval time.Duration, maxPolls int, logger *slog.Logger) *Service { | |||||
| if interval <= 0 { | |||||
| interval = 5 * time.Second | |||||
| } | |||||
| if maxPolls <= 0 { | |||||
| maxPolls = 4 | |||||
| } | |||||
| return &Service{ | |||||
| buildSvc: buildSvc, | |||||
| store: buildStore, | |||||
| interval: interval, | |||||
| maxPolls: maxPolls, | |||||
| logger: logger, | |||||
| } | |||||
| } | |||||
| func (s *Service) Start(ctx context.Context) error { | |||||
| ticker := time.NewTicker(s.interval) | |||||
| defer ticker.Stop() | |||||
| for { | |||||
| select { | |||||
| case <-ctx.Done(): | |||||
| return nil | |||||
| case <-ticker.C: | |||||
| s.PollPendingOnce(ctx, 20) | |||||
| } | |||||
| } | |||||
| } | |||||
| func (s *Service) PollPendingOnce(ctx context.Context, limit int) { | |||||
| builds, err := s.store.ListBuildsByStatuses(ctx, []string{"queued", "processing"}, limit) | |||||
| if err != nil { | |||||
| s.logger.Error("list pending builds failed", "error", err) | |||||
| return | |||||
| } | |||||
| semSize := s.maxPolls | |||||
| if semSize <= 0 { | |||||
| semSize = 1 | |||||
| } | |||||
| sem := make(chan struct{}, semSize) | |||||
| var wg sync.WaitGroup | |||||
| for _, build := range builds { | |||||
| select { | |||||
| case <-ctx.Done(): | |||||
| return | |||||
| default: | |||||
| } | |||||
| wg.Add(1) | |||||
| sem <- struct{}{} | |||||
| go func(buildID string) { | |||||
| defer wg.Done() | |||||
| defer func() { <-sem }() | |||||
| if err := s.buildSvc.PollOnce(ctx, buildID); err != nil { | |||||
| s.logger.Error("poll build failed", "buildId", buildID, "error", err) | |||||
| } | |||||
| }(build.ID) | |||||
| } | |||||
| wg.Wait() | |||||
| } | |||||
| @@ -0,0 +1,195 @@ | |||||
| package qcclient | |||||
| import ( | |||||
| "bytes" | |||||
| "context" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "io" | |||||
| "log/slog" | |||||
| "net/http" | |||||
| "strings" | |||||
| "time" | |||||
| ) | |||||
| type Client interface { | |||||
| Health(ctx context.Context) error | |||||
| ListAITemplates(ctx context.Context) ([]Template, error) | |||||
| GetTemplate(ctx context.Context, templateID int64) (*Template, error) | |||||
| GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) | |||||
| GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) | |||||
| CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) | |||||
| GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) | |||||
| GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) | |||||
| } | |||||
| type HTTPClient struct { | |||||
| baseURL string | |||||
| token string | |||||
| client *http.Client | |||||
| logger *slog.Logger | |||||
| } | |||||
| func New(baseURL, token string, timeout time.Duration, logger *slog.Logger) *HTTPClient { | |||||
| if timeout <= 0 { | |||||
| timeout = 10 * time.Second | |||||
| } | |||||
| return &HTTPClient{ | |||||
| baseURL: strings.TrimRight(baseURL, "/"), | |||||
| token: token, | |||||
| client: &http.Client{Timeout: timeout}, | |||||
| logger: logger, | |||||
| } | |||||
| } | |||||
| func (c *HTTPClient) Health(ctx context.Context) error { | |||||
| _, _, err := c.doJSON(ctx, http.MethodGet, "/health", nil) | |||||
| return err | |||||
| } | |||||
| func (c *HTTPClient) ListAITemplates(ctx context.Context) ([]Template, error) { | |||||
| body, _, err := c.doJSON(ctx, http.MethodGet, "/templates?type=ai", nil) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| var rsp APIResponse[[]Template] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, fmt.Errorf("decode templates response: %w", err) | |||||
| } | |||||
| return rsp.Data, nil | |||||
| } | |||||
| func (c *HTTPClient) GetTemplate(ctx context.Context, templateID int64) (*Template, error) { | |||||
| body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d", templateID), nil) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| var rsp APIResponse[Template] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, fmt.Errorf("decode template response: %w", err) | |||||
| } | |||||
| return &rsp.Data, nil | |||||
| } | |||||
| func (c *HTTPClient) GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) { | |||||
| body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d/schema", templateID), nil) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return json.RawMessage(body), nil | |||||
| } | |||||
| func (c *HTTPClient) GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) { | |||||
| body, raw, err := c.doJSON(ctx, http.MethodPost, "/generate-content", req) | |||||
| if err != nil { | |||||
| return nil, nil, err | |||||
| } | |||||
| var rsp APIResponse[GenerateContentData] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, raw, fmt.Errorf("decode generate-content response: %w", err) | |||||
| } | |||||
| return rsp.Data, raw, nil | |||||
| } | |||||
| func (c *HTTPClient) CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) { | |||||
| body, raw, err := c.doJSON(ctx, http.MethodPost, "/sites", req) | |||||
| if err != nil { | |||||
| return nil, nil, err | |||||
| } | |||||
| var rsp APIResponse[CreateSiteResponseData] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, raw, fmt.Errorf("decode create site response: %w", err) | |||||
| } | |||||
| return &rsp.Data, raw, nil | |||||
| } | |||||
| func (c *HTTPClient) GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) { | |||||
| body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/jobs/%d", jobID), nil) | |||||
| if err != nil { | |||||
| return nil, nil, err | |||||
| } | |||||
| var rsp APIResponse[JobStatusData] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, raw, fmt.Errorf("decode job response: %w", err) | |||||
| } | |||||
| return &rsp.Data, raw, nil | |||||
| } | |||||
| func (c *HTTPClient) GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) { | |||||
| body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/sites/%d/editor-url", siteID), nil) | |||||
| if err != nil { | |||||
| return nil, nil, err | |||||
| } | |||||
| var rsp APIResponse[SiteEditorLoginData] | |||||
| if err := json.Unmarshal(body, &rsp); err != nil { | |||||
| return nil, raw, fmt.Errorf("decode editor-url response: %w", err) | |||||
| } | |||||
| return &rsp.Data, raw, nil | |||||
| } | |||||
| func (c *HTTPClient) doJSON(ctx context.Context, method, path string, payload any) ([]byte, json.RawMessage, error) { | |||||
| var body io.Reader | |||||
| if payload != nil { | |||||
| b, err := json.Marshal(payload) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("marshal request: %w", err) | |||||
| } | |||||
| body = bytes.NewReader(b) | |||||
| } | |||||
| req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("build request: %w", err) | |||||
| } | |||||
| req.Header.Set("Authorization", "Bearer "+c.token) | |||||
| req.Header.Set("Accept", "application/json") | |||||
| if payload != nil { | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| } | |||||
| start := time.Now() | |||||
| res, err := c.client.Do(req) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("do request: %w", err) | |||||
| } | |||||
| defer res.Body.Close() | |||||
| respBody, err := io.ReadAll(res.Body) | |||||
| if err != nil { | |||||
| return nil, nil, fmt.Errorf("read response: %w", err) | |||||
| } | |||||
| c.logger.Info("qc request", | |||||
| "method", method, | |||||
| "path", path, | |||||
| "status", res.StatusCode, | |||||
| "duration_ms", time.Since(start).Milliseconds(), | |||||
| "auth", "Bearer [REDACTED]", | |||||
| ) | |||||
| if res.StatusCode >= 400 { | |||||
| return nil, json.RawMessage(respBody), c.mapHTTPError(res.StatusCode, respBody) | |||||
| } | |||||
| return respBody, json.RawMessage(respBody), nil | |||||
| } | |||||
| func (c *HTTPClient) mapHTTPError(statusCode int, raw []byte) error { | |||||
| var apiErr APIError | |||||
| _ = json.Unmarshal(raw, &apiErr) | |||||
| err := &HTTPError{ | |||||
| StatusCode: statusCode, | |||||
| APIError: &apiErr, | |||||
| RawBody: raw, | |||||
| } | |||||
| switch statusCode { | |||||
| case http.StatusBadRequest: | |||||
| return fmt.Errorf("%w: %w", ErrBadRequest, err) | |||||
| case http.StatusUnauthorized: | |||||
| return fmt.Errorf("%w: %w", ErrUnauthorized, err) | |||||
| case http.StatusNotFound: | |||||
| return fmt.Errorf("%w: %w", ErrNotFound, err) | |||||
| default: | |||||
| return err | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,22 @@ | |||||
| package qcclient | |||||
| import "errors" | |||||
| var ( | |||||
| ErrUnauthorized = errors.New("qc unauthorized") | |||||
| ErrBadRequest = errors.New("qc bad request") | |||||
| ErrNotFound = errors.New("qc not found") | |||||
| ) | |||||
| type HTTPError struct { | |||||
| StatusCode int | |||||
| APIError *APIError | |||||
| RawBody []byte | |||||
| } | |||||
| func (e *HTTPError) Error() string { | |||||
| if e.APIError != nil && e.APIError.Message != "" { | |||||
| return e.APIError.Message | |||||
| } | |||||
| return "qc request failed" | |||||
| } | |||||
| @@ -0,0 +1,140 @@ | |||||
| package qcclient | |||||
| import ( | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "strconv" | |||||
| "strings" | |||||
| ) | |||||
| type APIResponse[T any] struct { | |||||
| Status string `json:"status"` | |||||
| Data T `json:"data"` | |||||
| } | |||||
| type APIError struct { | |||||
| Status string `json:"status"` | |||||
| Message string `json:"message"` | |||||
| Code string `json:"code"` | |||||
| Details map[string]any `json:"details,omitempty"` | |||||
| } | |||||
| type Template struct { | |||||
| ID int64 `json:"id"` | |||||
| Name string `json:"name"` | |||||
| Description string `json:"description"` | |||||
| Locale string `json:"locale"` | |||||
| ThumbnailURL string `json:"thumbnailUrl"` | |||||
| TemplatePreviewURL string `json:"templatePreviewUrl"` | |||||
| Type string `json:"type"` | |||||
| PaletteReady Boolish `json:"paletteReady"` | |||||
| GlobalColors map[string]any `json:"globalColors"` | |||||
| TextsWithGlobalColors map[string]any `json:"textsWithGlobalColors"` | |||||
| TemplateHeadings []TemplateHeading `json:"templateHeadings,omitempty"` | |||||
| Raw map[string]interface{} `json:"-"` | |||||
| } | |||||
| type Boolish bool | |||||
| func (b Boolish) Bool() bool { | |||||
| return bool(b) | |||||
| } | |||||
| func (b Boolish) MarshalJSON() ([]byte, error) { | |||||
| return json.Marshal(bool(b)) | |||||
| } | |||||
| func (b *Boolish) UnmarshalJSON(data []byte) error { | |||||
| trimmed := strings.TrimSpace(string(data)) | |||||
| if trimmed == "" || strings.EqualFold(trimmed, "null") { | |||||
| *b = false | |||||
| return nil | |||||
| } | |||||
| var boolVal bool | |||||
| if err := json.Unmarshal(data, &boolVal); err == nil { | |||||
| *b = Boolish(boolVal) | |||||
| return nil | |||||
| } | |||||
| var numVal float64 | |||||
| if err := json.Unmarshal(data, &numVal); err == nil { | |||||
| *b = Boolish(numVal != 0) | |||||
| return nil | |||||
| } | |||||
| var stringVal string | |||||
| if err := json.Unmarshal(data, &stringVal); err == nil { | |||||
| parsed, err := parseBoolishString(stringVal) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| *b = Boolish(parsed) | |||||
| return nil | |||||
| } | |||||
| return fmt.Errorf("unsupported boolish value: %s", trimmed) | |||||
| } | |||||
| func parseBoolishString(v string) (bool, error) { | |||||
| normalized := strings.ToLower(strings.TrimSpace(v)) | |||||
| switch normalized { | |||||
| case "", "0", "false", "f", "no", "n", "off": | |||||
| return false, nil | |||||
| case "1", "true", "t", "yes", "y", "on": | |||||
| return true, nil | |||||
| } | |||||
| numeric, err := strconv.ParseFloat(normalized, 64) | |||||
| if err == nil { | |||||
| return numeric != 0, nil | |||||
| } | |||||
| return false, fmt.Errorf("unsupported boolish string value: %q", v) | |||||
| } | |||||
| type TemplateHeading struct { | |||||
| TranslationKey string `json:"translationKey"` | |||||
| } | |||||
| type GenerateContentRequest struct { | |||||
| TemplateID int64 `json:"templateId"` | |||||
| GlobalData map[string]any `json:"globalData"` | |||||
| Empty bool `json:"empty"` | |||||
| ToneOfVoice string `json:"toneOfVoice,omitempty"` | |||||
| TargetAudience string `json:"targetAudience,omitempty"` | |||||
| CustomTemplateData map[string]any `json:"customTemplateData,omitempty"` | |||||
| } | |||||
| type GenerateContentData map[string]map[string]any | |||||
| type CreateSiteRequest struct { | |||||
| TemplateID int64 `json:"templateId"` | |||||
| GlobalData map[string]any `json:"globalData"` | |||||
| Content CreateSiteContent `json:"content"` | |||||
| } | |||||
| type CreateSiteContent struct { | |||||
| AIData map[string]map[string]any `json:"aiData"` | |||||
| } | |||||
| type CreateSiteResponseData struct { | |||||
| Status string `json:"status"` | |||||
| JobID int64 `json:"jobId"` | |||||
| } | |||||
| type JobStatusResult struct { | |||||
| SiteID int64 `json:"siteId"` | |||||
| PreviewURL string `json:"previewUrl"` | |||||
| } | |||||
| type JobStatusData struct { | |||||
| JobID int64 `json:"jobId"` | |||||
| Status string `json:"status"` | |||||
| CreatedAt int64 `json:"createdAt"` | |||||
| Result JobStatusResult `json:"result"` | |||||
| } | |||||
| type SiteEditorLoginData struct { | |||||
| LoginURL string `json:"loginUrl"` | |||||
| } | |||||
| @@ -0,0 +1,79 @@ | |||||
| package qcclient | |||||
| import ( | |||||
| "encoding/json" | |||||
| "testing" | |||||
| ) | |||||
| func TestBoolish_UnmarshalJSON(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| input string | |||||
| want bool | |||||
| expectErr bool | |||||
| }{ | |||||
| {name: "bool true", input: `true`, want: true}, | |||||
| {name: "bool false", input: `false`, want: false}, | |||||
| {name: "number one", input: `1`, want: true}, | |||||
| {name: "number zero", input: `0`, want: false}, | |||||
| {name: "number float", input: `2.5`, want: true}, | |||||
| {name: "string one", input: `"1"`, want: true}, | |||||
| {name: "string zero", input: `"0"`, want: false}, | |||||
| {name: "string true", input: `"true"`, want: true}, | |||||
| {name: "string false", input: `"false"`, want: false}, | |||||
| {name: "string yes", input: `"yes"`, want: true}, | |||||
| {name: "string no", input: `"no"`, want: false}, | |||||
| {name: "string numeric", input: `"0.0"`, want: false}, | |||||
| {name: "null", input: `null`, want: false}, | |||||
| {name: "invalid string", input: `"maybe"`, expectErr: true}, | |||||
| {name: "invalid object", input: `{}`, expectErr: true}, | |||||
| } | |||||
| for _, tc := range tests { | |||||
| t.Run(tc.name, func(t *testing.T) { | |||||
| var got Boolish | |||||
| err := json.Unmarshal([]byte(tc.input), &got) | |||||
| if tc.expectErr { | |||||
| if err == nil { | |||||
| t.Fatalf("expected error for input %s", tc.input) | |||||
| } | |||||
| return | |||||
| } | |||||
| if err != nil { | |||||
| t.Fatalf("unexpected error for input %s: %v", tc.input, err) | |||||
| } | |||||
| if got.Bool() != tc.want { | |||||
| t.Fatalf("got %v, want %v for input %s", got.Bool(), tc.want, tc.input) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestTemplateUnmarshal_PaletteReadyVariants(t *testing.T) { | |||||
| payload := `{ | |||||
| "status":"ok", | |||||
| "data":[ | |||||
| {"id":1,"name":"a","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":true,"globalColors":{},"textsWithGlobalColors":{}}, | |||||
| {"id":2,"name":"b","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":1,"globalColors":{},"textsWithGlobalColors":{}}, | |||||
| {"id":3,"name":"c","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":"0","globalColors":{},"textsWithGlobalColors":{}} | |||||
| ] | |||||
| }` | |||||
| var rsp APIResponse[[]Template] | |||||
| if err := json.Unmarshal([]byte(payload), &rsp); err != nil { | |||||
| t.Fatalf("unexpected error: %v", err) | |||||
| } | |||||
| if len(rsp.Data) != 3 { | |||||
| t.Fatalf("unexpected template count: got %d, want 3", len(rsp.Data)) | |||||
| } | |||||
| if !rsp.Data[0].PaletteReady.Bool() { | |||||
| t.Fatalf("template 1 paletteReady should be true") | |||||
| } | |||||
| if !rsp.Data[1].PaletteReady.Bool() { | |||||
| t.Fatalf("template 2 paletteReady should be true") | |||||
| } | |||||
| if rsp.Data[2].PaletteReady.Bool() { | |||||
| t.Fatalf("template 3 paletteReady should be false") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| package store | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "time" | |||||
| "qctextbuilder/internal/domain" | |||||
| ) | |||||
| type TemplateStore interface { | |||||
| UpsertTemplates(ctx context.Context, templates []domain.Template) error | |||||
| GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) | |||||
| ListTemplates(ctx context.Context) ([]domain.Template, error) | |||||
| SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error | |||||
| } | |||||
| type ManifestStore interface { | |||||
| CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error | |||||
| GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) | |||||
| ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) | |||||
| UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error | |||||
| } | |||||
| type BuildStore interface { | |||||
| CreateBuild(ctx context.Context, build domain.SiteBuild) error | |||||
| GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) | |||||
| ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) | |||||
| MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error | |||||
| UpdateBuildFromJob(ctx context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error | |||||
| UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error | |||||
| } | |||||
| type SettingsStore interface{} | |||||
| @@ -0,0 +1,237 @@ | |||||
| package memory | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "errors" | |||||
| "sync" | |||||
| "time" | |||||
| "qctextbuilder/internal/domain" | |||||
| ) | |||||
| var ErrNotFound = errors.New("not found") | |||||
| type Store struct { | |||||
| mu sync.RWMutex | |||||
| templates map[int64]domain.Template | |||||
| manifests map[int64]domain.TemplateManifest | |||||
| manifestField map[string][]domain.TemplateField | |||||
| builds map[string]domain.SiteBuild | |||||
| } | |||||
| func New() *Store { | |||||
| return &Store{ | |||||
| templates: make(map[int64]domain.Template), | |||||
| manifests: make(map[int64]domain.TemplateManifest), | |||||
| manifestField: make(map[string][]domain.TemplateField), | |||||
| builds: make(map[string]domain.SiteBuild), | |||||
| } | |||||
| } | |||||
| func (s *Store) UpsertTemplates(_ context.Context, templates []domain.Template) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| for _, t := range templates { | |||||
| existing, ok := s.templates[t.ID] | |||||
| if ok { | |||||
| t.IsOnboarded = existing.IsOnboarded | |||||
| t.ManifestStatus = existing.ManifestStatus | |||||
| t.LastDiscoveredAt = existing.LastDiscoveredAt | |||||
| } | |||||
| s.templates[t.ID] = t | |||||
| } | |||||
| return nil | |||||
| } | |||||
| func (s *Store) GetTemplateByID(_ context.Context, id int64) (*domain.Template, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| t, ok := s.templates[id] | |||||
| if !ok { | |||||
| return nil, ErrNotFound | |||||
| } | |||||
| copy := t | |||||
| return ©, nil | |||||
| } | |||||
| func (s *Store) ListTemplates(_ context.Context) ([]domain.Template, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| out := make([]domain.Template, 0, len(s.templates)) | |||||
| for _, t := range s.templates { | |||||
| out = append(out, t) | |||||
| } | |||||
| return out, nil | |||||
| } | |||||
| func (s *Store) SetTemplateManifestStatus(_ context.Context, templateID int64, status string, onboarded bool) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| t, ok := s.templates[templateID] | |||||
| if !ok { | |||||
| return ErrNotFound | |||||
| } | |||||
| t.ManifestStatus = status | |||||
| t.IsOnboarded = onboarded | |||||
| s.templates[templateID] = t | |||||
| return nil | |||||
| } | |||||
| func (s *Store) CreateManifest(_ context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| s.manifests[manifest.TemplateID] = manifest | |||||
| s.manifestField[manifest.ID] = fields | |||||
| return nil | |||||
| } | |||||
| func (s *Store) GetActiveManifestByTemplateID(_ context.Context, templateID int64) (*domain.TemplateManifest, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| m, ok := s.manifests[templateID] | |||||
| if !ok { | |||||
| return nil, ErrNotFound | |||||
| } | |||||
| copy := m | |||||
| return ©, nil | |||||
| } | |||||
| func (s *Store) ListFieldsByManifestID(_ context.Context, manifestID string) ([]domain.TemplateField, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| fields, ok := s.manifestField[manifestID] | |||||
| if !ok { | |||||
| return nil, ErrNotFound | |||||
| } | |||||
| out := make([]domain.TemplateField, 0, len(fields)) | |||||
| out = append(out, fields...) | |||||
| return out, nil | |||||
| } | |||||
| func (s *Store) UpdateFields(_ context.Context, manifestID string, fields []domain.TemplateField) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| if _, ok := s.manifestField[manifestID]; !ok { | |||||
| return ErrNotFound | |||||
| } | |||||
| next := make([]domain.TemplateField, len(fields)) | |||||
| copy(next, fields) | |||||
| s.manifestField[manifestID] = next | |||||
| return nil | |||||
| } | |||||
| func (s *Store) CreateBuild(_ context.Context, build domain.SiteBuild) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| if _, exists := s.builds[build.ID]; exists { | |||||
| return errors.New("build already exists") | |||||
| } | |||||
| s.builds[build.ID] = build | |||||
| return nil | |||||
| } | |||||
| func (s *Store) GetBuildByID(_ context.Context, id string) (*domain.SiteBuild, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| build, ok := s.builds[id] | |||||
| if !ok { | |||||
| return nil, ErrNotFound | |||||
| } | |||||
| copy := build | |||||
| return ©, nil | |||||
| } | |||||
| func (s *Store) ListBuildsByStatuses(_ context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) { | |||||
| s.mu.RLock() | |||||
| defer s.mu.RUnlock() | |||||
| allowed := make(map[string]struct{}, len(statuses)) | |||||
| for _, status := range statuses { | |||||
| allowed[status] = struct{}{} | |||||
| } | |||||
| out := make([]domain.SiteBuild, 0) | |||||
| for _, build := range s.builds { | |||||
| if len(allowed) > 0 { | |||||
| if _, ok := allowed[build.QCStatus]; !ok { | |||||
| continue | |||||
| } | |||||
| } | |||||
| out = append(out, build) | |||||
| if limit > 0 && len(out) >= limit { | |||||
| break | |||||
| } | |||||
| } | |||||
| return out, nil | |||||
| } | |||||
| func (s *Store) MarkBuildSubmitted(_ context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| build, ok := s.builds[buildID] | |||||
| if !ok { | |||||
| return ErrNotFound | |||||
| } | |||||
| build.QCJobID = &jobID | |||||
| build.QCStatus = status | |||||
| build.QCResultJSON = cloneRaw(qcResult) | |||||
| build.StartedAt = &startedAt | |||||
| s.builds[buildID] = build | |||||
| return nil | |||||
| } | |||||
| func (s *Store) UpdateBuildFromJob(_ context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| build, ok := s.builds[buildID] | |||||
| if !ok { | |||||
| return ErrNotFound | |||||
| } | |||||
| build.QCStatus = status | |||||
| build.QCResultJSON = cloneRaw(qcResult) | |||||
| build.QCErrorJSON = cloneRaw(qcError) | |||||
| build.QCPreviewURL = previewURL | |||||
| if siteID != nil { | |||||
| id := *siteID | |||||
| build.QCSiteID = &id | |||||
| } | |||||
| build.FinishedAt = finishedAt | |||||
| s.builds[buildID] = build | |||||
| return nil | |||||
| } | |||||
| func (s *Store) UpdateBuildEditorURL(_ context.Context, buildID string, editorURL string, qcResult json.RawMessage) error { | |||||
| s.mu.Lock() | |||||
| defer s.mu.Unlock() | |||||
| build, ok := s.builds[buildID] | |||||
| if !ok { | |||||
| return ErrNotFound | |||||
| } | |||||
| build.QCEditorURL = editorURL | |||||
| build.QCResultJSON = cloneRaw(qcResult) | |||||
| s.builds[buildID] = build | |||||
| return nil | |||||
| } | |||||
| func cloneRaw(raw json.RawMessage) json.RawMessage { | |||||
| if raw == nil { | |||||
| return nil | |||||
| } | |||||
| out := make([]byte, len(raw)) | |||||
| copy(out, raw) | |||||
| return json.RawMessage(out) | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| package postgres | |||||
| // TODO(milestone-2): postgres-backed store implementation. | |||||
| @@ -0,0 +1,3 @@ | |||||
| package sqlite | |||||
| // TODO(milestone-2): sqlite-backed store implementation for local development. | |||||
| @@ -0,0 +1,117 @@ | |||||
| package templatesvc | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "sort" | |||||
| "strings" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/qcclient" | |||||
| "qctextbuilder/internal/store" | |||||
| ) | |||||
| type Service struct { | |||||
| qc qcclient.Client | |||||
| templateStore store.TemplateStore | |||||
| manifestStore store.ManifestStore | |||||
| } | |||||
| type TemplateDetail struct { | |||||
| Template *domain.Template `json:"template"` | |||||
| Manifest *domain.TemplateManifest `json:"manifest,omitempty"` | |||||
| Fields []domain.TemplateField `json:"fields"` | |||||
| } | |||||
| func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service { | |||||
| return &Service{ | |||||
| qc: qc, | |||||
| templateStore: templateStore, | |||||
| manifestStore: manifestStore, | |||||
| } | |||||
| } | |||||
| func (s *Service) SyncAITemplates(ctx context.Context) ([]domain.Template, error) { | |||||
| templates, err := s.qc.ListAITemplates(ctx) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| out := make([]domain.Template, 0, len(templates)) | |||||
| for _, t := range templates { | |||||
| raw, _ := json.Marshal(t) | |||||
| out = append(out, domain.Template{ | |||||
| ID: t.ID, | |||||
| Name: t.Name, | |||||
| Description: t.Description, | |||||
| Locale: strings.ToUpper(t.Locale), | |||||
| ThumbnailURL: t.ThumbnailURL, | |||||
| TemplatePreviewURL: t.TemplatePreviewURL, | |||||
| Type: t.Type, | |||||
| PaletteReady: t.PaletteReady.Bool(), | |||||
| RawJSON: raw, | |||||
| IsAITemplate: strings.EqualFold(t.Type, "ai"), | |||||
| ManifestStatus: "missing", | |||||
| }) | |||||
| } | |||||
| if err := s.templateStore.UpsertTemplates(ctx, out); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return out, nil | |||||
| } | |||||
| func (s *Service) ListTemplates(ctx context.Context) ([]domain.Template, error) { | |||||
| templates, err := s.templateStore.ListTemplates(ctx) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("list templates: %w", err) | |||||
| } | |||||
| sort.Slice(templates, func(i, j int) bool { | |||||
| if templates[i].Name == templates[j].Name { | |||||
| return templates[i].ID < templates[j].ID | |||||
| } | |||||
| return templates[i].Name < templates[j].Name | |||||
| }) | |||||
| return templates, nil | |||||
| } | |||||
| func (s *Service) GetTemplateDetail(ctx context.Context, templateID int64) (*TemplateDetail, error) { | |||||
| template, err := s.templateStore.GetTemplateByID(ctx, templateID) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("get template: %w", err) | |||||
| } | |||||
| detail := &TemplateDetail{ | |||||
| Template: template, | |||||
| Fields: []domain.TemplateField{}, | |||||
| } | |||||
| manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID) | |||||
| if err != nil { | |||||
| if isNotFoundErr(err) { | |||||
| return detail, nil | |||||
| } | |||||
| return nil, fmt.Errorf("get active manifest: %w", err) | |||||
| } | |||||
| fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("list fields: %w", err) | |||||
| } | |||||
| sort.Slice(fields, func(i, j int) bool { | |||||
| if fields[i].DisplayOrder == fields[j].DisplayOrder { | |||||
| return fields[i].Path < fields[j].Path | |||||
| } | |||||
| return fields[i].DisplayOrder < fields[j].DisplayOrder | |||||
| }) | |||||
| detail.Manifest = manifest | |||||
| detail.Fields = fields | |||||
| return detail, nil | |||||
| } | |||||
| func isNotFoundErr(err error) bool { | |||||
| return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "not found") | |||||
| } | |||||
| @@ -0,0 +1,29 @@ | |||||
| package validation | |||||
| import ( | |||||
| "errors" | |||||
| "strings" | |||||
| ) | |||||
| var ( | |||||
| ErrMissingCompanyName = errors.New("globalData.companyName is required") | |||||
| ErrMissingEmail = errors.New("globalData.email is required") | |||||
| ErrMissingUsername = errors.New("globalData.username is required") | |||||
| ) | |||||
| func ValidateBuildGlobalData(globalData map[string]any) error { | |||||
| company, _ := globalData["companyName"].(string) | |||||
| email, _ := globalData["email"].(string) | |||||
| username, _ := globalData["username"].(string) | |||||
| if strings.TrimSpace(company) == "" { | |||||
| return ErrMissingCompanyName | |||||
| } | |||||
| if strings.TrimSpace(email) == "" { | |||||
| return ErrMissingEmail | |||||
| } | |||||
| if strings.TrimSpace(username) == "" { | |||||
| return ErrMissingUsername | |||||
| } | |||||
| return nil | |||||
| } | |||||
| @@ -0,0 +1,25 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| $projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path | |||||
| $envFile = Join-Path $projectRoot '.env.local' | |||||
| if (-not (Test-Path $envFile)) { | |||||
| throw "Missing .env.local at $envFile" | |||||
| } | |||||
| Get-Content $envFile | ForEach-Object { | |||||
| $line = $_.Trim() | |||||
| if (-not $line -or $line.StartsWith('#')) { return } | |||||
| $parts = $line -split '=', 2 | |||||
| if ($parts.Count -ne 2) { return } | |||||
| $name = $parts[0].Trim() | |||||
| $value = $parts[1] | |||||
| [System.Environment]::SetEnvironmentVariable($name, $value, 'Process') | |||||
| } | |||||
| Write-Host "Starting QC Text Builder on $env:HTTP_ADDR" -ForegroundColor Green | |||||
| Set-Location $projectRoot | |||||
| go run ./cmd/qctextbuilder | |||||
| @@ -0,0 +1,49 @@ | |||||
| {{define "nav"}} | |||||
| <nav> | |||||
| <a href="/" {{if eq .Current "/"}}aria-current="page"{{end}}>Home</a> | |||||
| <a href="/settings" {{if eq .Current "/settings"}}aria-current="page"{{end}}>Settings</a> | |||||
| <a href="/templates" {{if eq .Current "/templates"}}aria-current="page"{{end}}>Templates</a> | |||||
| <a href="/builds/new" {{if eq .Current "/builds/new"}}aria-current="page"{{end}}>New Build</a> | |||||
| </nav> | |||||
| {{end}} | |||||
| {{define "head"}} | |||||
| <meta charset="utf-8"> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||||
| <style> | |||||
| :root { color-scheme: light; } | |||||
| body { font-family: "Segoe UI", Tahoma, sans-serif; margin: 2rem auto; max-width: 1100px; padding: 0 1rem; line-height: 1.35; } | |||||
| nav { margin-bottom: 1rem; display: flex; gap: 1rem; } | |||||
| a[aria-current="page"] { font-weight: 700; } | |||||
| h1, h2 { margin: .4rem 0 .6rem; } | |||||
| table { border-collapse: collapse; width: 100%; margin: .8rem 0 1.2rem; } | |||||
| th, td { border: 1px solid #ddd; padding: .45rem .5rem; vertical-align: top; } | |||||
| th { background: #f6f6f6; text-align: left; } | |||||
| form.inline { display: inline; } | |||||
| input[type="text"], input[type="email"], input[type="number"], textarea, select { width: 100%; padding: .4rem; box-sizing: border-box; } | |||||
| textarea { min-height: 4rem; } | |||||
| .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; } | |||||
| .flash { padding: .6rem .8rem; margin-bottom: .8rem; border-radius: 4px; } | |||||
| .flash-ok { background: #ecf8ed; border: 1px solid #84c08a; } | |||||
| .flash-err { background: #fdecec; border: 1px solid #dc8f8f; } | |||||
| .mono { font-family: Consolas, "Courier New", monospace; white-space: pre-wrap; word-break: break-word; } | |||||
| .thumb-hover { position: relative; display: inline-block; } | |||||
| .thumb-preview { | |||||
| display: none; | |||||
| position: absolute; | |||||
| left: 0; | |||||
| top: calc(100% + .35rem); | |||||
| z-index: 20; | |||||
| width: 260px; | |||||
| max-width: min(70vw, 360px); | |||||
| background: #fff; | |||||
| border: 1px solid #cfcfcf; | |||||
| box-shadow: 0 6px 24px rgba(0, 0, 0, .18); | |||||
| border-radius: 4px; | |||||
| padding: .35rem; | |||||
| } | |||||
| .thumb-preview img { display: block; width: 100%; height: auto; border-radius: 2px; } | |||||
| .thumb-hover:hover .thumb-preview, | |||||
| .thumb-hover:focus-within .thumb-preview { display: block; } | |||||
| </style> | |||||
| {{end}} | |||||
| @@ -0,0 +1,60 @@ | |||||
| {{define "build_detail"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{if gt .AutoRefreshSeconds 0}}<meta http-equiv="refresh" content="{{.AutoRefreshSeconds}}">{{end}} | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>Build Detail</h1> | |||||
| <table> | |||||
| <tr><th>Build ID</th><td class="mono">{{.Build.ID}}</td></tr> | |||||
| <tr><th>Template ID</th><td>{{.Build.TemplateID}}</td></tr> | |||||
| <tr><th>Manifest ID</th><td class="mono">{{.Build.ManifestID}}</td></tr> | |||||
| <tr><th>Request Name</th><td>{{.Build.RequestName}}</td></tr> | |||||
| <tr><th>Status</th><td>{{.Build.QCStatus}}</td></tr> | |||||
| <tr><th>QC Job ID</th><td>{{if .Build.QCJobID}}{{.Build.QCJobID}}{{else}}-{{end}}</td></tr> | |||||
| <tr><th>QC Site ID</th><td>{{if .Build.QCSiteID}}{{.Build.QCSiteID}}{{else}}-{{end}}</td></tr> | |||||
| <tr><th>Preview URL</th><td>{{if .Build.QCPreviewURL}}<a href="{{.Build.QCPreviewURL}}" target="_blank" rel="noopener">open</a>{{else}}-{{end}}</td></tr> | |||||
| <tr><th>Editor URL</th><td>{{if .Build.QCEditorURL}}<a href="{{.Build.QCEditorURL}}" target="_blank" rel="noopener">open</a>{{else}}-{{end}}</td></tr> | |||||
| <tr><th>Started At</th><td>{{if .Build.StartedAt}}{{.Build.StartedAt}}{{else}}-{{end}}</td></tr> | |||||
| <tr><th>Finished At</th><td>{{if .Build.FinishedAt}}{{.Build.FinishedAt}}{{else}}-{{end}}</td></tr> | |||||
| </table> | |||||
| {{if .CanPoll}} | |||||
| <p>Build is active. This page auto-refreshes every {{.AutoRefreshSeconds}} seconds.</p> | |||||
| <form class="inline" method="post" action="/builds/{{.Build.ID}}/poll"> | |||||
| <button type="submit">Poll Once</button> | |||||
| </form> | |||||
| {{end}} | |||||
| {{if .CanFetchEditorURL}} | |||||
| <form class="inline" method="post" action="/builds/{{.Build.ID}}/fetch-editor-url"> | |||||
| <button type="submit">Fetch Editor URL</button> | |||||
| </form> | |||||
| {{end}} | |||||
| <h2>Effective Global Data (sent to /sites)</h2> | |||||
| <p class="mono">{{prettyJSON .EffectiveGlobal}}</p> | |||||
| <h2>Stored Global Data JSON</h2> | |||||
| <p class="mono">{{prettyJSON .Build.GlobalDataJSON}}</p> | |||||
| <h2>AI Data JSON</h2> | |||||
| <p class="mono">{{prettyJSON .Build.AIDataJSON}}</p> | |||||
| <h2>Final /sites Payload</h2> | |||||
| <p class="mono">{{prettyJSON .Build.FinalSitesPayload}}</p> | |||||
| <h2>QC Result JSON</h2> | |||||
| <p class="mono">{{prettyJSON .Build.QCResultJSON}}</p> | |||||
| <h2>QC Error JSON</h2> | |||||
| <p class="mono">{{prettyJSON .Build.QCErrorJSON}}</p> | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||
| @@ -0,0 +1,93 @@ | |||||
| {{define "build_new"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>New Build</h1> | |||||
| <form method="get" action="/builds/new"> | |||||
| <label for="template_id">Template</label> | |||||
| <select id="template_id" name="template_id"> | |||||
| <option value="">Select template</option> | |||||
| {{range .Templates}} | |||||
| <option value="{{.ID}}" {{if eq $.SelectedTemplateID .ID}}selected{{end}}>{{.Name}} ({{.ID}})</option> | |||||
| {{end}} | |||||
| </select> | |||||
| <button type="submit">Load Fields</button> | |||||
| </form> | |||||
| {{if gt .SelectedTemplateID 0}} | |||||
| <form method="post" action="/builds"> | |||||
| <input type="hidden" name="template_id" value="{{.SelectedTemplateID}}"> | |||||
| <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | |||||
| <input type="hidden" name="field_count" value="{{len .EnabledFields}}"> | |||||
| <h2>Global Data</h2> | |||||
| <div><label>Request Name<input type="text" name="request_name" value="{{.Form.RequestName}}"></label></div> | |||||
| <h3>Basis / Firma</h3> | |||||
| <div class="grid2"> | |||||
| <div><label>Firmenname*<input type="text" name="company_name" value="{{.Form.CompanyName}}" required></label></div> | |||||
| <div><label>Benutzername*<input type="text" name="username" value="{{.Form.Username}}" required></label></div> | |||||
| <div><label>Branche / Business Type<input type="text" name="business_type" value="{{.Form.BusinessType}}"></label></div> | |||||
| <div><label>Website-Sprache<input type="text" name="site_language" value="{{.Form.SiteLanguage}}"></label></div> | |||||
| </div> | |||||
| <h3>Kontakt</h3> | |||||
| <div class="grid2"> | |||||
| <div><label>E-Mail*<input type="email" name="email" value="{{.Form.Email}}" required></label></div> | |||||
| <div><label>Telefon<input type="text" name="phone" value="{{.Form.Phone}}"></label></div> | |||||
| </div> | |||||
| <h3>Firmeninformation</h3> | |||||
| <div class="grid2"> | |||||
| <div><label>Organisationsnummer<input type="text" name="org_number" value="{{.Form.OrgNumber}}"></label></div> | |||||
| <div><label>Startdatum<input type="text" name="start_date" value="{{.Form.StartDate}}" placeholder="YYYY-MM-DD"></label></div> | |||||
| <div><label>Mission<textarea name="mission">{{.Form.Mission}}</textarea></label></div> | |||||
| <div><label>Kurzbeschreibung<textarea name="description_short">{{.Form.DescriptionShort}}</textarea></label></div> | |||||
| </div> | |||||
| <div><label>Langbeschreibung<textarea name="description_long">{{.Form.DescriptionLong}}</textarea></label></div> | |||||
| <h3>Adresse</h3> | |||||
| <div class="grid2"> | |||||
| <div><label>Adresszeile 1<input type="text" name="address_line1" value="{{.Form.AddressLine1}}"></label></div> | |||||
| <div><label>Adresszeile 2<input type="text" name="address_line2" value="{{.Form.AddressLine2}}"></label></div> | |||||
| <div><label>Stadt<input type="text" name="address_city" value="{{.Form.AddressCity}}"></label></div> | |||||
| <div><label>Region / Bundesland<input type="text" name="address_region" value="{{.Form.AddressRegion}}"></label></div> | |||||
| <div><label>PLZ<input type="text" name="address_zip" value="{{.Form.AddressZIP}}"></label></div> | |||||
| <div><label>Land<input type="text" name="address_country" value="{{.Form.AddressCountry}}"></label></div> | |||||
| </div> | |||||
| <h2>Enabled Text Fields</h2> | |||||
| <table> | |||||
| <thead> | |||||
| <tr><th>Field</th><th>Value</th><th>Sample</th></tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {{range $i, $f := .EnabledFields}} | |||||
| <tr> | |||||
| <td> | |||||
| <input type="hidden" name="field_path_{{$i}}" value="{{$f.Path}}"> | |||||
| {{$f.DisplayLabel}}<br><span class="mono">{{$f.Path}}</span> | |||||
| </td> | |||||
| <td><textarea name="field_value_{{$i}}">{{$f.Value}}</textarea></td> | |||||
| <td class="mono">{{$f.SampleValue}}</td> | |||||
| </tr> | |||||
| {{else}} | |||||
| <tr><td colspan="3">No enabled text fields found for this template.</td></tr> | |||||
| {{end}} | |||||
| </tbody> | |||||
| </table> | |||||
| <button type="submit">Start Build</button> | |||||
| </form> | |||||
| {{end}} | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||
| @@ -0,0 +1,22 @@ | |||||
| {{define "home"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>QC Text Builder</h1> | |||||
| <p>Minimal admin UI for AI template workflow.</p> | |||||
| <table> | |||||
| <tr><th>Available templates</th><td>{{.TemplateCount}}</td></tr> | |||||
| </table> | |||||
| <p> | |||||
| <a href="/templates">Open Templates</a> | |||||
| </p> | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||
| @@ -0,0 +1,24 @@ | |||||
| {{define "settings"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>Settings</h1> | |||||
| <p>Read-only summary for milestone 4.</p> | |||||
| <table> | |||||
| <tr><th>QC Base URL</th><td class="mono">{{.QCBaseURL}}</td></tr> | |||||
| <tr><th>Bearer token configured</th><td>{{if .TokenConfigured}}yes{{else}}no{{end}}</td></tr> | |||||
| <tr><th>Poll interval (seconds)</th><td>{{.PollIntervalSeconds}}</td></tr> | |||||
| <tr><th>Poll timeout (seconds)</th><td>{{.PollTimeoutSeconds}}</td></tr> | |||||
| <tr><th>Poll max concurrent</th><td>{{.PollMaxConcurrent}}</td></tr> | |||||
| <tr><th>Language output mode</th><td>{{.LanguageOutputMode}}</td></tr> | |||||
| </table> | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||
| @@ -0,0 +1,83 @@ | |||||
| {{define "template_detail"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>Template Detail</h1> | |||||
| <table> | |||||
| <tr><th>ID</th><td>{{.Detail.Template.ID}}</td></tr> | |||||
| <tr><th>Name</th><td>{{.Detail.Template.Name}}</td></tr> | |||||
| <tr><th>Type</th><td>{{.Detail.Template.Type}}</td></tr> | |||||
| <tr><th>Manifest status</th><td>{{.Detail.Template.ManifestStatus}}</td></tr> | |||||
| <tr> | |||||
| <th>Thumbnail</th> | |||||
| <td> | |||||
| {{if .Detail.Template.ThumbnailURL}} | |||||
| <span class="thumb-hover"> | |||||
| <a href="{{.Detail.Template.ThumbnailURL}}" target="_blank" rel="noopener">open thumbnail</a> | |||||
| <span class="thumb-preview"><img src="{{.Detail.Template.ThumbnailURL}}" alt="Template thumbnail for {{.Detail.Template.Name}}" loading="lazy"></span> | |||||
| </span> | |||||
| {{else}}none{{end}} | |||||
| </td> | |||||
| </tr> | |||||
| <tr><th>Active manifest</th><td>{{if .Detail.Manifest}}{{.Detail.Manifest.ID}}{{else}}none{{end}}</td></tr> | |||||
| </table> | |||||
| <form method="post" action="/templates/{{.Detail.Template.ID}}/onboard"> | |||||
| <button type="submit">Run Onboarding Discovery</button> | |||||
| </form> | |||||
| {{if .Detail.Manifest}} | |||||
| <h2>Manifest</h2> | |||||
| <p class="mono">{{prettyJSON .Detail.Manifest.FlattenedManifestJSON}}</p> | |||||
| <h2>Fields</h2> | |||||
| <form method="post" action="/templates/{{.Detail.Template.ID}}/fields"> | |||||
| <input type="hidden" name="manifest_id" value="{{.Detail.Manifest.ID}}"> | |||||
| <input type="hidden" name="field_count" value="{{len .Fields}}"> | |||||
| <table> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>Path</th> | |||||
| <th>Kind</th> | |||||
| <th>Enabled</th> | |||||
| <th>Required</th> | |||||
| <th>Label</th> | |||||
| <th>Order</th> | |||||
| <th>Notes</th> | |||||
| <th>Sample</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {{range $i, $f := .Fields}} | |||||
| <tr> | |||||
| <td> | |||||
| <input type="hidden" name="field_path_{{$i}}" value="{{$f.Path}}"> | |||||
| <span class="mono">{{$f.Path}}</span> | |||||
| </td> | |||||
| <td>{{$f.FieldKind}}</td> | |||||
| <td><input type="checkbox" name="field_enabled_{{$i}}" {{if $f.IsEnabled}}checked{{end}}></td> | |||||
| <td><input type="checkbox" name="field_required_{{$i}}" {{if $f.IsRequiredByUs}}checked{{end}}></td> | |||||
| <td><input type="text" name="field_label_{{$i}}" value="{{$f.DisplayLabel}}"></td> | |||||
| <td><input type="number" name="field_order_{{$i}}" value="{{$f.DisplayOrder}}"></td> | |||||
| <td><input type="text" name="field_notes_{{$i}}" value="{{$f.Notes}}"></td> | |||||
| <td class="mono">{{$f.SampleValue}}</td> | |||||
| </tr> | |||||
| {{end}} | |||||
| </tbody> | |||||
| </table> | |||||
| <button type="submit">Save Field Settings</button> | |||||
| </form> | |||||
| {{end}} | |||||
| <h2>Raw Template JSON</h2> | |||||
| <p class="mono">{{prettyJSON .Detail.Template.RawJSON}}</p> | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||
| @@ -0,0 +1,54 @@ | |||||
| {{define "templates"}} | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <title>{{.Title}}</title> | |||||
| {{template "head" .}} | |||||
| </head> | |||||
| <body> | |||||
| {{template "nav" .}} | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||||
| <h1>Templates</h1> | |||||
| <form method="post" action="/templates/sync"> | |||||
| <button type="submit">Sync AI Templates</button> | |||||
| </form> | |||||
| <table> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>ID</th> | |||||
| <th>Name</th> | |||||
| <th>Locale</th> | |||||
| <th>AI</th> | |||||
| <th>Manifest Status</th> | |||||
| <th>Onboarded</th> | |||||
| <th>Preview</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {{range .Templates}} | |||||
| <tr> | |||||
| <td>{{.ID}}</td> | |||||
| <td><a href="/templates/{{.ID}}">{{.Name}}</a></td> | |||||
| <td>{{.Locale}}</td> | |||||
| <td>{{if .IsAITemplate}}yes{{else}}no{{end}}</td> | |||||
| <td>{{.ManifestStatus}}</td> | |||||
| <td>{{if .IsOnboarded}}yes{{else}}no{{end}}</td> | |||||
| <td> | |||||
| {{if .TemplatePreviewURL}}<a href="{{.TemplatePreviewURL}}" target="_blank" rel="noopener">open</a>{{end}} | |||||
| {{if .ThumbnailURL}} | |||||
| <span class="thumb-hover"> | |||||
| <a href="{{.ThumbnailURL}}" target="_blank" rel="noopener">thumbnail</a> | |||||
| <span class="thumb-preview"><img src="{{.ThumbnailURL}}" alt="Template thumbnail for {{.Name}}" loading="lazy"></span> | |||||
| </span> | |||||
| {{end}} | |||||
| </td> | |||||
| </tr> | |||||
| {{else}} | |||||
| <tr><td colspan="7">No templates available. Run sync.</td></tr> | |||||
| {{end}} | |||||
| </tbody> | |||||
| </table> | |||||
| </body> | |||||
| </html> | |||||
| {{end}} | |||||