| @@ -16,6 +16,7 @@ Dieses Dokument definiert projektlokale Leitplanken fuer Menschen und Agenten, d | |||
| - AI-Template-Sync, Discovery/Onboarding und bearbeitbare Manifest-Felder. | |||
| - Draft-Intake, Draft-Bearbeitung und Statuswechsel (`draft`, `reviewed`, `submitted`). | |||
| - Externer Draft-Intake-Vertrag (`POST /api/drafts/intake`) fuer Stammdaten plus optionalen Website-/Stilkontext. | |||
| - Build-Start aus geprueften Daten, Polling und Editor-URL-Abruf. | |||
| - SQLite als Default-Datenhaltung fuer lokalen Betrieb. | |||
| @@ -8,10 +8,12 @@ Die App kann heute: | |||
| - AI-Templates aus QC synchronisieren und anzeigen. | |||
| - Templates onboarden (Discovery/Manifest) und Felder fuer Mapping/Review bearbeiten. | |||
| - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. | |||
| - Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build). | |||
| - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | |||
| Wichtig: | |||
| - Leadharvester-Intake und LLM-Autofill sind geplant, aber noch nicht fertig integriert. | |||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | |||
| - LLM-Autofill ist noch nicht fertig; vorbereitet sind nur Kontextfelder (Website/Style/Ton) im Draft-Review-Flow. | |||
| ## Lokaler Start | |||
| @@ -36,6 +36,8 @@ Soll-Logik: | |||
| Aktueller Stand: | |||
| - Draft-Erfassung, Listing, Update und UI-Weiterbearbeitung sind vorhanden. | |||
| - Definierter externer Intake unter `POST /api/drafts/intake` ist vorhanden; `templateId` ist optional, Build wird dort nicht ausgeloest. | |||
| - Draft-Kontext fuer spaetere LLM-Unterstuetzung ist vorbereitet (Website-URL, Website-Summary, Stilprofil inkl. locale/market/address mode/tone/prompt instructions). | |||
| - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | |||
| - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | |||
| @@ -91,14 +93,14 @@ Statusmarker: | |||
| - [-] Review-Gate ist noch nicht als strikter, rollenbasierter Freigabeprozess umgesetzt. | |||
| ### D) Leadharvester-Integration | |||
| - [ ] Definierter Intake-Adapter von Leadharvester in `POST /api/drafts/intake`. | |||
| - [ ] Mapping-Contract fuer Stammdaten + optionale Website-Zusammenfassung. | |||
| - [x] Definierter Intake-Adapter von Leadharvester in `POST /api/drafts/intake`. | |||
| - [x] Mapping-Contract fuer Stammdaten + optionale Website-Zusammenfassung. | |||
| - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. | |||
| ### E) LLM-Assistenz | |||
| - [ ] Feldvorschlaege fuer fehlende Inhalte im Draft. | |||
| - [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld. | |||
| - [ ] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet. | |||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder vorhanden, produktive Vorschlagslogik offen). | |||
| ### F) Security und Betriebsreife | |||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | |||
| @@ -72,17 +72,38 @@ type SiteBuild struct { | |||
| } | |||
| 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"` | |||
| 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"` | |||
| DraftContextJSON json.RawMessage `json:"draftContextJson"` | |||
| Status string `json:"status"` | |||
| Notes string `json:"notes"` | |||
| CreatedAt time.Time `json:"createdAt"` | |||
| UpdatedAt time.Time `json:"updatedAt"` | |||
| } | |||
| type DraftStyleProfile struct { | |||
| LocaleStyle string `json:"localeStyle,omitempty"` | |||
| MarketStyle string `json:"marketStyle,omitempty"` | |||
| AddressMode string `json:"addressMode,omitempty"` | |||
| ContentTone string `json:"contentTone,omitempty"` | |||
| PromptInstructions string `json:"promptInstructions,omitempty"` | |||
| } | |||
| type DraftLLMContext struct { | |||
| BusinessType string `json:"businessType,omitempty"` | |||
| WebsiteURL string `json:"websiteUrl,omitempty"` | |||
| WebsiteSummary string `json:"websiteSummary,omitempty"` | |||
| StyleProfile DraftStyleProfile `json:"styleProfile,omitempty"` | |||
| } | |||
| type DraftContext struct { | |||
| IntakeSource string `json:"intakeSource,omitempty"` | |||
| LLM DraftLLMContext `json:"llm,omitempty"` | |||
| } | |||
| type AppSettings struct { | |||
| @@ -14,15 +14,16 @@ import ( | |||
| ) | |||
| 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"` | |||
| DraftID string `json:"draftId,omitempty"` | |||
| TemplateID *int64 `json:"templateId,omitempty"` | |||
| 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"` | |||
| DraftContext *domain.DraftContext `json:"draftContext,omitempty"` | |||
| Status string `json:"status,omitempty"` | |||
| Notes string `json:"notes,omitempty"` | |||
| } | |||
| type Service struct { | |||
| @@ -40,9 +41,14 @@ func New(draftStore store.DraftStore, templateStore store.TemplateStore, manifes | |||
| } | |||
| func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domain.BuildDraft, error) { | |||
| templateID := req.TemplateID | |||
| templateID := int64(0) | |||
| if req.TemplateID != nil { | |||
| templateID = *req.TemplateID | |||
| } | |||
| var existing *domain.BuildDraft | |||
| if strings.TrimSpace(req.DraftID) != "" { | |||
| existing, err := s.drafts.GetDraftByID(ctx, strings.TrimSpace(req.DraftID)) | |||
| var err error | |||
| existing, err = s.drafts.GetDraftByID(ctx, strings.TrimSpace(req.DraftID)) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("get draft: %w", err) | |||
| } | |||
| @@ -50,20 +56,21 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||
| 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") | |||
| if templateID > 0 { | |||
| 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 == "" { | |||
| if templateID <= 0 { | |||
| manifestID = "" | |||
| } else if manifestID == "" { | |||
| manifest, err := s.manifests.GetActiveManifestByTemplateID(ctx, templateID) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("get active manifest: %w", err) | |||
| @@ -79,6 +86,10 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||
| if err != nil { | |||
| return nil, errors.New("fieldValues is invalid JSON") | |||
| } | |||
| draftContextJSON, err := buildDraftContextJSON(req, existing) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| now := time.Now().UTC() | |||
| source := defaultString(req.Source, "ui") | |||
| @@ -88,16 +99,17 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||
| } | |||
| 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, | |||
| ID: strings.TrimSpace(req.DraftID), | |||
| TemplateID: templateID, | |||
| ManifestID: manifestID, | |||
| Source: source, | |||
| RequestName: strings.TrimSpace(req.RequestName), | |||
| GlobalDataJSON: globalDataJSON, | |||
| FieldValuesJSON: fieldValuesJSON, | |||
| DraftContextJSON: draftContextJSON, | |||
| Status: status, | |||
| Notes: strings.TrimSpace(req.Notes), | |||
| UpdatedAt: now, | |||
| } | |||
| if draft.ID == "" { | |||
| draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10) | |||
| @@ -119,6 +131,20 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||
| return s.drafts.GetDraftByID(ctx, draft.ID) | |||
| } | |||
| func buildDraftContextJSON(req UpsertDraftRequest, existing *domain.BuildDraft) (json.RawMessage, error) { | |||
| if req.DraftContext == nil { | |||
| if existing != nil { | |||
| return existing.DraftContextJSON, nil | |||
| } | |||
| return nil, nil | |||
| } | |||
| raw, err := json.Marshal(req.DraftContext) | |||
| if err != nil { | |||
| return nil, errors.New("draftContext is invalid JSON") | |||
| } | |||
| return raw, nil | |||
| } | |||
| func (s *Service) GetDraft(ctx context.Context, draftID string) (*domain.BuildDraft, error) { | |||
| return s.drafts.GetDraftByID(ctx, strings.TrimSpace(draftID)) | |||
| } | |||
| @@ -9,6 +9,7 @@ import ( | |||
| "github.com/go-chi/chi/v5" | |||
| "qctextbuilder/internal/buildsvc" | |||
| "qctextbuilder/internal/domain" | |||
| "qctextbuilder/internal/draftsvc" | |||
| "qctextbuilder/internal/onboarding" | |||
| "qctextbuilder/internal/templatesvc" | |||
| @@ -164,37 +165,109 @@ func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) { | |||
| } | |||
| 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"` | |||
| TemplateID *int64 `json:"templateId,omitempty"` | |||
| ManifestID string `json:"manifestId"` | |||
| Source string `json:"source"` | |||
| RequestName string `json:"requestName"` | |||
| GlobalData map[string]any `json:"globalData"` | |||
| FieldValues map[string]string `json:"fieldValues"` | |||
| DraftContext *domain.DraftContext `json:"draftContext,omitempty"` | |||
| Status string `json:"status"` | |||
| Notes string `json:"notes"` | |||
| } | |||
| type intakeDraftRequest struct { | |||
| DraftID string `json:"draftId,omitempty"` | |||
| Source string `json:"source"` | |||
| RequestName string `json:"requestName"` | |||
| TemplateID *int64 `json:"templateId,omitempty"` | |||
| GlobalData map[string]any `json:"globalData"` | |||
| Notes string `json:"notes"` | |||
| WebsiteURL string `json:"websiteUrl,omitempty"` | |||
| WebsiteSummary string `json:"websiteSummary,omitempty"` | |||
| BusinessType string `json:"businessType,omitempty"` | |||
| LocaleStyle string `json:"localeStyle,omitempty"` | |||
| MarketStyle string `json:"marketStyle,omitempty"` | |||
| AddressMode string `json:"addressMode,omitempty"` | |||
| ContentTone string `json:"contentTone,omitempty"` | |||
| PromptInstructions string `json:"promptInstructions,omitempty"` | |||
| StyleProfile *domain.DraftStyleProfile `json:"styleProfile,omitempty"` | |||
| } | |||
| func (a *API) IntakeDraft(w http.ResponseWriter, r *http.Request) { | |||
| var req upsertDraftRequest | |||
| var req intakeDraftRequest | |||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) | |||
| return | |||
| } | |||
| globalData := req.GlobalData | |||
| if globalData == nil { | |||
| globalData = map[string]any{} | |||
| } | |||
| if strings.TrimSpace(req.BusinessType) != "" && strings.TrimSpace(getMapString(globalData, "businessType")) == "" { | |||
| globalData["businessType"] = strings.TrimSpace(req.BusinessType) | |||
| } | |||
| styleProfile := domain.DraftStyleProfile{ | |||
| LocaleStyle: strings.TrimSpace(req.LocaleStyle), | |||
| MarketStyle: strings.TrimSpace(req.MarketStyle), | |||
| AddressMode: strings.TrimSpace(req.AddressMode), | |||
| ContentTone: strings.TrimSpace(req.ContentTone), | |||
| PromptInstructions: strings.TrimSpace(req.PromptInstructions), | |||
| } | |||
| if req.StyleProfile != nil { | |||
| styleProfile = *req.StyleProfile | |||
| if styleProfile.LocaleStyle == "" { | |||
| styleProfile.LocaleStyle = strings.TrimSpace(req.LocaleStyle) | |||
| } | |||
| if styleProfile.MarketStyle == "" { | |||
| styleProfile.MarketStyle = strings.TrimSpace(req.MarketStyle) | |||
| } | |||
| if styleProfile.AddressMode == "" { | |||
| styleProfile.AddressMode = strings.TrimSpace(req.AddressMode) | |||
| } | |||
| if styleProfile.ContentTone == "" { | |||
| styleProfile.ContentTone = strings.TrimSpace(req.ContentTone) | |||
| } | |||
| if styleProfile.PromptInstructions == "" { | |||
| styleProfile.PromptInstructions = strings.TrimSpace(req.PromptInstructions) | |||
| } | |||
| } | |||
| businessType := strings.TrimSpace(req.BusinessType) | |||
| if businessType == "" { | |||
| businessType = strings.TrimSpace(getMapString(globalData, "businessType")) | |||
| } | |||
| draftContext := &domain.DraftContext{ | |||
| IntakeSource: strings.TrimSpace(req.Source), | |||
| LLM: domain.DraftLLMContext{ | |||
| BusinessType: businessType, | |||
| WebsiteURL: strings.TrimSpace(req.WebsiteURL), | |||
| WebsiteSummary: strings.TrimSpace(req.WebsiteSummary), | |||
| StyleProfile: styleProfile, | |||
| }, | |||
| } | |||
| 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, | |||
| DraftID: strings.TrimSpace(req.DraftID), | |||
| TemplateID: req.TemplateID, | |||
| Source: defaultStr(req.Source, "intake-api"), | |||
| RequestName: req.RequestName, | |||
| GlobalData: globalData, | |||
| FieldValues: map[string]string{}, | |||
| DraftContext: draftContext, | |||
| Status: "draft", | |||
| Notes: req.Notes, | |||
| }) | |||
| if err != nil { | |||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||
| return | |||
| } | |||
| writeJSON(w, http.StatusCreated, draft) | |||
| if strings.TrimSpace(req.DraftID) == "" { | |||
| writeJSON(w, http.StatusCreated, draft) | |||
| return | |||
| } | |||
| writeJSON(w, http.StatusOK, draft) | |||
| } | |||
| func (a *API) ListDrafts(w http.ResponseWriter, r *http.Request) { | |||
| @@ -225,15 +298,16 @@ func (a *API) UpdateDraft(w http.ResponseWriter, r *http.Request) { | |||
| 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, | |||
| DraftID: draftID, | |||
| TemplateID: req.TemplateID, | |||
| ManifestID: req.ManifestID, | |||
| Source: req.Source, | |||
| RequestName: req.RequestName, | |||
| GlobalData: req.GlobalData, | |||
| FieldValues: req.FieldValues, | |||
| DraftContext: req.DraftContext, | |||
| Status: req.Status, | |||
| Notes: req.Notes, | |||
| }) | |||
| if err != nil { | |||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | |||
| @@ -294,3 +368,11 @@ func defaultStr(v, fallback string) string { | |||
| } | |||
| return strings.TrimSpace(v) | |||
| } | |||
| func getMapString(values map[string]any, key string) string { | |||
| if values == nil { | |||
| return "" | |||
| } | |||
| raw, _ := values[key].(string) | |||
| return raw | |||
| } | |||
| @@ -145,28 +145,35 @@ type buildNewPageData struct { | |||
| } | |||
| type buildFormInput struct { | |||
| DraftID string | |||
| DraftSource string | |||
| DraftStatus string | |||
| DraftNotes string | |||
| RequestName string | |||
| CompanyName string | |||
| BusinessType string | |||
| Username string | |||
| Email string | |||
| Phone string | |||
| OrgNumber string | |||
| StartDate string | |||
| Mission string | |||
| DescriptionShort string | |||
| DescriptionLong string | |||
| SiteLanguage string | |||
| AddressLine1 string | |||
| AddressLine2 string | |||
| AddressCity string | |||
| AddressRegion string | |||
| AddressZIP string | |||
| AddressCountry string | |||
| DraftID string | |||
| DraftSource string | |||
| DraftStatus string | |||
| DraftNotes string | |||
| RequestName string | |||
| CompanyName string | |||
| BusinessType string | |||
| Username string | |||
| Email string | |||
| Phone string | |||
| OrgNumber string | |||
| StartDate string | |||
| Mission string | |||
| DescriptionShort string | |||
| DescriptionLong string | |||
| SiteLanguage string | |||
| AddressLine1 string | |||
| AddressLine2 string | |||
| AddressCity string | |||
| AddressRegion string | |||
| AddressZIP string | |||
| AddressCountry string | |||
| WebsiteURL string | |||
| WebsiteSummary string | |||
| LocaleStyle string | |||
| MarketStyle string | |||
| AddressMode string | |||
| ContentTone string | |||
| PromptInstructions string | |||
| } | |||
| type buildDetailPageData struct { | |||
| @@ -391,15 +398,16 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||
| 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, | |||
| DraftID: form.DraftID, | |||
| TemplateID: int64Ptr(templateID), | |||
| ManifestID: strings.TrimSpace(r.FormValue("manifest_id")), | |||
| Source: form.DraftSource, | |||
| RequestName: form.RequestName, | |||
| GlobalData: globalData, | |||
| FieldValues: fieldValues, | |||
| DraftContext: buildDraftContextFromForm(form, globalData), | |||
| Status: "submitted", | |||
| Notes: form.DraftNotes, | |||
| }) | |||
| } | |||
| @@ -438,15 +446,16 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) { | |||
| 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, | |||
| DraftID: form.DraftID, | |||
| TemplateID: int64Ptr(templateID), | |||
| ManifestID: strings.TrimSpace(r.FormValue("manifest_id")), | |||
| Source: form.DraftSource, | |||
| RequestName: form.RequestName, | |||
| GlobalData: globalData, | |||
| FieldValues: fieldValues, | |||
| DraftContext: buildDraftContextFromForm(form, globalData), | |||
| Status: defaultDraftStatus(form.DraftStatus), | |||
| Notes: form.DraftNotes, | |||
| }) | |||
| if err != nil { | |||
| data, loadErr := u.loadBuildNewPageData(r, pageData{ | |||
| @@ -532,8 +541,9 @@ func urlQuery(s string) string { | |||
| return url.QueryEscape(s) | |||
| } | |||
| func boolPtr(v bool) *bool { return &v } | |||
| func intPtr(v int) *int { return &v } | |||
| func boolPtr(v bool) *bool { return &v } | |||
| func intPtr(v int) *int { return &v } | |||
| func int64Ptr(v int64) *int64 { return &v } | |||
| func strPtr(v string) *string { | |||
| return &v | |||
| } | |||
| @@ -1127,28 +1137,35 @@ func humanizeKey(key string) string { | |||
| 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")), | |||
| Username: strings.TrimSpace(r.FormValue("username")), | |||
| Email: strings.TrimSpace(r.FormValue("email")), | |||
| Phone: strings.TrimSpace(r.FormValue("phone")), | |||
| OrgNumber: strings.TrimSpace(r.FormValue("org_number")), | |||
| StartDate: strings.TrimSpace(r.FormValue("start_date")), | |||
| Mission: strings.TrimSpace(r.FormValue("mission")), | |||
| DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), | |||
| DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), | |||
| SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), | |||
| AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), | |||
| AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), | |||
| AddressCity: strings.TrimSpace(r.FormValue("address_city")), | |||
| AddressRegion: strings.TrimSpace(r.FormValue("address_region")), | |||
| AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), | |||
| AddressCountry: strings.TrimSpace(r.FormValue("address_country")), | |||
| 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")), | |||
| Username: strings.TrimSpace(r.FormValue("username")), | |||
| Email: strings.TrimSpace(r.FormValue("email")), | |||
| Phone: strings.TrimSpace(r.FormValue("phone")), | |||
| OrgNumber: strings.TrimSpace(r.FormValue("org_number")), | |||
| StartDate: strings.TrimSpace(r.FormValue("start_date")), | |||
| Mission: strings.TrimSpace(r.FormValue("mission")), | |||
| DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), | |||
| DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), | |||
| SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), | |||
| AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), | |||
| AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), | |||
| AddressCity: strings.TrimSpace(r.FormValue("address_city")), | |||
| AddressRegion: strings.TrimSpace(r.FormValue("address_region")), | |||
| AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), | |||
| AddressCountry: strings.TrimSpace(r.FormValue("address_country")), | |||
| WebsiteURL: strings.TrimSpace(r.FormValue("website_url")), | |||
| WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")), | |||
| LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")), | |||
| MarketStyle: strings.TrimSpace(r.FormValue("market_style")), | |||
| AddressMode: strings.TrimSpace(r.FormValue("address_mode")), | |||
| ContentTone: strings.TrimSpace(r.FormValue("content_tone")), | |||
| PromptInstructions: strings.TrimSpace(r.FormValue("prompt_instructions")), | |||
| } | |||
| } | |||
| @@ -1161,6 +1178,7 @@ func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput { | |||
| RequestName: draft.RequestName, | |||
| } | |||
| mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON) | |||
| mergeDraftContextIntoForm(&form, draft.DraftContextJSON) | |||
| return form | |||
| } | |||
| @@ -1234,6 +1252,48 @@ func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) { | |||
| form.AddressCountry = getString(address["country"]) | |||
| } | |||
| func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) { | |||
| if form == nil || len(raw) == 0 { | |||
| return | |||
| } | |||
| var ctx domain.DraftContext | |||
| if err := json.Unmarshal(raw, &ctx); err != nil { | |||
| return | |||
| } | |||
| if strings.TrimSpace(form.BusinessType) == "" { | |||
| form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType) | |||
| } | |||
| form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL) | |||
| form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary) | |||
| form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle) | |||
| form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle) | |||
| form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode) | |||
| form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone) | |||
| form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions) | |||
| } | |||
| func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext { | |||
| businessType := strings.TrimSpace(form.BusinessType) | |||
| if businessType == "" { | |||
| businessType = strings.TrimSpace(getString(globalData["businessType"])) | |||
| } | |||
| return &domain.DraftContext{ | |||
| IntakeSource: strings.TrimSpace(form.DraftSource), | |||
| LLM: domain.DraftLLMContext{ | |||
| BusinessType: businessType, | |||
| WebsiteURL: strings.TrimSpace(form.WebsiteURL), | |||
| WebsiteSummary: strings.TrimSpace(form.WebsiteSummary), | |||
| StyleProfile: domain.DraftStyleProfile{ | |||
| LocaleStyle: strings.TrimSpace(form.LocaleStyle), | |||
| MarketStyle: strings.TrimSpace(form.MarketStyle), | |||
| AddressMode: strings.TrimSpace(form.AddressMode), | |||
| ContentTone: strings.TrimSpace(form.ContentTone), | |||
| PromptInstructions: strings.TrimSpace(form.PromptInstructions), | |||
| }, | |||
| }, | |||
| } | |||
| } | |||
| func getString(v any) string { | |||
| s, _ := v.(string) | |||
| return strings.TrimSpace(s) | |||
| @@ -288,6 +288,7 @@ func (s *Store) GetDraftByID(_ context.Context, id string) (*domain.BuildDraft, | |||
| copy := draft | |||
| copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON) | |||
| copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | |||
| copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | |||
| return ©, nil | |||
| } | |||
| @@ -299,6 +300,7 @@ func (s *Store) ListDrafts(_ context.Context, limit int) ([]domain.BuildDraft, e | |||
| copy := draft | |||
| copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON) | |||
| copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | |||
| copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | |||
| out = append(out, copy) | |||
| } | |||
| sort.Slice(out, func(i, j int) bool { | |||
| @@ -0,0 +1,33 @@ | |||
| PRAGMA foreign_keys=OFF; | |||
| CREATE TABLE IF NOT EXISTS build_drafts_new ( | |||
| id TEXT PRIMARY KEY, | |||
| template_id INTEGER, | |||
| 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, | |||
| draft_context_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 | |||
| ); | |||
| INSERT INTO build_drafts_new ( | |||
| id, template_id, manifest_id, source, request_name, global_data_json, | |||
| field_values_json, draft_context_json, status, notes, created_at, updated_at | |||
| ) | |||
| SELECT | |||
| id, template_id, manifest_id, source, request_name, global_data_json, | |||
| field_values_json, NULL, status, notes, created_at, updated_at | |||
| FROM build_drafts; | |||
| DROP TABLE build_drafts; | |||
| ALTER TABLE build_drafts_new RENAME TO build_drafts; | |||
| CREATE INDEX IF NOT EXISTS idx_drafts_updated_at ON build_drafts(updated_at DESC); | |||
| PRAGMA foreign_keys=ON; | |||
| @@ -452,10 +452,10 @@ 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), | |||
| field_values_json, draft_context_json, status, notes, created_at, updated_at | |||
| ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |||
| draft.ID, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), | |||
| asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano), | |||
| ) | |||
| return err | |||
| } | |||
| @@ -463,10 +463,10 @@ func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error | |||
| 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 = ?, | |||
| SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?, | |||
| status = ?, notes = ?, updated_at = ? | |||
| WHERE id = ?`, | |||
| draft.TemplateID, draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), | |||
| nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), | |||
| draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID, | |||
| ) | |||
| if err != nil { | |||
| @@ -481,7 +481,7 @@ func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error | |||
| 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 | |||
| SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, status, notes, created_at, updated_at | |||
| FROM build_drafts | |||
| WHERE id = ?`, id) | |||
| return scanDraft(row.Scan) | |||
| @@ -489,7 +489,7 @@ func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft | |||
| 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 | |||
| SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, status, notes, created_at, updated_at | |||
| FROM build_drafts | |||
| ORDER BY updated_at DESC` | |||
| args := make([]any, 0, 1) | |||
| @@ -662,20 +662,26 @@ func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) { | |||
| func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) { | |||
| var d domain.BuildDraft | |||
| var templateID sql.NullInt64 | |||
| var globalRaw []byte | |||
| var fieldsRaw []byte | |||
| var draftContextRaw []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, | |||
| &d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw, | |||
| ); err != nil { | |||
| if err == sql.ErrNoRows { | |||
| return nil, store.ErrNotFound | |||
| } | |||
| return nil, err | |||
| } | |||
| if templateID.Valid { | |||
| d.TemplateID = templateID.Int64 | |||
| } | |||
| d.GlobalDataJSON = cloneBytes(globalRaw) | |||
| d.FieldValuesJSON = cloneBytes(fieldsRaw) | |||
| d.DraftContextJSON = cloneBytes(draftContextRaw) | |||
| d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw) | |||
| d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw) | |||
| return &d, nil | |||
| @@ -735,3 +741,10 @@ func parseTimePtr(value sql.NullString) *time.Time { | |||
| } | |||
| return &ts | |||
| } | |||
| func nullableInt64(value int64) any { | |||
| if value <= 0 { | |||
| return nil | |||
| } | |||
| return value | |||
| } | |||
| @@ -65,6 +65,17 @@ | |||
| <div><label>Website-Sprache<input type="text" name="site_language" value="{{.Form.SiteLanguage}}"></label></div> | |||
| </div> | |||
| <h3>Intake / Website-Kontext</h3> | |||
| <div class="grid2"> | |||
| <div><label>Website URL<input type="url" name="website_url" value="{{.Form.WebsiteURL}}" placeholder="https://example.com"></label></div> | |||
| <div><label>Locale Style (z.B. de-CH)<input type="text" name="locale_style" value="{{.Form.LocaleStyle}}" placeholder="de-CH"></label></div> | |||
| <div><label>Market Style (z.B. DACH)<input type="text" name="market_style" value="{{.Form.MarketStyle}}" placeholder="de-CH, de-DE, de-AT"></label></div> | |||
| <div><label>Address Mode<input type="text" name="address_mode" value="{{.Form.AddressMode}}" placeholder="du oder sie"></label></div> | |||
| <div><label>Content Tone<input type="text" name="content_tone" value="{{.Form.ContentTone}}" placeholder="sachlich, freundlich, ..."></label></div> | |||
| </div> | |||
| <div><label>Website Summary<textarea name="website_summary">{{.Form.WebsiteSummary}}</textarea></label></div> | |||
| <div><label>Prompt Instructions<textarea name="prompt_instructions">{{.Form.PromptInstructions}}</textarea></label></div> | |||
| <h3>Kontakt</h3> | |||
| <div class="grid2"> | |||
| <div><label>E-Mail*<input type="email" name="email" value="{{.Form.Email}}" required></label></div> | |||