| @@ -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. | |||
| @@ -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 | |||
| ) | |||
| @@ -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= | |||
| @@ -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) | |||
| @@ -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"), | |||
| @@ -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"` | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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" | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 ©, 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 ©, 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 ©, 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 ©, 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 | |||
| } | |||
| @@ -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); | |||
| @@ -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 | |||
| } | |||
| @@ -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}} | |||