Bladeren bron

Initial QC Text Builder MVP scaffold

master
Jan Svabenik 1 maand geleden
commit
5bcebf63e4
48 gewijzigde bestanden met toevoegingen van 4543 en 0 verwijderingen
  1. +6
    -0
      .env.local
  2. +46
    -0
      README.md
  3. +36
    -0
      build-local.ps1
  4. +27
    -0
      cmd/qctextbuilder/main.go
  5. +1055
    -0
      concept.json
  6. BIN
      dist/qctextbuilder.exe
  7. +5
    -0
      go.mod
  8. +2
    -0
      go.sum
  9. +103
    -0
      internal/app/app.go
  10. +103
    -0
      internal/buildsvc/global_data.go
  11. +132
    -0
      internal/buildsvc/global_data_test.go
  12. +329
    -0
      internal/buildsvc/service.go
  13. +52
    -0
      internal/config/config.go
  14. +21
    -0
      internal/crypto/crypto.go
  15. +79
    -0
      internal/domain/models.go
  16. +204
    -0
      internal/httpserver/handlers/handlers.go
  17. +474
    -0
      internal/httpserver/handlers/ui.go
  18. +21
    -0
      internal/httpserver/middleware/logger.go
  19. +41
    -0
      internal/httpserver/server.go
  20. +3
    -0
      internal/httpserver/views/doc.go
  21. +40
    -0
      internal/httpserver/views/renderer.go
  22. +10
    -0
      internal/logging/logger.go
  23. +44
    -0
      internal/mapping/service.go
  24. +262
    -0
      internal/onboarding/service.go
  25. +95
    -0
      internal/onboarding/service_test.go
  26. +84
    -0
      internal/polling/service.go
  27. +195
    -0
      internal/qcclient/client.go
  28. +22
    -0
      internal/qcclient/errors.go
  29. +140
    -0
      internal/qcclient/types.go
  30. +79
    -0
      internal/qcclient/types_test.go
  31. +34
    -0
      internal/store/interfaces.go
  32. +237
    -0
      internal/store/memory/store.go
  33. +3
    -0
      internal/store/postgres/store.go
  34. +3
    -0
      internal/store/sqlite/store.go
  35. +117
    -0
      internal/templatesvc/service.go
  36. +29
    -0
      internal/validation/validation.go
  37. +0
    -0
      migrations/.keep
  38. +25
    -0
      run-local.ps1
  39. +0
    -0
      test/integration/.keep
  40. +0
    -0
      web/static/.keep
  41. +0
    -0
      web/templates/.keep
  42. +49
    -0
      web/templates/_shared.gohtml
  43. +60
    -0
      web/templates/build_detail.gohtml
  44. +93
    -0
      web/templates/build_new.gohtml
  45. +22
    -0
      web/templates/home.gohtml
  46. +24
    -0
      web/templates/settings.gohtml
  47. +83
    -0
      web/templates/template_detail.gohtml
  48. +54
    -0
      web/templates/templates.gohtml

+ 6
- 0
.env.local Bestand weergeven

@@ -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

+ 46
- 0
README.md Bestand weergeven

@@ -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.

+ 36
- 0
build-local.ps1 Bestand weergeven

@@ -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

+ 27
- 0
cmd/qctextbuilder/main.go Bestand weergeven

@@ -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)
}
}

+ 1055
- 0
concept.json
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


BIN
dist/qctextbuilder.exe Bestand weergeven


+ 5
- 0
go.mod Bestand weergeven

@@ -0,0 +1,5 @@
module qctextbuilder

go 1.24

require github.com/go-chi/chi/v5 v5.2.3

+ 2
- 0
go.sum Bestand weergeven

@@ -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=

+ 103
- 0
internal/app/app.go Bestand weergeven

@@ -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
}
}

+ 103
- 0
internal/buildsvc/global_data.go Bestand weergeven

@@ -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)
}

+ 132
- 0
internal/buildsvc/global_data_test.go Bestand weergeven

@@ -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")
}
}

+ 329
- 0
internal/buildsvc/service.go Bestand weergeven

@@ -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)
}

+ 52
- 0
internal/config/config.go Bestand weergeven

@@ -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
}

+ 21
- 0
internal/crypto/crypto.go Bestand weergeven

@@ -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
}

+ 79
- 0
internal/domain/models.go Bestand weergeven

@@ -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"`
}

+ 204
- 0
internal/httpserver/handlers/handlers.go Bestand weergeven

@@ -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)
}

+ 474
- 0
internal/httpserver/handlers/ui.go Bestand weergeven

@@ -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
}

+ 21
- 0
internal/httpserver/middleware/logger.go Bestand weergeven

@@ -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(),
)
})
}
}

+ 41
- 0
internal/httpserver/server.go Bestand weergeven

@@ -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)
}

+ 3
- 0
internal/httpserver/views/doc.go Bestand weergeven

@@ -0,0 +1,3 @@
package views

// Package views contains server-rendered templates for the admin UI.

+ 40
- 0
internal/httpserver/views/renderer.go Bestand weergeven

@@ -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()
}

+ 10
- 0
internal/logging/logger.go Bestand weergeven

@@ -0,0 +1,10 @@
package logging

import (
"log/slog"
"os"
)

func New() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stdout, nil))
}

+ 44
- 0
internal/mapping/service.go Bestand weergeven

@@ -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
}

+ 262
- 0
internal/onboarding/service.go Bestand weergeven

@@ -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
}

+ 95
- 0
internal/onboarding/service_test.go Bestand weergeven

@@ -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)
}
}

+ 84
- 0
internal/polling/service.go Bestand weergeven

@@ -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()
}

+ 195
- 0
internal/qcclient/client.go Bestand weergeven

@@ -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
}
}

+ 22
- 0
internal/qcclient/errors.go Bestand weergeven

@@ -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"
}

+ 140
- 0
internal/qcclient/types.go Bestand weergeven

@@ -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"`
}

+ 79
- 0
internal/qcclient/types_test.go Bestand weergeven

@@ -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")
}
}

+ 34
- 0
internal/store/interfaces.go Bestand weergeven

@@ -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{}

+ 237
- 0
internal/store/memory/store.go Bestand weergeven

@@ -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 &copy, 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 &copy, 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 &copy, 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)
}

+ 3
- 0
internal/store/postgres/store.go Bestand weergeven

@@ -0,0 +1,3 @@
package postgres

// TODO(milestone-2): postgres-backed store implementation.

+ 3
- 0
internal/store/sqlite/store.go Bestand weergeven

@@ -0,0 +1,3 @@
package sqlite

// TODO(milestone-2): sqlite-backed store implementation for local development.

+ 117
- 0
internal/templatesvc/service.go Bestand weergeven

@@ -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")
}

+ 29
- 0
internal/validation/validation.go Bestand weergeven

@@ -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
migrations/.keep Bestand weergeven


+ 25
- 0
run-local.ps1 Bestand weergeven

@@ -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
test/integration/.keep Bestand weergeven


+ 0
- 0
web/static/.keep Bestand weergeven


+ 0
- 0
web/templates/.keep Bestand weergeven


+ 49
- 0
web/templates/_shared.gohtml Bestand weergeven

@@ -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}}

+ 60
- 0
web/templates/build_detail.gohtml Bestand weergeven

@@ -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}}

+ 93
- 0
web/templates/build_new.gohtml Bestand weergeven

@@ -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}}

+ 22
- 0
web/templates/home.gohtml Bestand weergeven

@@ -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}}

+ 24
- 0
web/templates/settings.gohtml Bestand weergeven

@@ -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}}

+ 83
- 0
web/templates/template_detail.gohtml Bestand weergeven

@@ -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}}

+ 54
- 0
web/templates/templates.gohtml Bestand weergeven

@@ -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}}

Laden…
Annuleren
Opslaan