Bläddra i källkod

Add SQLite persistence and draft review flow

master
Jan Svabenik 1 månad sedan
förälder
incheckning
10fcc64937
16 ändrade filer med 1550 tillägg och 50 borttagningar
  1. +17
    -2
      README.md
  2. Binär
      data/qctextbuilder.db
  3. Binär
      dist/qctextbuilder.exe
  4. +14
    -1
      go.mod
  5. +51
    -0
      go.sum
  6. +54
    -7
      internal/app/app.go
  7. +2
    -2
      internal/config/config.go
  8. +14
    -0
      internal/domain/models.go
  9. +149
    -0
      internal/draftsvc/service.go
  10. +91
    -1
      internal/httpserver/handlers/handlers.go
  11. +194
    -24
      internal/httpserver/handlers/ui.go
  12. +14
    -1
      internal/store/interfaces.go
  13. +85
    -11
      internal/store/memory/store.go
  14. +104
    -0
      internal/store/sqlite/migrations/001_init.sql
  15. +734
    -1
      internal/store/sqlite/store.go
  16. +27
    -0
      web/templates/build_new.gohtml

+ 17
- 2
README.md Visa fil

@@ -6,14 +6,17 @@ Milestone 2 status:
- 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`
- SQLite persistence (default) for settings, templates, manifests, fields, site builds, and build drafts
- build polling (`POST /api/site-builds/{id}/poll`) and background polling supervisor
- draft intake/review flow (`draft -> reviewed -> submitted`) before final build
- strict MVP scope: no ACP login flow, no DCM/EFL, no image payload handling

## Run

1. Set env vars:
- `HTTP_ADDR=:8080`
- `DB_DRIVER=sqlite` (default)
- `DB_URL=data/qctextbuilder.db` (default, local file)
- `QC_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1`
- `QC_TOKEN=<your bearer token>`
2. Start:
@@ -27,11 +30,22 @@ Milestone 2 status:
- `GET /api/templates/{id}`
- `POST /api/templates/{id}/onboard`
- `PUT /api/templates/{id}/fields`
- `POST /api/drafts/intake` (external prefilled draft intake)
- `GET /api/drafts`
- `GET /api/drafts/{id}`
- `PUT /api/drafts/{id}`
- `POST /api/site-builds`
- `GET /api/site-builds/{id}`
- `POST /api/site-builds/{id}/poll`
- `POST /api/site-builds/{id}/fetch-editor-url`

Draft payload (`POST /api/drafts/intake` / `PUT /api/drafts/{id}`) supports:
- `templateId`, optional `manifestId`
- `source`, `requestName`
- `globalData` (same documented QC fields as build flow)
- `fieldValues` keyed by manifest path (`section.keyName`)
- `status` (`draft|reviewed|submitted`), `notes`

Build request payload (`POST /api/site-builds`) expects:
- `templateId` (AI template only, onboarded/reviewed)
- `requestName`
@@ -43,4 +57,5 @@ Documented `globalData` scope supported by UI/API mapping:
- `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.
UI note:
- `/builds/new` now supports loading an existing draft, reviewing/editing values, saving draft, and only then starting the build.

Binär
data/qctextbuilder.db Visa fil


Binär
dist/qctextbuilder.exe Visa fil


+ 14
- 1
go.mod Visa fil

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

go 1.24
go 1.25.0

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

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.36.3
)

+ 51
- 0
go.sum Visa fil

@@ -1,2 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss=
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 54
- 7
internal/app/app.go Visa fil

@@ -5,12 +5,15 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/go-chi/chi/v5"

"qctextbuilder/internal/buildsvc"
"qctextbuilder/internal/config"
"qctextbuilder/internal/domain"
"qctextbuilder/internal/draftsvc"
"qctextbuilder/internal/httpserver"
"qctextbuilder/internal/httpserver/handlers"
"qctextbuilder/internal/httpserver/views"
@@ -19,7 +22,9 @@ import (
"qctextbuilder/internal/onboarding"
"qctextbuilder/internal/polling"
"qctextbuilder/internal/qcclient"
"qctextbuilder/internal/store"
"qctextbuilder/internal/store/memory"
"qctextbuilder/internal/store/sqlite"
"qctextbuilder/internal/templatesvc"
)

@@ -30,21 +35,58 @@ type App struct {

func New(cfg config.Config) (*App, error) {
logger := logging.New()
memStore := memory.New()

var (
templateStore store.TemplateStore
manifestStore store.ManifestStore
buildStore store.BuildStore
draftStore store.DraftStore
settingsStore store.SettingsStore
)

driver := strings.ToLower(strings.TrimSpace(cfg.DBDriver))
switch driver {
case "", "sqlite":
sqliteStore, err := sqlite.New(cfg.DBURL)
if err != nil {
return nil, fmt.Errorf("init sqlite store: %w", err)
}
templateStore = sqliteStore
manifestStore = sqliteStore
buildStore = sqliteStore
draftStore = sqliteStore
settingsStore = sqliteStore
default:
memStore := memory.New()
templateStore = memStore
manifestStore = memStore
buildStore = memStore
draftStore = memStore
settingsStore = memStore
}

qc := qcclient.New(cfg.QCBaseURL, cfg.QCToken, 15*time.Second, logger)
templateSvc := templatesvc.New(qc, memStore, memStore)
onboardSvc := onboarding.New(qc, memStore, memStore)
templateSvc := templatesvc.New(qc, templateStore, manifestStore)
onboardSvc := onboarding.New(qc, templateStore, manifestStore)
draftSvc := draftsvc.New(draftStore, templateStore, manifestStore)
mappingSvc := mapping.New()
buildSvc := buildsvc.New(qc, 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)
buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second)
pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger)
api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc)

_ = settingsStore.UpsertSettings(context.Background(), domain.AppSettings{
QCBaseURL: cfg.QCBaseURL,
QCBearerTokenEncrypted: cfg.QCToken,
LanguageOutputMode: "EN",
JobPollIntervalSeconds: cfg.PollIntervalSeconds,
JobPollTimeoutSeconds: cfg.PollTimeoutSeconds,
})

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)
ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, cfg, renderer)

server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) {
r.Get("/", ui.Home)
@@ -55,6 +97,7 @@ func New(cfg config.Config) (*App, error) {
r.Post("/templates/{id}/onboard", ui.OnboardTemplate)
r.Post("/templates/{id}/fields", ui.UpdateTemplateFields)
r.Get("/builds/new", ui.BuildNew)
r.Post("/builds/drafts", ui.SaveDraft)
r.Post("/builds", ui.CreateBuild)
r.Get("/builds/{id}", ui.BuildDetail)
r.Post("/builds/{id}/poll", ui.PollBuild)
@@ -67,6 +110,10 @@ func New(cfg config.Config) (*App, error) {
r.Get("/templates/{id}", api.GetTemplateDetail)
r.Post("/templates/{id}/onboard", api.OnboardTemplate)
r.Put("/templates/{id}/fields", api.UpdateTemplateFields)
r.Post("/drafts/intake", api.IntakeDraft)
r.Get("/drafts", api.ListDrafts)
r.Get("/drafts/{id}", api.GetDraft)
r.Put("/drafts/{id}", api.UpdateDraft)
r.Post("/site-builds", api.StartBuild)
r.Get("/site-builds/{id}", api.GetBuild)
r.Post("/site-builds/{id}/poll", api.PollBuildOnce)


+ 2
- 2
internal/config/config.go Visa fil

@@ -20,8 +20,8 @@ type Config struct {
func Load() Config {
return Config{
HTTPAddr: getenv("HTTP_ADDR", ":8080"),
DBDriver: getenv("DB_DRIVER", "postgres"),
DBURL: os.Getenv("DB_URL"),
DBDriver: getenv("DB_DRIVER", "sqlite"),
DBURL: getenv("DB_URL", "data/qctextbuilder.db"),
AppSecret: os.Getenv("APP_SECRET"),
QCBaseURL: getenv("QC_BASE_URL", "https://qc-api.yggdrasil.dev-mono.net/api/v1"),
QCToken: os.Getenv("QC_TOKEN"),


+ 14
- 0
internal/domain/models.go Visa fil

@@ -70,6 +70,20 @@ type SiteBuild struct {
FinishedAt *time.Time `json:"finishedAt,omitempty"`
}

type BuildDraft struct {
ID string `json:"id"`
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalDataJSON json.RawMessage `json:"globalDataJson"`
FieldValuesJSON json.RawMessage `json:"fieldValuesJson"`
Status string `json:"status"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

type AppSettings struct {
QCBaseURL string `json:"qcBaseUrl"`
QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"`


+ 149
- 0
internal/draftsvc/service.go Visa fil

@@ -0,0 +1,149 @@
package draftsvc

import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"

"qctextbuilder/internal/domain"
"qctextbuilder/internal/store"
)

type UpsertDraftRequest struct {
DraftID string `json:"draftId,omitempty"`
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId,omitempty"`
Source string `json:"source,omitempty"`
RequestName string `json:"requestName,omitempty"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}

type Service struct {
drafts store.DraftStore
templates store.TemplateStore
manifests store.ManifestStore
}

func New(draftStore store.DraftStore, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service {
return &Service{
drafts: draftStore,
templates: templateStore,
manifests: manifestStore,
}
}

func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domain.BuildDraft, error) {
templateID := req.TemplateID
if strings.TrimSpace(req.DraftID) != "" {
existing, err := s.drafts.GetDraftByID(ctx, strings.TrimSpace(req.DraftID))
if err != nil {
return nil, fmt.Errorf("get draft: %w", err)
}
if templateID == 0 {
templateID = existing.TemplateID
}
}
if templateID <= 0 {
return nil, errors.New("templateId is required")
}

template, err := s.templates.GetTemplateByID(ctx, templateID)
if err != nil {
return nil, fmt.Errorf("get template: %w", err)
}
if !template.IsAITemplate {
return nil, errors.New("only ai templates are allowed")
}

manifestID := strings.TrimSpace(req.ManifestID)
if manifestID == "" {
manifest, err := s.manifests.GetActiveManifestByTemplateID(ctx, templateID)
if err != nil {
return nil, fmt.Errorf("get active manifest: %w", err)
}
manifestID = manifest.ID
}

globalDataJSON, err := json.Marshal(req.GlobalData)
if err != nil {
return nil, errors.New("globalData is invalid JSON")
}
fieldValuesJSON, err := json.Marshal(req.FieldValues)
if err != nil {
return nil, errors.New("fieldValues is invalid JSON")
}

now := time.Now().UTC()
source := defaultString(req.Source, "ui")
status := normalizeDraftStatus(req.Status)
if status == "" {
status = "draft"
}

draft := domain.BuildDraft{
ID: strings.TrimSpace(req.DraftID),
TemplateID: templateID,
ManifestID: manifestID,
Source: source,
RequestName: strings.TrimSpace(req.RequestName),
GlobalDataJSON: globalDataJSON,
FieldValuesJSON: fieldValuesJSON,
Status: status,
Notes: strings.TrimSpace(req.Notes),
UpdatedAt: now,
}
if draft.ID == "" {
draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10)
draft.CreatedAt = now
if err := s.drafts.CreateDraft(ctx, draft); err != nil {
return nil, fmt.Errorf("create draft: %w", err)
}
} else {
existing, err := s.drafts.GetDraftByID(ctx, draft.ID)
if err != nil {
return nil, fmt.Errorf("get draft: %w", err)
}
draft.CreatedAt = existing.CreatedAt
if err := s.drafts.UpdateDraft(ctx, draft); err != nil {
return nil, fmt.Errorf("update draft: %w", err)
}
}

return s.drafts.GetDraftByID(ctx, draft.ID)
}

func (s *Service) GetDraft(ctx context.Context, draftID string) (*domain.BuildDraft, error) {
return s.drafts.GetDraftByID(ctx, strings.TrimSpace(draftID))
}

func (s *Service) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
if limit <= 0 {
limit = 50
}
return s.drafts.ListDrafts(ctx, limit)
}

func normalizeDraftStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "":
return ""
case "draft", "reviewed", "submitted":
return strings.ToLower(strings.TrimSpace(status))
default:
return "draft"
}
}

func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}

+ 91
- 1
internal/httpserver/handlers/handlers.go Visa fil

@@ -4,10 +4,12 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"

"github.com/go-chi/chi/v5"

"qctextbuilder/internal/buildsvc"
"qctextbuilder/internal/draftsvc"
"qctextbuilder/internal/onboarding"
"qctextbuilder/internal/templatesvc"
)
@@ -15,13 +17,15 @@ import (
type API struct {
templateSvc *templatesvc.Service
onboardSvc *onboarding.Service
draftSvc *draftsvc.Service
buildSvc buildsvc.Service
}

func NewAPI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, buildSvc buildsvc.Service) *API {
func NewAPI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service) *API {
return &API{
templateSvc: templateSvc,
onboardSvc: onboardSvc,
draftSvc: draftSvc,
buildSvc: buildSvc,
}
}
@@ -157,6 +161,85 @@ func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, result)
}

type upsertDraftRequest struct {
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
Status string `json:"status"`
Notes string `json:"notes"`
}

func (a *API) IntakeDraft(w http.ResponseWriter, r *http.Request) {
var req upsertDraftRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
return
}
draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
TemplateID: req.TemplateID,
ManifestID: req.ManifestID,
Source: defaultStr(req.Source, "intake-api"),
RequestName: req.RequestName,
GlobalData: req.GlobalData,
FieldValues: req.FieldValues,
Status: defaultStr(req.Status, "draft"),
Notes: req.Notes,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, draft)
}

func (a *API) ListDrafts(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
drafts, err := a.draftSvc.ListDrafts(r.Context(), limit)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"count": len(drafts), "drafts": drafts})
}

func (a *API) GetDraft(w http.ResponseWriter, r *http.Request) {
draftID := strings.TrimSpace(chi.URLParam(r, "id"))
draft, err := a.draftSvc.GetDraft(r.Context(), draftID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, draft)
}

func (a *API) UpdateDraft(w http.ResponseWriter, r *http.Request) {
draftID := strings.TrimSpace(chi.URLParam(r, "id"))
var req upsertDraftRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
return
}
draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: draftID,
TemplateID: req.TemplateID,
ManifestID: req.ManifestID,
Source: req.Source,
RequestName: req.RequestName,
GlobalData: req.GlobalData,
FieldValues: req.FieldValues,
Status: req.Status,
Notes: req.Notes,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, draft)
}

func (a *API) GetBuild(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
build, err := a.buildSvc.GetBuild(r.Context(), buildID)
@@ -202,3 +285,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

func defaultStr(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return strings.TrimSpace(v)
}

+ 194
- 24
internal/httpserver/handlers/ui.go Visa fil

@@ -13,6 +13,7 @@ import (
"qctextbuilder/internal/buildsvc"
"qctextbuilder/internal/config"
"qctextbuilder/internal/domain"
"qctextbuilder/internal/draftsvc"
"qctextbuilder/internal/onboarding"
"qctextbuilder/internal/templatesvc"
)
@@ -20,6 +21,7 @@ import (
type UI struct {
templateSvc *templatesvc.Service
onboardSvc *onboarding.Service
draftSvc *draftsvc.Service
buildSvc buildsvc.Service
cfg config.Config
render htmlRenderer
@@ -83,6 +85,8 @@ type buildFieldView struct {
type buildNewPageData struct {
pageData
Templates []domain.Template
Drafts []domain.BuildDraft
SelectedDraftID string
SelectedTemplateID int64
SelectedManifestID string
EnabledFields []buildFieldView
@@ -90,6 +94,10 @@ type buildNewPageData struct {
}

type buildFormInput struct {
DraftID string
DraftSource string
DraftStatus string
DraftNotes string
RequestName string
CompanyName string
BusinessType string
@@ -119,8 +127,8 @@ type buildDetailPageData struct {
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 NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI {
return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, cfg: cfg, render: render}
}

func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
@@ -245,7 +253,22 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {

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)
selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
form := buildFormInput{
DraftID: selectedDraftID,
DraftSource: "ui",
DraftStatus: "draft",
}
fieldValues := map[string]string{}
if selectedDraftID != "" {
draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
if err == nil {
selectedTemplateID = draft.TemplateID
form = buildFormInputFromDraft(draft)
fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
}
}
data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -261,6 +284,25 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {

form := buildFormInputFromRequest(r)
fieldValues := parseBuildFieldValues(r)
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,
})

templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
if err != nil || templateID <= 0 {
@@ -271,25 +313,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
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,
}),
GlobalData: globalData,
FieldValues: fieldValues,
})
if err != nil {
@@ -297,7 +321,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
Title: "New Build",
Err: err.Error(),
Current: "/builds/new",
}, templateID, form, fieldValues)
}, form.DraftID, templateID, form, fieldValues)
if loadErr != nil {
http.Error(w, loadErr.Error(), http.StatusBadRequest)
return
@@ -305,9 +329,82 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
u.render.Render(w, "build_new", data)
return
}

if form.DraftID != "" {
_, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: form.DraftID,
TemplateID: templateID,
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
Status: "submitted",
Notes: form.DraftNotes,
})
}

http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
}

func (u *UI) SaveDraft(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
}
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,
})
draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: form.DraftID,
TemplateID: templateID,
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
Status: defaultDraftStatus(form.DraftStatus),
Notes: form.DraftNotes,
})
if err != nil {
data, loadErr := u.loadBuildNewPageData(r, pageData{
Title: "New Build",
Err: err.Error(),
Current: "/builds/new",
}, form.DraftID, 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/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), 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)
@@ -382,15 +479,21 @@ 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) {
func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) {
templates, err := u.templateSvc.ListTemplates(r.Context())
if err != nil {
return buildNewPageData{}, err
}
drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
if err != nil {
return buildNewPageData{}, err
}

data := buildNewPageData{
pageData: page,
Templates: templates,
Drafts: drafts,
SelectedDraftID: selectedDraftID,
SelectedTemplateID: selectedTemplateID,
Form: form,
}
@@ -419,6 +522,10 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedTempla

func buildFormInputFromRequest(r *http.Request) buildFormInput {
return buildFormInput{
DraftID: strings.TrimSpace(r.FormValue("draft_id")),
DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
RequestName: strings.TrimSpace(r.FormValue("request_name")),
CompanyName: strings.TrimSpace(r.FormValue("company_name")),
BusinessType: strings.TrimSpace(r.FormValue("business_type")),
@@ -440,6 +547,18 @@ func buildFormInputFromRequest(r *http.Request) buildFormInput {
}
}

func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
form := buildFormInput{
DraftID: draft.ID,
DraftSource: draft.Source,
DraftStatus: draft.Status,
DraftNotes: draft.Notes,
RequestName: draft.RequestName,
}
mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
return form
}

func parseBuildFieldValues(r *http.Request) map[string]string {
fieldValues := map[string]string{}
count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
@@ -472,3 +591,54 @@ func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
}
return data, nil
}

func parseFieldValuesJSON(raw []byte) map[string]string {
out := map[string]string{}
if len(raw) == 0 {
return out
}
_ = json.Unmarshal(raw, &out)
return out
}

func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
if form == nil || len(raw) == 0 {
return
}
var global map[string]any
if err := json.Unmarshal(raw, &global); err != nil {
return
}
form.CompanyName = getString(global["companyName"])
form.BusinessType = getString(global["businessType"])
form.Username = getString(global["username"])
form.Email = getString(global["email"])
form.Phone = getString(global["phone"])
form.OrgNumber = getString(global["orgNumber"])
form.StartDate = getString(global["startDate"])
form.Mission = getString(global["mission"])
form.DescriptionShort = getString(global["descriptionShort"])
form.DescriptionLong = getString(global["descriptionLong"])
form.SiteLanguage = getString(global["siteLanguage"])
address, _ := global["address"].(map[string]any)
form.AddressLine1 = getString(address["line1"])
form.AddressLine2 = getString(address["line2"])
form.AddressCity = getString(address["city"])
form.AddressRegion = getString(address["region"])
form.AddressZIP = getString(address["zip"])
form.AddressCountry = getString(address["country"])
}

func getString(v any) string {
s, _ := v.(string)
return strings.TrimSpace(s)
}

func defaultDraftStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "reviewed", "submitted":
return strings.ToLower(strings.TrimSpace(status))
default:
return "draft"
}
}

+ 14
- 1
internal/store/interfaces.go Visa fil

@@ -3,11 +3,14 @@ package store
import (
"context"
"encoding/json"
"errors"
"time"

"qctextbuilder/internal/domain"
)

var ErrNotFound = errors.New("not found")

type TemplateStore interface {
UpsertTemplates(ctx context.Context, templates []domain.Template) error
GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error)
@@ -31,4 +34,14 @@ type BuildStore interface {
UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error
}

type SettingsStore interface{}
type SettingsStore interface {
UpsertSettings(ctx context.Context, settings domain.AppSettings) error
GetSettings(ctx context.Context) (*domain.AppSettings, error)
}

type DraftStore interface {
CreateDraft(ctx context.Context, draft domain.BuildDraft) error
UpdateDraft(ctx context.Context, draft domain.BuildDraft) error
GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error)
ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error)
}

+ 85
- 11
internal/store/memory/store.go Visa fil

@@ -4,20 +4,23 @@ import (
"context"
"encoding/json"
"errors"
"sort"
"sync"
"time"

"qctextbuilder/internal/domain"
"qctextbuilder/internal/store"
)

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
drafts map[string]domain.BuildDraft
settings domain.AppSettings
hasSettings bool
}

func New() *Store {
@@ -26,6 +29,7 @@ func New() *Store {
manifests: make(map[int64]domain.TemplateManifest),
manifestField: make(map[string][]domain.TemplateField),
builds: make(map[string]domain.SiteBuild),
drafts: make(map[string]domain.BuildDraft),
}
}

@@ -51,7 +55,7 @@ func (s *Store) GetTemplateByID(_ context.Context, id int64) (*domain.Template,

t, ok := s.templates[id]
if !ok {
return nil, ErrNotFound
return nil, store.ErrNotFound
}
copy := t
return &copy, nil
@@ -74,7 +78,7 @@ func (s *Store) SetTemplateManifestStatus(_ context.Context, templateID int64, s

t, ok := s.templates[templateID]
if !ok {
return ErrNotFound
return store.ErrNotFound
}
t.ManifestStatus = status
t.IsOnboarded = onboarded
@@ -97,7 +101,7 @@ func (s *Store) GetActiveManifestByTemplateID(_ context.Context, templateID int6

m, ok := s.manifests[templateID]
if !ok {
return nil, ErrNotFound
return nil, store.ErrNotFound
}
copy := m
return &copy, nil
@@ -109,7 +113,7 @@ func (s *Store) ListFieldsByManifestID(_ context.Context, manifestID string) ([]

fields, ok := s.manifestField[manifestID]
if !ok {
return nil, ErrNotFound
return nil, store.ErrNotFound
}
out := make([]domain.TemplateField, 0, len(fields))
out = append(out, fields...)
@@ -121,7 +125,7 @@ func (s *Store) UpdateFields(_ context.Context, manifestID string, fields []doma
defer s.mu.Unlock()

if _, ok := s.manifestField[manifestID]; !ok {
return ErrNotFound
return store.ErrNotFound
}
next := make([]domain.TemplateField, len(fields))
copy(next, fields)
@@ -146,7 +150,7 @@ func (s *Store) GetBuildByID(_ context.Context, id string) (*domain.SiteBuild, e

build, ok := s.builds[id]
if !ok {
return nil, ErrNotFound
return nil, store.ErrNotFound
}
copy := build
return &copy, nil
@@ -182,7 +186,7 @@ func (s *Store) MarkBuildSubmitted(_ context.Context, buildID string, jobID int6

build, ok := s.builds[buildID]
if !ok {
return ErrNotFound
return store.ErrNotFound
}
build.QCJobID = &jobID
build.QCStatus = status
@@ -198,7 +202,7 @@ func (s *Store) UpdateBuildFromJob(_ context.Context, buildID string, status str

build, ok := s.builds[buildID]
if !ok {
return ErrNotFound
return store.ErrNotFound
}
build.QCStatus = status
build.QCResultJSON = cloneRaw(qcResult)
@@ -219,7 +223,7 @@ func (s *Store) UpdateBuildEditorURL(_ context.Context, buildID string, editorUR

build, ok := s.builds[buildID]
if !ok {
return ErrNotFound
return store.ErrNotFound
}
build.QCEditorURL = editorURL
build.QCResultJSON = cloneRaw(qcResult)
@@ -235,3 +239,73 @@ func cloneRaw(raw json.RawMessage) json.RawMessage {
copy(out, raw)
return json.RawMessage(out)
}

func (s *Store) UpsertSettings(_ context.Context, settings domain.AppSettings) error {
s.mu.Lock()
defer s.mu.Unlock()
s.settings = settings
s.hasSettings = true
return nil
}

func (s *Store) GetSettings(_ context.Context) (*domain.AppSettings, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.hasSettings {
return nil, store.ErrNotFound
}
value := s.settings
return &value, nil
}

func (s *Store) CreateDraft(_ context.Context, draft domain.BuildDraft) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.drafts[draft.ID]; exists {
return errors.New("draft already exists")
}
s.drafts[draft.ID] = draft
return nil
}

func (s *Store) UpdateDraft(_ context.Context, draft domain.BuildDraft) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.drafts[draft.ID]; !exists {
return store.ErrNotFound
}
s.drafts[draft.ID] = draft
return nil
}

func (s *Store) GetDraftByID(_ context.Context, id string) (*domain.BuildDraft, error) {
s.mu.RLock()
defer s.mu.RUnlock()
draft, ok := s.drafts[id]
if !ok {
return nil, store.ErrNotFound
}
copy := draft
copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON)
copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON)
return &copy, nil
}

func (s *Store) ListDrafts(_ context.Context, limit int) ([]domain.BuildDraft, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.BuildDraft, 0, len(s.drafts))
for _, draft := range s.drafts {
copy := draft
copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON)
copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON)
out = append(out, copy)
}
sort.Slice(out, func(i, j int) bool {
return out[i].UpdatedAt.After(out[j].UpdatedAt)
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}

+ 104
- 0
internal/store/sqlite/migrations/001_init.sql Visa fil

@@ -0,0 +1,104 @@
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
qc_base_url TEXT NOT NULL DEFAULT '',
qc_bearer_token_encrypted TEXT NOT NULL DEFAULT '',
language_output_mode TEXT NOT NULL DEFAULT 'EN',
job_poll_interval_seconds INTEGER NOT NULL DEFAULT 5,
job_poll_timeout_seconds INTEGER NOT NULL DEFAULT 300,
updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS qc_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
locale TEXT NOT NULL DEFAULT '',
thumbnail_url TEXT NOT NULL DEFAULT '',
template_preview_url TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
palette_ready INTEGER NOT NULL DEFAULT 0,
raw_template_json BLOB,
is_ai_template INTEGER NOT NULL DEFAULT 0,
is_onboarded INTEGER NOT NULL DEFAULT 0,
manifest_status TEXT NOT NULL DEFAULT 'missing',
last_discovered_at TEXT,
updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS qc_template_manifests (
id TEXT PRIMARY KEY,
template_id INTEGER NOT NULL,
manifest_version INTEGER NOT NULL,
source TEXT NOT NULL,
language_used_discovery TEXT NOT NULL,
discovery_payload_json BLOB,
discovery_response_json BLOB,
flattened_manifest_json BLOB,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (template_id) REFERENCES qc_templates(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_manifests_template_active ON qc_template_manifests(template_id, is_active);

CREATE TABLE IF NOT EXISTS qc_template_fields (
id TEXT PRIMARY KEY,
template_id INTEGER NOT NULL,
manifest_id TEXT NOT NULL,
section TEXT NOT NULL,
key_name TEXT NOT NULL,
path TEXT NOT NULL,
field_kind TEXT NOT NULL,
sample_value TEXT NOT NULL DEFAULT '',
is_enabled INTEGER NOT NULL DEFAULT 1,
is_required_by_us INTEGER NOT NULL DEFAULT 0,
display_label TEXT NOT NULL DEFAULT '',
display_order INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
FOREIGN KEY (template_id) REFERENCES qc_templates(id) ON DELETE CASCADE,
FOREIGN KEY (manifest_id) REFERENCES qc_template_manifests(id) ON DELETE CASCADE,
UNIQUE(template_id, manifest_id, path)
);

CREATE INDEX IF NOT EXISTS idx_fields_manifest ON qc_template_fields(manifest_id);

CREATE TABLE IF NOT EXISTS site_builds (
id TEXT PRIMARY KEY,
template_id INTEGER NOT NULL,
manifest_id TEXT NOT NULL,
request_name TEXT NOT NULL,
global_data_json BLOB,
ai_data_json BLOB,
final_sites_payload_json BLOB,
qc_job_id INTEGER,
qc_site_id INTEGER,
qc_status TEXT NOT NULL,
qc_preview_url TEXT NOT NULL DEFAULT '',
qc_editor_url TEXT NOT NULL DEFAULT '',
qc_result_json BLOB,
qc_error_json BLOB,
started_at TEXT,
finished_at TEXT,
FOREIGN KEY (template_id) REFERENCES qc_templates(id) ON DELETE RESTRICT,
FOREIGN KEY (manifest_id) REFERENCES qc_template_manifests(id) ON DELETE RESTRICT
);

CREATE INDEX IF NOT EXISTS idx_builds_status ON site_builds(qc_status);

CREATE TABLE IF NOT EXISTS build_drafts (
id TEXT PRIMARY KEY,
template_id INTEGER NOT NULL,
manifest_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'ui',
request_name TEXT NOT NULL DEFAULT '',
global_data_json BLOB,
field_values_json BLOB,
status TEXT NOT NULL DEFAULT 'draft',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (template_id) REFERENCES qc_templates(id) ON DELETE RESTRICT
);

CREATE INDEX IF NOT EXISTS idx_drafts_updated_at ON build_drafts(updated_at DESC);

+ 734
- 1
internal/store/sqlite/store.go Visa fil

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

// TODO(milestone-2): sqlite-backed store implementation for local development.
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"

_ "modernc.org/sqlite"

"qctextbuilder/internal/domain"
"qctextbuilder/internal/store"
)

//go:embed migrations/*.sql
var migrationFS embed.FS

type Store struct {
db *sql.DB
}

func New(dbPath string) (*Store, error) {
path := strings.TrimSpace(dbPath)
if path == "" {
path = "data/qctextbuilder.db"
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}

db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
if err := runMigrations(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("run migrations: %w", err)
}
return &Store{db: db}, nil
}

func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}

func (s *Store) UpsertTemplates(ctx context.Context, templates []domain.Template) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer rollback(tx)

stmt := `
INSERT INTO qc_templates (
id, name, description, locale, thumbnail_url, template_preview_url, type,
palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT is_onboarded FROM qc_templates WHERE id = ?), ?),
COALESCE((SELECT manifest_status FROM qc_templates WHERE id = ?), ?),
COALESCE((SELECT last_discovered_at FROM qc_templates WHERE id = ?), ?),
?
)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
locale = excluded.locale,
thumbnail_url = excluded.thumbnail_url,
template_preview_url = excluded.template_preview_url,
type = excluded.type,
palette_ready = excluded.palette_ready,
raw_template_json = excluded.raw_template_json,
is_ai_template = excluded.is_ai_template,
updated_at = excluded.updated_at;
`
now := time.Now().UTC()
for _, t := range templates {
_, err := tx.ExecContext(ctx, stmt,
t.ID, t.Name, t.Description, t.Locale, t.ThumbnailURL, t.TemplatePreviewURL, t.Type,
boolToInt(t.PaletteReady), asRaw(t.RawJSON), boolToInt(t.IsAITemplate),
t.ID, boolToInt(t.IsOnboarded),
t.ID, defaultString(t.ManifestStatus, "missing"),
t.ID, asRFC3339Ptr(t.LastDiscoveredAt),
now.Format(time.RFC3339Nano),
)
if err != nil {
return fmt.Errorf("upsert template %d: %w", t.ID, err)
}
}
return tx.Commit()
}

func (s *Store) GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
FROM qc_templates
WHERE id = ?`, id)
t, err := scanTemplate(row.Scan)
if err != nil {
return nil, err
}
return t, nil
}

func (s *Store) ListTemplates(ctx context.Context) ([]domain.Template, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
FROM qc_templates`)
if err != nil {
return nil, err
}
defer rows.Close()

out := make([]domain.Template, 0)
for rows.Next() {
t, err := scanTemplate(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *t)
}
return out, rows.Err()
}

func (s *Store) SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error {
res, err := s.db.ExecContext(ctx, `
UPDATE qc_templates
SET manifest_status = ?, is_onboarded = ?, last_discovered_at = ?, updated_at = ?
WHERE id = ?`,
defaultString(status, "missing"),
boolToInt(onboarded),
time.Now().UTC().Format(time.RFC3339Nano),
time.Now().UTC().Format(time.RFC3339Nano),
templateID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrNotFound
}
return nil
}

func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer rollback(tx)

if _, err := tx.ExecContext(ctx, `UPDATE qc_template_manifests SET is_active = 0 WHERE template_id = ?`, manifest.TemplateID); err != nil {
return err
}

_, err = tx.ExecContext(ctx, `
INSERT INTO qc_template_manifests (
id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
manifest.ID, manifest.TemplateID, manifest.Version, manifest.Source, manifest.LanguageUsedDiscovery,
asRaw(manifest.DiscoveryPayloadJSON), asRaw(manifest.DiscoveryResponseJSON), asRaw(manifest.FlattenedManifestJSON),
boolToInt(manifest.IsActive), manifest.CreatedAt.UTC().Format(time.RFC3339Nano), manifest.UpdatedAt.UTC().Format(time.RFC3339Nano),
)
if err != nil {
return err
}

if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifest.ID); err != nil {
return err
}
for _, f := range fields {
_, err := tx.ExecContext(ctx, `
INSERT INTO qc_template_fields (
id, template_id, manifest_id, section, key_name, path, field_kind,
sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
)
if err != nil {
return err
}
}
return tx.Commit()
}

func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
FROM qc_template_manifests
WHERE template_id = ? AND is_active = 1
ORDER BY created_at DESC
LIMIT 1`, templateID)
manifest, err := scanManifest(row.Scan)
if err != nil {
return nil, err
}
return manifest, nil
}

func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, template_id, manifest_id, section, key_name, path, field_kind, sample_value,
is_enabled, is_required_by_us, display_label, display_order, notes
FROM qc_template_fields
WHERE manifest_id = ?
ORDER BY display_order ASC, id ASC`, manifestID)
if err != nil {
return nil, err
}
defer rows.Close()

fields := make([]domain.TemplateField, 0)
for rows.Next() {
var f domain.TemplateField
var isEnabled, isRequired int
if err := rows.Scan(
&f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
&isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes,
); err != nil {
return nil, err
}
f.IsEnabled = isEnabled == 1
f.IsRequiredByUs = isRequired == 1
fields = append(fields, f)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(fields) == 0 {
return nil, store.ErrNotFound
}
return fields, nil
}

func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer rollback(tx)

var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM qc_template_manifests WHERE id = ?`, manifestID).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return store.ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifestID); err != nil {
return err
}
for _, f := range fields {
_, err := tx.ExecContext(ctx, `
INSERT INTO qc_template_fields (
id, template_id, manifest_id, section, key_name, path, field_kind,
sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
)
if err != nil {
return err
}
}
return tx.Commit()
}

func (s *Store) CreateBuild(ctx context.Context, build domain.SiteBuild) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO site_builds (
id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
build.ID, build.TemplateID, build.ManifestID, build.RequestName, asRaw(build.GlobalDataJSON), asRaw(build.AIDataJSON), asRaw(build.FinalSitesPayload),
build.QCJobID, build.QCSiteID, build.QCStatus, build.QCPreviewURL, build.QCEditorURL, asRaw(build.QCResultJSON), asRaw(build.QCErrorJSON),
asRFC3339Ptr(build.StartedAt), asRFC3339Ptr(build.FinishedAt),
)
return err
}

func (s *Store) GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
FROM site_builds
WHERE id = ?`, id)
build, err := scanBuild(row.Scan)
if err != nil {
return nil, err
}
return build, nil
}

func (s *Store) ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) {
base := `
SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
FROM site_builds`

args := make([]any, 0)
parts := make([]string, 0)
if len(statuses) > 0 {
placeholders := make([]string, 0, len(statuses))
for _, status := range statuses {
placeholders = append(placeholders, "?")
args = append(args, status)
}
parts = append(parts, "qc_status IN ("+strings.Join(placeholders, ", ")+")")
}
query := base
if len(parts) > 0 {
query += " WHERE " + strings.Join(parts, " AND ")
}
query += " ORDER BY started_at ASC, id ASC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}

rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()

builds := make([]domain.SiteBuild, 0)
for rows.Next() {
build, err := scanBuild(rows.Scan)
if err != nil {
return nil, err
}
builds = append(builds, *build)
}
return builds, rows.Err()
}

func (s *Store) MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE site_builds
SET qc_job_id = ?, qc_status = ?, qc_result_json = ?, started_at = ?
WHERE id = ?`,
jobID, status, asRaw(qcResult), startedAt.UTC().Format(time.RFC3339Nano), buildID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrNotFound
}
return nil
}

func (s *Store) UpdateBuildFromJob(ctx context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE site_builds
SET qc_status = ?, qc_site_id = COALESCE(?, qc_site_id), qc_preview_url = ?, qc_result_json = ?, qc_error_json = ?, finished_at = ?
WHERE id = ?`,
status, siteID, previewURL, asRaw(qcResult), asRaw(qcError), asRFC3339Ptr(finishedAt), buildID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrNotFound
}
return nil
}

func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error {
res, err := s.db.ExecContext(ctx, `
UPDATE site_builds
SET qc_editor_url = ?, qc_result_json = ?
WHERE id = ?`,
editorURL, asRaw(qcResult), buildID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrNotFound
}
return nil
}

func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO app_settings (
id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, updated_at
) VALUES (1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
qc_base_url = excluded.qc_base_url,
qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted,
language_output_mode = excluded.language_output_mode,
job_poll_interval_seconds = excluded.job_poll_interval_seconds,
job_poll_timeout_seconds = excluded.job_poll_timeout_seconds,
updated_at = excluded.updated_at`,
settings.QCBaseURL,
settings.QCBearerTokenEncrypted,
defaultString(settings.LanguageOutputMode, "EN"),
settings.JobPollIntervalSeconds,
settings.JobPollTimeoutSeconds,
time.Now().UTC().Format(time.RFC3339Nano),
)
return err
}

func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
row := s.db.QueryRowContext(ctx, `
SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds
FROM app_settings
WHERE id = 1`)
var settings domain.AppSettings
if err := row.Scan(
&settings.QCBaseURL,
&settings.QCBearerTokenEncrypted,
&settings.LanguageOutputMode,
&settings.JobPollIntervalSeconds,
&settings.JobPollTimeoutSeconds,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
}
return nil, err
}
return &settings, nil
}

func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO build_drafts (
id, template_id, manifest_id, source, request_name, global_data_json,
field_values_json, status, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
draft.ID, draft.TemplateID, draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON),
asRaw(draft.FieldValuesJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
)
return err
}

func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error {
res, err := s.db.ExecContext(ctx, `
UPDATE build_drafts
SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?,
status = ?, notes = ?, updated_at = ?
WHERE id = ?`,
draft.TemplateID, draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON),
draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrNotFound
}
return nil
}

func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, status, notes, created_at, updated_at
FROM build_drafts
WHERE id = ?`, id)
return scanDraft(row.Scan)
}

func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
query := `
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, status, notes, created_at, updated_at
FROM build_drafts
ORDER BY updated_at DESC`
args := make([]any, 0, 1)
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()

out := make([]domain.BuildDraft, 0)
for rows.Next() {
draft, err := scanDraft(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *draft)
}
return out, rows.Err()
}

func runMigrations(db *sql.DB) error {
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return err
}

entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return err
}
files := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
files = append(files, e.Name())
}
sort.Strings(files)

for _, name := range files {
var exists int
if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
return err
}
if exists > 0 {
continue
}

raw, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(raw)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}

func scanTemplate(scan func(dest ...any) error) (*domain.Template, error) {
var t domain.Template
var paletteReady int
var isAITemplate int
var isOnboarded int
var lastDiscovered sql.NullString
var raw []byte
if err := scan(
&t.ID, &t.Name, &t.Description, &t.Locale, &t.ThumbnailURL, &t.TemplatePreviewURL, &t.Type,
&paletteReady, &raw, &isAITemplate, &isOnboarded, &t.ManifestStatus, &lastDiscovered,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
}
return nil, err
}
t.PaletteReady = paletteReady == 1
t.IsAITemplate = isAITemplate == 1
t.IsOnboarded = isOnboarded == 1
t.RawJSON = cloneBytes(raw)
if lastDiscovered.Valid {
if ts, err := time.Parse(time.RFC3339Nano, lastDiscovered.String); err == nil {
t.LastDiscoveredAt = &ts
}
}
return &t, nil
}

func scanManifest(scan func(dest ...any) error) (*domain.TemplateManifest, error) {
var m domain.TemplateManifest
var isActive int
var payloadRaw []byte
var responseRaw []byte
var flattenedRaw []byte
var createdAtRaw string
var updatedAtRaw string
if err := scan(
&m.ID, &m.TemplateID, &m.Version, &m.Source, &m.LanguageUsedDiscovery, &payloadRaw,
&responseRaw, &flattenedRaw, &isActive, &createdAtRaw, &updatedAtRaw,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
}
return nil, err
}
m.IsActive = isActive == 1
m.DiscoveryPayloadJSON = cloneBytes(payloadRaw)
m.DiscoveryResponseJSON = cloneBytes(responseRaw)
m.FlattenedManifestJSON = cloneBytes(flattenedRaw)
m.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
m.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
return &m, nil
}

func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) {
var b domain.SiteBuild
var globalRaw []byte
var aiDataRaw []byte
var payloadRaw []byte
var resultRaw []byte
var errorRaw []byte
var startedAtRaw sql.NullString
var finishedAtRaw sql.NullString
var jobID sql.NullInt64
var siteID sql.NullInt64
if err := scan(
&b.ID, &b.TemplateID, &b.ManifestID, &b.RequestName, &globalRaw, &aiDataRaw, &payloadRaw,
&jobID, &siteID, &b.QCStatus, &b.QCPreviewURL, &b.QCEditorURL, &resultRaw, &errorRaw, &startedAtRaw, &finishedAtRaw,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
}
return nil, err
}
b.GlobalDataJSON = cloneBytes(globalRaw)
b.AIDataJSON = cloneBytes(aiDataRaw)
b.FinalSitesPayload = cloneBytes(payloadRaw)
b.QCResultJSON = cloneBytes(resultRaw)
b.QCErrorJSON = cloneBytes(errorRaw)
if jobID.Valid {
id := jobID.Int64
b.QCJobID = &id
}
if siteID.Valid {
id := siteID.Int64
b.QCSiteID = &id
}
b.StartedAt = parseTimePtr(startedAtRaw)
b.FinishedAt = parseTimePtr(finishedAtRaw)
return &b, nil
}

func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
var d domain.BuildDraft
var globalRaw []byte
var fieldsRaw []byte
var createdAtRaw string
var updatedAtRaw string
if err := scan(
&d.ID, &d.TemplateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
}
return nil, err
}
d.GlobalDataJSON = cloneBytes(globalRaw)
d.FieldValuesJSON = cloneBytes(fieldsRaw)
d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
return &d, nil
}

func rollback(tx *sql.Tx) {
_ = tx.Rollback()
}

func boolToInt(v bool) int {
if v {
return 1
}
return 0
}

func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}

func asRaw(raw json.RawMessage) []byte {
if len(raw) == 0 {
return nil
}
out := make([]byte, len(raw))
copy(out, raw)
return out
}

func cloneBytes(raw []byte) []byte {
if len(raw) == 0 {
return nil
}
out := make([]byte, len(raw))
copy(out, raw)
return out
}

func asRFC3339Ptr(t *time.Time) *string {
if t == nil {
return nil
}
v := t.UTC().Format(time.RFC3339Nano)
return &v
}

func parseTimePtr(value sql.NullString) *time.Time {
if !value.Valid {
return nil
}
ts, err := time.Parse(time.RFC3339Nano, value.String)
if err != nil {
return nil
}
return &ts
}

+ 27
- 0
web/templates/build_new.gohtml Visa fil

@@ -12,6 +12,18 @@
<h1>New Build</h1>

<form method="get" action="/builds/new">
<label for="draft_id">Draft laden</label>
<select id="draft_id" name="draft_id">
<option value="">Kein Draft</option>
{{range .Drafts}}
<option value="{{.ID}}" {{if eq $.SelectedDraftID .ID}}selected{{end}}>{{.RequestName}} | {{.Status}} | {{.Source}} ({{.TemplateID}})</option>
{{end}}
</select>
<button type="submit">Draft laden</button>
</form>

<form method="get" action="/builds/new">
{{if .SelectedDraftID}}<input type="hidden" name="draft_id" value="{{.SelectedDraftID}}">{{end}}
<label for="template_id">Template</label>
<select id="template_id" name="template_id">
<option value="">Select template</option>
@@ -24,12 +36,26 @@

{{if gt .SelectedTemplateID 0}}
<form method="post" action="/builds">
<input type="hidden" name="draft_id" value="{{.Form.DraftID}}">
<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 class="grid2">
<div><label>Draft Source<input type="text" name="draft_source" value="{{.Form.DraftSource}}" placeholder="ui"></label></div>
<div>
<label>Draft Status
<select name="draft_status">
<option value="draft" {{if eq .Form.DraftStatus "draft"}}selected{{end}}>draft</option>
<option value="reviewed" {{if eq .Form.DraftStatus "reviewed"}}selected{{end}}>reviewed</option>
<option value="submitted" {{if eq .Form.DraftStatus "submitted"}}selected{{end}}>submitted</option>
</select>
</label>
</div>
</div>
<div><label>Request Name<input type="text" name="request_name" value="{{.Form.RequestName}}"></label></div>
<div><label>Draft Notes<textarea name="draft_notes">{{.Form.DraftNotes}}</textarea></label></div>

<h3>Basis / Firma</h3>
<div class="grid2">
@@ -85,6 +111,7 @@
</tbody>
</table>

<button type="submit" formaction="/builds/drafts">Save Draft</button>
<button type="submit">Start Build</button>
</form>
{{end}}


Laddar…
Avbryt
Spara