diff --git a/README.md b/README.md index c8d6ea1..5835ecd 100644 --- a/README.md +++ b/README.md @@ -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=` 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. diff --git a/data/qctextbuilder.db b/data/qctextbuilder.db new file mode 100644 index 0000000..d2ef412 Binary files /dev/null and b/data/qctextbuilder.db differ diff --git a/dist/qctextbuilder.exe b/dist/qctextbuilder.exe index f73c024..db4cce0 100644 Binary files a/dist/qctextbuilder.exe and b/dist/qctextbuilder.exe differ diff --git a/go.mod b/go.mod index 389505a..683a11f 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 5bd7be3..ff18c75 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index d647a85..9622058 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 67d6734..4f6b70c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/domain/models.go b/internal/domain/models.go index 83332f9..9f8813d 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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"` diff --git a/internal/draftsvc/service.go b/internal/draftsvc/service.go new file mode 100644 index 0000000..ab19544 --- /dev/null +++ b/internal/draftsvc/service.go @@ -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) +} diff --git a/internal/httpserver/handlers/handlers.go b/internal/httpserver/handlers/handlers.go index 737c552..e0e741a 100644 --- a/internal/httpserver/handlers/handlers.go +++ b/internal/httpserver/handlers/handlers.go @@ -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) +} diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index df72e12..36eafad 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -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" + } +} diff --git a/internal/store/interfaces.go b/internal/store/interfaces.go index ec33586..bb14d22 100644 --- a/internal/store/interfaces.go +++ b/internal/store/interfaces.go @@ -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) +} diff --git a/internal/store/memory/store.go b/internal/store/memory/store.go index b68ad11..d73b585 100644 --- a/internal/store/memory/store.go +++ b/internal/store/memory/store.go @@ -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 +} diff --git a/internal/store/sqlite/migrations/001_init.sql b/internal/store/sqlite/migrations/001_init.sql new file mode 100644 index 0000000..ca00137 --- /dev/null +++ b/internal/store/sqlite/migrations/001_init.sql @@ -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); diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index b10e5d3..ddb82a5 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -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 +} diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index 1269b4f..afb2efb 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -12,6 +12,18 @@

New Build

+ + + +
+ +
+ {{if .SelectedDraftID}}{{end}}

Global Data

+
+
+
+ +
+
+

Basis / Firma

@@ -85,6 +111,7 @@ + {{end}}