| @@ -6,14 +6,17 @@ Milestone 2 status: | |||||
| - AI template sync endpoint | - AI template sync endpoint | ||||
| - onboarding/discovery endpoint with local manifest flattening | - onboarding/discovery endpoint with local manifest flattening | ||||
| - site build flow via `POST /sites` using local manifest + own text (`content.aiData`) | - 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 | - 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 | - strict MVP scope: no ACP login flow, no DCM/EFL, no image payload handling | ||||
| ## Run | ## Run | ||||
| 1. Set env vars: | 1. Set env vars: | ||||
| - `HTTP_ADDR=:8080` | - `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_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1` | ||||
| - `QC_TOKEN=<your bearer token>` | - `QC_TOKEN=<your bearer token>` | ||||
| 2. Start: | 2. Start: | ||||
| @@ -27,11 +30,22 @@ Milestone 2 status: | |||||
| - `GET /api/templates/{id}` | - `GET /api/templates/{id}` | ||||
| - `POST /api/templates/{id}/onboard` | - `POST /api/templates/{id}/onboard` | ||||
| - `PUT /api/templates/{id}/fields` | - `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` | - `POST /api/site-builds` | ||||
| - `GET /api/site-builds/{id}` | - `GET /api/site-builds/{id}` | ||||
| - `POST /api/site-builds/{id}/poll` | - `POST /api/site-builds/{id}/poll` | ||||
| - `POST /api/site-builds/{id}/fetch-editor-url` | - `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: | Build request payload (`POST /api/site-builds`) expects: | ||||
| - `templateId` (AI template only, onboarded/reviewed) | - `templateId` (AI template only, onboarded/reviewed) | ||||
| - `requestName` | - `requestName` | ||||
| @@ -43,4 +57,5 @@ Documented `globalData` scope supported by UI/API mapping: | |||||
| - `orgNumber`, `startDate`, `mission`, `descriptionShort`, `descriptionLong`, `siteLanguage` | - `orgNumber`, `startDate`, `mission`, `descriptionShort`, `descriptionLong`, `siteLanguage` | ||||
| - `address.line1`, `address.line2`, `address.city`, `address.region`, `address.zip`, `address.country` | - `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 | module qctextbuilder | ||||
| go 1.24 | |||||
| go 1.25.0 | |||||
| require github.com/go-chi/chi/v5 v5.2.3 | 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 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= | ||||
| github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= | 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" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "strings" | |||||
| "time" | "time" | ||||
| "github.com/go-chi/chi/v5" | "github.com/go-chi/chi/v5" | ||||
| "qctextbuilder/internal/buildsvc" | "qctextbuilder/internal/buildsvc" | ||||
| "qctextbuilder/internal/config" | "qctextbuilder/internal/config" | ||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/draftsvc" | |||||
| "qctextbuilder/internal/httpserver" | "qctextbuilder/internal/httpserver" | ||||
| "qctextbuilder/internal/httpserver/handlers" | "qctextbuilder/internal/httpserver/handlers" | ||||
| "qctextbuilder/internal/httpserver/views" | "qctextbuilder/internal/httpserver/views" | ||||
| @@ -19,7 +22,9 @@ import ( | |||||
| "qctextbuilder/internal/onboarding" | "qctextbuilder/internal/onboarding" | ||||
| "qctextbuilder/internal/polling" | "qctextbuilder/internal/polling" | ||||
| "qctextbuilder/internal/qcclient" | "qctextbuilder/internal/qcclient" | ||||
| "qctextbuilder/internal/store" | |||||
| "qctextbuilder/internal/store/memory" | "qctextbuilder/internal/store/memory" | ||||
| "qctextbuilder/internal/store/sqlite" | |||||
| "qctextbuilder/internal/templatesvc" | "qctextbuilder/internal/templatesvc" | ||||
| ) | ) | ||||
| @@ -30,21 +35,58 @@ type App struct { | |||||
| func New(cfg config.Config) (*App, error) { | func New(cfg config.Config) (*App, error) { | ||||
| logger := logging.New() | 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) | 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() | 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") | renderer, err := views.NewRenderer("web/templates/*.gohtml") | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("init renderer: %w", err) | 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) { | server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | ||||
| r.Get("/", ui.Home) | 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}/onboard", ui.OnboardTemplate) | ||||
| r.Post("/templates/{id}/fields", ui.UpdateTemplateFields) | r.Post("/templates/{id}/fields", ui.UpdateTemplateFields) | ||||
| r.Get("/builds/new", ui.BuildNew) | r.Get("/builds/new", ui.BuildNew) | ||||
| r.Post("/builds/drafts", ui.SaveDraft) | |||||
| r.Post("/builds", ui.CreateBuild) | r.Post("/builds", ui.CreateBuild) | ||||
| r.Get("/builds/{id}", ui.BuildDetail) | r.Get("/builds/{id}", ui.BuildDetail) | ||||
| r.Post("/builds/{id}/poll", ui.PollBuild) | 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.Get("/templates/{id}", api.GetTemplateDetail) | ||||
| r.Post("/templates/{id}/onboard", api.OnboardTemplate) | r.Post("/templates/{id}/onboard", api.OnboardTemplate) | ||||
| r.Put("/templates/{id}/fields", api.UpdateTemplateFields) | 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.Post("/site-builds", api.StartBuild) | ||||
| r.Get("/site-builds/{id}", api.GetBuild) | r.Get("/site-builds/{id}", api.GetBuild) | ||||
| r.Post("/site-builds/{id}/poll", api.PollBuildOnce) | r.Post("/site-builds/{id}/poll", api.PollBuildOnce) | ||||
| @@ -20,8 +20,8 @@ type Config struct { | |||||
| func Load() Config { | func Load() Config { | ||||
| return Config{ | return Config{ | ||||
| HTTPAddr: getenv("HTTP_ADDR", ":8080"), | 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"), | AppSecret: os.Getenv("APP_SECRET"), | ||||
| QCBaseURL: getenv("QC_BASE_URL", "https://qc-api.yggdrasil.dev-mono.net/api/v1"), | QCBaseURL: getenv("QC_BASE_URL", "https://qc-api.yggdrasil.dev-mono.net/api/v1"), | ||||
| QCToken: os.Getenv("QC_TOKEN"), | QCToken: os.Getenv("QC_TOKEN"), | ||||
| @@ -70,6 +70,20 @@ type SiteBuild struct { | |||||
| FinishedAt *time.Time `json:"finishedAt,omitempty"` | 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 { | type AppSettings struct { | ||||
| QCBaseURL string `json:"qcBaseUrl"` | QCBaseURL string `json:"qcBaseUrl"` | ||||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | 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" | "encoding/json" | ||||
| "net/http" | "net/http" | ||||
| "strconv" | "strconv" | ||||
| "strings" | |||||
| "github.com/go-chi/chi/v5" | "github.com/go-chi/chi/v5" | ||||
| "qctextbuilder/internal/buildsvc" | "qctextbuilder/internal/buildsvc" | ||||
| "qctextbuilder/internal/draftsvc" | |||||
| "qctextbuilder/internal/onboarding" | "qctextbuilder/internal/onboarding" | ||||
| "qctextbuilder/internal/templatesvc" | "qctextbuilder/internal/templatesvc" | ||||
| ) | ) | ||||
| @@ -15,13 +17,15 @@ import ( | |||||
| type API struct { | type API struct { | ||||
| templateSvc *templatesvc.Service | templateSvc *templatesvc.Service | ||||
| onboardSvc *onboarding.Service | onboardSvc *onboarding.Service | ||||
| draftSvc *draftsvc.Service | |||||
| buildSvc buildsvc.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{ | return &API{ | ||||
| templateSvc: templateSvc, | templateSvc: templateSvc, | ||||
| onboardSvc: onboardSvc, | onboardSvc: onboardSvc, | ||||
| draftSvc: draftSvc, | |||||
| buildSvc: buildSvc, | buildSvc: buildSvc, | ||||
| } | } | ||||
| } | } | ||||
| @@ -157,6 +161,85 @@ func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) { | |||||
| writeJSON(w, http.StatusAccepted, result) | 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) { | func (a *API) GetBuild(w http.ResponseWriter, r *http.Request) { | ||||
| buildID := chi.URLParam(r, "id") | buildID := chi.URLParam(r, "id") | ||||
| build, err := a.buildSvc.GetBuild(r.Context(), buildID) | build, err := a.buildSvc.GetBuild(r.Context(), buildID) | ||||
| @@ -202,3 +285,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) { | |||||
| w.WriteHeader(status) | w.WriteHeader(status) | ||||
| _ = json.NewEncoder(w).Encode(v) | _ = 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/buildsvc" | ||||
| "qctextbuilder/internal/config" | "qctextbuilder/internal/config" | ||||
| "qctextbuilder/internal/domain" | "qctextbuilder/internal/domain" | ||||
| "qctextbuilder/internal/draftsvc" | |||||
| "qctextbuilder/internal/onboarding" | "qctextbuilder/internal/onboarding" | ||||
| "qctextbuilder/internal/templatesvc" | "qctextbuilder/internal/templatesvc" | ||||
| ) | ) | ||||
| @@ -20,6 +21,7 @@ import ( | |||||
| type UI struct { | type UI struct { | ||||
| templateSvc *templatesvc.Service | templateSvc *templatesvc.Service | ||||
| onboardSvc *onboarding.Service | onboardSvc *onboarding.Service | ||||
| draftSvc *draftsvc.Service | |||||
| buildSvc buildsvc.Service | buildSvc buildsvc.Service | ||||
| cfg config.Config | cfg config.Config | ||||
| render htmlRenderer | render htmlRenderer | ||||
| @@ -83,6 +85,8 @@ type buildFieldView struct { | |||||
| type buildNewPageData struct { | type buildNewPageData struct { | ||||
| pageData | pageData | ||||
| Templates []domain.Template | Templates []domain.Template | ||||
| Drafts []domain.BuildDraft | |||||
| SelectedDraftID string | |||||
| SelectedTemplateID int64 | SelectedTemplateID int64 | ||||
| SelectedManifestID string | SelectedManifestID string | ||||
| EnabledFields []buildFieldView | EnabledFields []buildFieldView | ||||
| @@ -90,6 +94,10 @@ type buildNewPageData struct { | |||||
| } | } | ||||
| type buildFormInput struct { | type buildFormInput struct { | ||||
| DraftID string | |||||
| DraftSource string | |||||
| DraftStatus string | |||||
| DraftNotes string | |||||
| RequestName string | RequestName string | ||||
| CompanyName string | CompanyName string | ||||
| BusinessType string | BusinessType string | ||||
| @@ -119,8 +127,8 @@ type buildDetailPageData struct { | |||||
| AutoRefreshSeconds int | 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) { | 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) { | func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { | ||||
| selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64) | 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 { | if err != nil { | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| @@ -261,6 +284,25 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| form := buildFormInputFromRequest(r) | form := buildFormInputFromRequest(r) | ||||
| fieldValues := parseBuildFieldValues(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) | templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | ||||
| if err != nil || templateID <= 0 { | 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{ | result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{ | ||||
| TemplateID: templateID, | TemplateID: templateID, | ||||
| RequestName: form.RequestName, | 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, | FieldValues: fieldValues, | ||||
| }) | }) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -297,7 +321,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| Title: "New Build", | Title: "New Build", | ||||
| Err: err.Error(), | Err: err.Error(), | ||||
| Current: "/builds/new", | Current: "/builds/new", | ||||
| }, templateID, form, fieldValues) | |||||
| }, form.DraftID, templateID, form, fieldValues) | |||||
| if loadErr != nil { | if loadErr != nil { | ||||
| http.Error(w, loadErr.Error(), http.StatusBadRequest) | http.Error(w, loadErr.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| @@ -305,9 +329,82 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| u.render.Render(w, "build_new", data) | u.render.Render(w, "build_new", data) | ||||
| return | 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) | 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) { | func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) { | ||||
| buildID := strings.TrimSpace(chi.URLParam(r, "id")) | buildID := strings.TrimSpace(chi.URLParam(r, "id")) | ||||
| build, err := u.buildSvc.GetBuild(r.Context(), buildID) | build, err := u.buildSvc.GetBuild(r.Context(), buildID) | ||||
| @@ -382,15 +479,21 @@ func strPtr(v string) *string { | |||||
| return &v | 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()) | templates, err := u.templateSvc.ListTemplates(r.Context()) | ||||
| if err != nil { | if err != nil { | ||||
| return buildNewPageData{}, err | return buildNewPageData{}, err | ||||
| } | } | ||||
| drafts, err := u.draftSvc.ListDrafts(r.Context(), 50) | |||||
| if err != nil { | |||||
| return buildNewPageData{}, err | |||||
| } | |||||
| data := buildNewPageData{ | data := buildNewPageData{ | ||||
| pageData: page, | pageData: page, | ||||
| Templates: templates, | Templates: templates, | ||||
| Drafts: drafts, | |||||
| SelectedDraftID: selectedDraftID, | |||||
| SelectedTemplateID: selectedTemplateID, | SelectedTemplateID: selectedTemplateID, | ||||
| Form: form, | Form: form, | ||||
| } | } | ||||
| @@ -419,6 +522,10 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedTempla | |||||
| func buildFormInputFromRequest(r *http.Request) buildFormInput { | func buildFormInputFromRequest(r *http.Request) buildFormInput { | ||||
| return 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")), | RequestName: strings.TrimSpace(r.FormValue("request_name")), | ||||
| CompanyName: strings.TrimSpace(r.FormValue("company_name")), | CompanyName: strings.TrimSpace(r.FormValue("company_name")), | ||||
| BusinessType: strings.TrimSpace(r.FormValue("business_type")), | 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 { | func parseBuildFieldValues(r *http.Request) map[string]string { | ||||
| fieldValues := map[string]string{} | fieldValues := map[string]string{} | ||||
| count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) | count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) | ||||
| @@ -472,3 +591,54 @@ func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) { | |||||
| } | } | ||||
| return data, nil | 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 ( | import ( | ||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | |||||
| "time" | "time" | ||||
| "qctextbuilder/internal/domain" | "qctextbuilder/internal/domain" | ||||
| ) | ) | ||||
| var ErrNotFound = errors.New("not found") | |||||
| type TemplateStore interface { | type TemplateStore interface { | ||||
| UpsertTemplates(ctx context.Context, templates []domain.Template) error | UpsertTemplates(ctx context.Context, templates []domain.Template) error | ||||
| GetTemplateByID(ctx context.Context, id int64) (*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 | 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" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | "errors" | ||||
| "sort" | |||||
| "sync" | "sync" | ||||
| "time" | "time" | ||||
| "qctextbuilder/internal/domain" | "qctextbuilder/internal/domain" | ||||
| "qctextbuilder/internal/store" | |||||
| ) | ) | ||||
| var ErrNotFound = errors.New("not found") | |||||
| type Store struct { | type Store struct { | ||||
| mu sync.RWMutex | mu sync.RWMutex | ||||
| templates map[int64]domain.Template | templates map[int64]domain.Template | ||||
| manifests map[int64]domain.TemplateManifest | manifests map[int64]domain.TemplateManifest | ||||
| manifestField map[string][]domain.TemplateField | manifestField map[string][]domain.TemplateField | ||||
| builds map[string]domain.SiteBuild | builds map[string]domain.SiteBuild | ||||
| drafts map[string]domain.BuildDraft | |||||
| settings domain.AppSettings | |||||
| hasSettings bool | |||||
| } | } | ||||
| func New() *Store { | func New() *Store { | ||||
| @@ -26,6 +29,7 @@ func New() *Store { | |||||
| manifests: make(map[int64]domain.TemplateManifest), | manifests: make(map[int64]domain.TemplateManifest), | ||||
| manifestField: make(map[string][]domain.TemplateField), | manifestField: make(map[string][]domain.TemplateField), | ||||
| builds: make(map[string]domain.SiteBuild), | 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] | t, ok := s.templates[id] | ||||
| if !ok { | if !ok { | ||||
| return nil, ErrNotFound | |||||
| return nil, store.ErrNotFound | |||||
| } | } | ||||
| copy := t | copy := t | ||||
| return ©, nil | return ©, nil | ||||
| @@ -74,7 +78,7 @@ func (s *Store) SetTemplateManifestStatus(_ context.Context, templateID int64, s | |||||
| t, ok := s.templates[templateID] | t, ok := s.templates[templateID] | ||||
| if !ok { | if !ok { | ||||
| return ErrNotFound | |||||
| return store.ErrNotFound | |||||
| } | } | ||||
| t.ManifestStatus = status | t.ManifestStatus = status | ||||
| t.IsOnboarded = onboarded | t.IsOnboarded = onboarded | ||||
| @@ -97,7 +101,7 @@ func (s *Store) GetActiveManifestByTemplateID(_ context.Context, templateID int6 | |||||
| m, ok := s.manifests[templateID] | m, ok := s.manifests[templateID] | ||||
| if !ok { | if !ok { | ||||
| return nil, ErrNotFound | |||||
| return nil, store.ErrNotFound | |||||
| } | } | ||||
| copy := m | copy := m | ||||
| return ©, nil | return ©, nil | ||||
| @@ -109,7 +113,7 @@ func (s *Store) ListFieldsByManifestID(_ context.Context, manifestID string) ([] | |||||
| fields, ok := s.manifestField[manifestID] | fields, ok := s.manifestField[manifestID] | ||||
| if !ok { | if !ok { | ||||
| return nil, ErrNotFound | |||||
| return nil, store.ErrNotFound | |||||
| } | } | ||||
| out := make([]domain.TemplateField, 0, len(fields)) | out := make([]domain.TemplateField, 0, len(fields)) | ||||
| out = append(out, fields...) | out = append(out, fields...) | ||||
| @@ -121,7 +125,7 @@ func (s *Store) UpdateFields(_ context.Context, manifestID string, fields []doma | |||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| if _, ok := s.manifestField[manifestID]; !ok { | if _, ok := s.manifestField[manifestID]; !ok { | ||||
| return ErrNotFound | |||||
| return store.ErrNotFound | |||||
| } | } | ||||
| next := make([]domain.TemplateField, len(fields)) | next := make([]domain.TemplateField, len(fields)) | ||||
| copy(next, fields) | copy(next, fields) | ||||
| @@ -146,7 +150,7 @@ func (s *Store) GetBuildByID(_ context.Context, id string) (*domain.SiteBuild, e | |||||
| build, ok := s.builds[id] | build, ok := s.builds[id] | ||||
| if !ok { | if !ok { | ||||
| return nil, ErrNotFound | |||||
| return nil, store.ErrNotFound | |||||
| } | } | ||||
| copy := build | copy := build | ||||
| return ©, nil | return ©, nil | ||||
| @@ -182,7 +186,7 @@ func (s *Store) MarkBuildSubmitted(_ context.Context, buildID string, jobID int6 | |||||
| build, ok := s.builds[buildID] | build, ok := s.builds[buildID] | ||||
| if !ok { | if !ok { | ||||
| return ErrNotFound | |||||
| return store.ErrNotFound | |||||
| } | } | ||||
| build.QCJobID = &jobID | build.QCJobID = &jobID | ||||
| build.QCStatus = status | build.QCStatus = status | ||||
| @@ -198,7 +202,7 @@ func (s *Store) UpdateBuildFromJob(_ context.Context, buildID string, status str | |||||
| build, ok := s.builds[buildID] | build, ok := s.builds[buildID] | ||||
| if !ok { | if !ok { | ||||
| return ErrNotFound | |||||
| return store.ErrNotFound | |||||
| } | } | ||||
| build.QCStatus = status | build.QCStatus = status | ||||
| build.QCResultJSON = cloneRaw(qcResult) | build.QCResultJSON = cloneRaw(qcResult) | ||||
| @@ -219,7 +223,7 @@ func (s *Store) UpdateBuildEditorURL(_ context.Context, buildID string, editorUR | |||||
| build, ok := s.builds[buildID] | build, ok := s.builds[buildID] | ||||
| if !ok { | if !ok { | ||||
| return ErrNotFound | |||||
| return store.ErrNotFound | |||||
| } | } | ||||
| build.QCEditorURL = editorURL | build.QCEditorURL = editorURL | ||||
| build.QCResultJSON = cloneRaw(qcResult) | build.QCResultJSON = cloneRaw(qcResult) | ||||
| @@ -235,3 +239,73 @@ func cloneRaw(raw json.RawMessage) json.RawMessage { | |||||
| copy(out, raw) | copy(out, raw) | ||||
| return json.RawMessage(out) | 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 | 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> | <h1>New Build</h1> | ||||
| <form method="get" action="/builds/new"> | <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> | <label for="template_id">Template</label> | ||||
| <select id="template_id" name="template_id"> | <select id="template_id" name="template_id"> | ||||
| <option value="">Select template</option> | <option value="">Select template</option> | ||||
| @@ -24,12 +36,26 @@ | |||||
| {{if gt .SelectedTemplateID 0}} | {{if gt .SelectedTemplateID 0}} | ||||
| <form method="post" action="/builds"> | <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="template_id" value="{{.SelectedTemplateID}}"> | ||||
| <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | ||||
| <input type="hidden" name="field_count" value="{{len .EnabledFields}}"> | <input type="hidden" name="field_count" value="{{len .EnabledFields}}"> | ||||
| <h2>Global Data</h2> | <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>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> | <h3>Basis / Firma</h3> | ||||
| <div class="grid2"> | <div class="grid2"> | ||||
| @@ -85,6 +111,7 @@ | |||||
| </tbody> | </tbody> | ||||
| </table> | </table> | ||||
| <button type="submit" formaction="/builds/drafts">Save Draft</button> | |||||
| <button type="submit">Start Build</button> | <button type="submit">Start Build</button> | ||||
| </form> | </form> | ||||
| {{end}} | {{end}} | ||||