commit 5bcebf63e4fda1da10b590d32b75d7b53730f5ef Author: Jan Svabenik Date: Thu Mar 26 16:27:33 2026 +0100 Initial QC Text Builder MVP scaffold diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..1119ed6 --- /dev/null +++ b/.env.local @@ -0,0 +1,6 @@ +HTTP_ADDR=:8080 +QC_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1 +QC_TOKEN=6bcb61e064f2f058833a93a45d0505ac +POLL_INTERVAL_SECONDS=5 +POLL_TIMEOUT_SECONDS=300 +POLL_MAX_CONCURRENT=4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8d6ea1 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# QC Text Builder (Go) + +Milestone 2 status: +- bootstrap app/server/config +- Quick Creator client contracts (Bearer token only) +- 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` +- build polling (`POST /api/site-builds/{id}/poll`) and background polling supervisor +- strict MVP scope: no ACP login flow, no DCM/EFL, no image payload handling + +## Run + +1. Set env vars: + - `HTTP_ADDR=:8080` + - `QC_BASE_URL=https://qc-api.yggdrasil.dev-mono.net/api/v1` + - `QC_TOKEN=` +2. Start: + - `go run ./cmd/qctextbuilder` + +## API (Milestone 3) + +- `GET /healthz` +- `POST /api/templates/sync` +- `GET /api/templates` +- `GET /api/templates/{id}` +- `POST /api/templates/{id}/onboard` +- `PUT /api/templates/{id}/fields` +- `POST /api/site-builds` +- `GET /api/site-builds/{id}` +- `POST /api/site-builds/{id}/poll` +- `POST /api/site-builds/{id}/fetch-editor-url` + +Build request payload (`POST /api/site-builds`) expects: +- `templateId` (AI template only, onboarded/reviewed) +- `requestName` +- `globalData` (`companyName`, `email`, `username` required; all other documented fields optional) +- `fieldValues` keyed by manifest paths (`section.keyName`) + +Documented `globalData` scope supported by UI/API mapping: +- `companyName`, `businessType`, `username`, `email`, `phone` +- `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. diff --git a/build-local.ps1 b/build-local.ps1 new file mode 100644 index 0000000..b025be9 --- /dev/null +++ b/build-local.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$envFile = Join-Path $projectRoot '.env.local' +$distDir = Join-Path $projectRoot 'dist' +$outFile = Join-Path $distDir 'qctextbuilder.exe' + +if (-not (Test-Path $envFile)) { + throw "Missing .env.local at $envFile" +} + +Get-Content $envFile | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + + $parts = $line -split '=', 2 + if ($parts.Count -ne 2) { return } + + $name = $parts[0].Trim() + $value = $parts[1] + [System.Environment]::SetEnvironmentVariable($name, $value, 'Process') +} + +if (-not (Test-Path $distDir)) { + New-Item -ItemType Directory -Path $distDir | Out-Null +} + +Set-Location $projectRoot + +Write-Host 'Running go test ./...' -ForegroundColor Cyan +go test ./... + +Write-Host "Building $outFile" -ForegroundColor Green +go build -o $outFile ./cmd/qctextbuilder + +Write-Host "Build complete: $outFile" -ForegroundColor Green diff --git a/cmd/qctextbuilder/main.go b/cmd/qctextbuilder/main.go new file mode 100644 index 0000000..3bd81c9 --- /dev/null +++ b/cmd/qctextbuilder/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "log" + "os/signal" + "syscall" + + "qctextbuilder/internal/app" + "qctextbuilder/internal/config" +) + +func main() { + cfg := config.Load() + + application, err := app.New(cfg) + if err != nil { + log.Fatalf("init app: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := application.Run(ctx); err != nil { + log.Fatalf("run app: %v", err) + } +} diff --git a/concept.json b/concept.json new file mode 100644 index 0000000..e2556f8 --- /dev/null +++ b/concept.json @@ -0,0 +1,1055 @@ +{ + "document_type": "developer_concept", + "language": "de-CH", + "title": "QC Text Builder App - Go-first Implementation Concept", + "version": "1.1.0", + "status": "ready_for_implementation", + "goal": "Eine interne Webanwendung in Go bauen, die Quick Creator ausschliesslich als Website-Builder nutzt und eigene Texte direkt in content.aiData von AI-Templates einspeist.", + "executive_summary": { + "one_sentence": "Baue eine Go-Webapp, die AI-Templates aus Quick Creator synchronisiert, pro Template via Discovery die internen aiData-Keys ermittelt, diese lokal speichert und danach Websites mit vollständig eigenem Text über POST /sites erzeugt.", + "non_negotiables": [ + "Nur AI-Templates", + "Nur Bearer-Token-Auth", + "Keine produktive Nutzung von /generate-content zur Texterzeugung", + "Keine DCM- oder EFL-Integration", + "Keine externen Bilder oder Dateisysteme", + "Nur eigene Texte in content.aiData" + ] + }, + "product_scope": { + "in_scope": [ + "Quick Creator API Client in Go", + "lokaler Template-Katalog", + "Template-Onboarding via /generate-content für Key-Discovery", + "lokale Persistenz von Template-Manifests", + "UI zur Bearbeitung template-spezifischer Textfelder", + "Site-Erstellung via POST /sites", + "Job-Polling via GET /jobs/{jobId}", + "Anzeige von previewUrl und optional editorUrl", + "redaktierte API-Logs", + "Admin-UI für Settings, Templates, Builds" + ], + "out_of_scope": [ + "DCM", + "EFL", + "Media-/Bild-Handling", + "CRM-Integration im MVP", + "nachträgliche Content-Manipulation über HAL/Site API", + "Multi-tenant SaaS für Endkunden", + "vollautomatische semantische Feldzuordnung über alle Templates" + ] + }, + "source_basis": { + "type": "user_provided_docs_only", + "used_inputs": [ + "Quick Creator API OAS-Auszug", + "Quick Creator Schritt-für-Schritt-Anleitung", + "DCM Dokumentation", + "EFL Dokumentation", + "HAL API Objektdefinitionen" + ], + "important_findings": [ + "Auth-Doku ist widersprüchlich; produktiv wird Bearer-Token angenommen.", + "content.aiData ist für AI-Templates vorgesehen.", + "Template-Keys sind template-spezifisch und müssen exakt getroffen werden.", + "Schema-Endpunkt ist nicht verlässlich genug als alleinige Quelle.", + "/generate-content eignet sich als Discovery-Quelle für echte Key-Namen." + ] + }, + "primary_architecture_decisions": { + "backend_language": "Go", + "go_version": "1.24+", + "web_stack": { + "router": "chi", + "rendering": "html/template for MVP", + "frontend_enhancement": "HTMX optional", + "api_style": "hybrid: HTML admin UI + JSON internal endpoints" + }, + "database": { + "production": "PostgreSQL", + "development": "SQLite allowed", + "migration_tool": "goose", + "query_strategy": "sqlc preferred, sqlx acceptable" + }, + "background_jobs": { + "mode": "in-process goroutines for MVP", + "polling": "persistent DB-backed polling states", + "later_upgrade": "separate worker process if needed" + }, + "http_client": { + "library": "net/http", + "timeouts": "strict per-request timeouts", + "retry_logic": "custom retry wrapper" + }, + "config": { + "env_loader": "caarlos0/env or stdlib", + "secrets": "env vars or secret manager", + "token_storage": "encrypted at rest if persisted" + } + }, + "why_go": { + "reasons": [ + "Go passt gut für API-Orchestrierung, Polling und robuste Services.", + "ein einzelnes statisches Binary vereinfacht Deployment massiv", + "geringer Runtime-Overhead", + "klare Typisierung für externe JSON-Kontrakte", + "gute Eignung für Hintergrundjobs und HTTP-Clients" + ], + "tradeoffs": [ + "weniger komfortabel für dynamische Admin-UIs als Python/JS", + "mehr Initialaufwand für Templates/Form-Handling" + ] + }, + "project_layout": { + "root_structure": [ + "cmd/qctextbuilder/main.go", + "internal/app/app.go", + "internal/config/config.go", + "internal/httpserver/server.go", + "internal/httpserver/middleware/", + "internal/httpserver/handlers/", + "internal/httpserver/views/", + "internal/qcclient/client.go", + "internal/qcclient/types.go", + "internal/qcclient/errors.go", + "internal/templatesvc/service.go", + "internal/onboarding/service.go", + "internal/mapping/service.go", + "internal/buildsvc/service.go", + "internal/polling/service.go", + "internal/store/", + "internal/store/postgres/", + "internal/store/sqlite/", + "internal/domain/", + "internal/logging/", + "internal/crypto/", + "internal/validation/", + "migrations/", + "web/templates/", + "web/static/", + "test/integration/" + ], + "design_rules": [ + "Keine Business-Logik in HTTP-Handlern", + "Quick Creator JSON-Typen zentral in qcclient/types.go", + "Store-Interfaces vom Business-Layer entkoppeln", + "Jede externe API-Operation über qcclient kapseln", + "Polling-Logik getrennt vom Build-Service halten" + ] + }, + "package_design": { + "internal/domain": { + "purpose": "interne Kernmodelle unabhängig von DB und HTTP", + "types": [ + "Template", + "TemplateManifest", + "TemplateField", + "SiteBuild", + "AppSettings" + ] + }, + "internal/qcclient": { + "purpose": "alle Quick Creator Requests und Response-Typen", + "responsibilities": [ + "Bearer-Auth setzen", + "Request/Response Logging via Hook", + "Retry/Timeout/Error-Mapping", + "JSON Marshalling/Unmarshalling" + ] + }, + "internal/templatesvc": { + "purpose": "Templates synchronisieren und lokal verwalten" + }, + "internal/onboarding": { + "purpose": "Template Discovery, Manifest-Erstellung, Validierung" + }, + "internal/mapping": { + "purpose": "Roh-Key-Eingaben in finale aiData-Struktur assemblieren" + }, + "internal/buildsvc": { + "purpose": "Sites bauen, Payloads speichern, Polling anstossen" + }, + "internal/polling": { + "purpose": "laufende Job-Abfragen gegen Quick Creator" + }, + "internal/store": { + "purpose": "Persistenz-Abstraktion" + }, + "internal/httpserver/handlers": { + "purpose": "Admin UI und JSON-Endpunkte" + } + }, + "domain_model": { + "Template": { + "fields": { + "ID": "int64", + "Name": "string", + "Description": "string", + "Locale": "string", + "ThumbnailURL": "string", + "TemplatePreviewURL": "string", + "Type": "string", + "PaletteReady": "bool", + "RawJSON": "json.RawMessage", + "IsAITemplate": "bool", + "IsOnboarded": "bool", + "ManifestStatus": "string", + "LastDiscoveredAt": "time.Time|null" + } + }, + "TemplateManifest": { + "fields": { + "ID": "uuid/string", + "TemplateID": "int64", + "Version": "int", + "Source": "string", + "LanguageUsedForDiscovery": "string", + "DiscoveryPayloadJSON": "json.RawMessage", + "DiscoveryResponseJSON": "json.RawMessage", + "FlattenedManifestJSON": "json.RawMessage", + "IsActive": "bool", + "CreatedAt": "time.Time", + "UpdatedAt": "time.Time" + } + }, + "TemplateField": { + "fields": { + "ID": "uuid/string", + "TemplateID": "int64", + "ManifestID": "uuid/string", + "Section": "string", + "KeyName": "string", + "Path": "string", + "FieldKind": "string", + "SampleValue": "string", + "IsEnabled": "bool", + "IsRequiredByUs": "bool", + "DisplayLabel": "string", + "DisplayOrder": "int", + "Notes": "string" + } + }, + "SiteBuild": { + "fields": { + "ID": "uuid/string", + "TemplateID": "int64", + "ManifestID": "uuid/string", + "RequestName": "string", + "GlobalDataJSON": "json.RawMessage", + "AIDataJSON": "json.RawMessage", + "FinalSitesPayloadJSON": "json.RawMessage", + "QCJobID": "int64|null", + "QCSiteID": "int64|null", + "QCStatus": "string", + "QCPreviewURL": "string", + "QCEditorURL": "string", + "QCResultJSON": "json.RawMessage", + "QCErrorJSON": "json.RawMessage", + "StartedAt": "time.Time|null", + "FinishedAt": "time.Time|null" + } + }, + "AppSettings": { + "fields": { + "QCBaseURL": "string", + "QCBearerTokenEncrypted": "string", + "LanguageOutputMode": "string", + "JobPollIntervalSeconds": "int", + "JobPollTimeoutSeconds": "int" + } + } + }, + "database_schema": { + "notes": [ + "PostgreSQL bevorzugt", + "SQLite für lokale Entwicklung zulässig", + "JSONB in PostgreSQL verwenden", + "bei SQLite JSON als TEXT speichern" + ], + "tables": [ + { + "name": "app_settings", + "columns": { + "id": "uuid pk", + "qc_base_url": "text not null", + "qc_bearer_token_encrypted": "text not null", + "language_output_mode": "text not null default 'upper'", + "job_poll_interval_seconds": "integer not null default 5", + "job_poll_timeout_seconds": "integer not null default 300", + "created_at": "timestamp not null", + "updated_at": "timestamp not null" + } + }, + { + "name": "qc_templates", + "columns": { + "id": "bigint pk", + "name": "text not null", + "description": "text not null", + "locale": "text not null", + "thumbnail_url": "text", + "template_preview_url": "text", + "type": "text not null", + "palette_ready": "boolean not null", + "raw_template_json": "json/jsonb not null", + "is_ai_template": "boolean not null", + "is_onboarded": "boolean not null default false", + "manifest_status": "text not null default 'missing'", + "last_discovered_at": "timestamp", + "created_at": "timestamp not null", + "updated_at": "timestamp not null" + } + }, + { + "name": "qc_template_manifests", + "columns": { + "id": "uuid pk", + "template_id": "bigint not null references qc_templates(id)", + "manifest_version": "integer not null", + "source": "text not null", + "language_used_for_discovery": "text not null", + "discovery_payload_json": "json/jsonb not null", + "discovery_response_json": "json/jsonb not null", + "flattened_manifest_json": "json/jsonb not null", + "is_active": "boolean not null default true", + "created_at": "timestamp not null", + "updated_at": "timestamp not null" + } + }, + { + "name": "qc_template_fields", + "columns": { + "id": "uuid pk", + "template_id": "bigint not null references qc_templates(id)", + "manifest_id": "uuid not null references qc_template_manifests(id)", + "section": "text not null", + "key_name": "text not null", + "path": "text not null", + "field_kind": "text not null", + "sample_value": "text", + "is_enabled": "boolean not null default true", + "is_required_by_us": "boolean not null default false", + "display_label": "text", + "display_order": "integer not null default 0", + "notes": "text", + "created_at": "timestamp not null", + "updated_at": "timestamp not null" + }, + "indexes": [ + "unique(template_id, manifest_id, path)" + ] + }, + { + "name": "site_builds", + "columns": { + "id": "uuid pk", + "template_id": "bigint not null references qc_templates(id)", + "manifest_id": "uuid not null references qc_template_manifests(id)", + "request_name": "text not null", + "global_data_json": "json/jsonb not null", + "ai_data_json": "json/jsonb not null", + "final_sites_payload_json": "json/jsonb not null", + "qc_job_id": "bigint", + "qc_site_id": "bigint", + "qc_status": "text not null", + "qc_preview_url": "text", + "qc_editor_url": "text", + "qc_result_json": "json/jsonb", + "qc_error_json": "json/jsonb", + "started_at": "timestamp", + "finished_at": "timestamp", + "created_at": "timestamp not null", + "updated_at": "timestamp not null" + }, + "indexes": [ + "index(qc_status)", + "index(qc_job_id)" + ] + }, + { + "name": "api_logs", + "columns": { + "id": "uuid pk", + "scope": "text not null", + "method": "text not null", + "url": "text not null", + "request_headers_redacted_json": "json/jsonb not null", + "request_body_json": "json/jsonb", + "response_status": "integer", + "response_body_json": "json/jsonb", + "duration_ms": "integer", + "created_at": "timestamp not null" + } + } + ] + }, + "quick_creator_api_contract": { + "base_url": "https://qc-api.yggdrasil.dev-mono.net/api/v1", + "auth": { + "mode": "Bearer Token", + "header": "Authorization: Bearer ", + "mvp_policy": "Nur diesen Auth-Modus implementieren", + "explicitly_not_used": [ + "/app/login" + ] + }, + "endpoints_used": [ + { + "method": "GET", + "path": "/health", + "purpose": "Health/Reachability" + }, + { + "method": "GET", + "path": "/templates?type=ai", + "purpose": "AI-Templates laden" + }, + { + "method": "GET", + "path": "/templates/{templateId}", + "purpose": "Template-Details" + }, + { + "method": "GET", + "path": "/templates/{templateId}/schema", + "purpose": "optionale Zusatzdaten, nicht Quelle der Wahrheit" + }, + { + "method": "POST", + "path": "/generate-content", + "purpose": "nur Discovery / Key-Extraktion" + }, + { + "method": "POST", + "path": "/sites", + "purpose": "produktive Site-Erstellung" + }, + { + "method": "GET", + "path": "/jobs/{jobId}", + "purpose": "Polling / previewUrl" + }, + { + "method": "GET", + "path": "/sites/{siteId}/editor-url", + "purpose": "optional editor login url" + } + ] + }, + "go_types_qcclient": { + "notes": [ + "Diese Typen in internal/qcclient/types.go definieren", + "Externe Response-Envelope immer als eigenes Typmodell abbilden" + ], + "types": { + "APIResponse": "type APIResponse[T any] struct { Status string `json:\"status\"`; Data T `json:\"data\"` }", + "APIError": "type APIError struct { Status string `json:\"status\"`; Message string `json:\"message\"`; Code string `json:\"code\"`; Details map[string]any `json:\"details,omitempty\"` }", + "Template": "type Template struct { ID int64 `json:\"id\"`; Name string `json:\"name\"`; Description string `json:\"description\"`; Locale string `json:\"locale\"`; ThumbnailURL string `json:\"thumbnailUrl\"`; TemplatePreviewURL string `json:\"templatePreviewUrl\"`; Type string `json:\"type\"`; PaletteReady bool `json:\"paletteReady\"`; GlobalColors map[string]any `json:\"globalColors\"`; TextsWithGlobalColors map[string]any `json:\"textsWithGlobalColors\"`; TemplateHeadings []TemplateHeading `json:\"templateHeadings,omitempty\"` }", + "TemplateHeading": "type TemplateHeading struct { TranslationKey string `json:\"translationKey\"` }", + "GenerateContentRequest": "type GenerateContentRequest struct { TemplateID int64 `json:\"templateId\"`; GlobalData map[string]any `json:\"globalData\"`; Empty bool `json:\"empty\"`; ToneOfVoice string `json:\"toneOfVoice,omitempty\"`; TargetAudience string `json:\"targetAudience,omitempty\"`; CustomTemplateData map[string]any `json:\"customTemplateData,omitempty\"` }", + "GenerateContentData": "type GenerateContentData map[string]map[string]any", + "CreateSiteRequest": "type CreateSiteRequest struct { TemplateID int64 `json:\"templateId\"`; GlobalData map[string]any `json:\"globalData\"`; Content CreateSiteContent `json:\"content\"` }", + "CreateSiteContent": "type CreateSiteContent struct { AIData map[string]map[string]any `json:\"aiData\"` }", + "CreateSiteResponseData": "type CreateSiteResponseData struct { Status string `json:\"status\"`; JobID int64 `json:\"jobId\"` }", + "JobStatusResult": "type JobStatusResult struct { SiteID int64 `json:\"siteId\"`; PreviewURL string `json:\"previewUrl\"` }", + "JobStatusData": "type JobStatusData struct { JobID int64 `json:\"jobId\"`; Status string `json:\"status\"`; CreatedAt int64 `json:\"createdAt\"`; Result JobStatusResult `json:\"result\"` }", + "SiteEditorLoginData": "type SiteEditorLoginData struct { LoginURL string `json:\"loginUrl\"` }" + } + }, + "qcclient_interface_design": { + "interface": "type Client interface { Health(ctx context.Context) error; ListAITemplates(ctx context.Context) ([]Template, error); GetTemplate(ctx context.Context, templateID int64) (*Template, error); GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error); GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error); CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error); GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error); GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) }", + "implementation_rules": [ + "Request body und Response body für Logs separat puffern", + "Authorization-Header in Logs immer redaktieren", + "400/401/404 nicht retrien", + "429/5xx mit Exponential Backoff retrien", + "Fehlerantworten in APIError zu mappen versuchen, sonst Raw Body behalten" + ] + }, + "template_onboarding_strategy": { + "summary": "Pro AI-Template einmalige Discovery durchführen und Feldmanifest lokal speichern.", + "steps": [ + "1. GET /templates?type=ai synchronisieren.", + "2. Ein Template auswählen.", + "3. Optional /templates/{templateId} und /templates/{templateId}/schema speichern.", + "4. Discovery via POST /generate-content mit Dummy-Daten ausführen.", + "5. Response data rekursiv flatten und section/key/path extrahieren.", + "6. Felder mit sample_value '#IMAGE#' oder ähnlichem als image markieren und standardmässig deaktivieren.", + "7. Textfelder als enabled markieren.", + "8. Manifest und Fields in DB speichern.", + "9. UI-Review zulassen: Labels, Reihenfolge, Aktivierung anpassen.", + "10. Validierungs-Build mit Minimaltexten fahren.", + "11. Bei Erfolg Manifeststatus = validated." + ], + "discovery_payload_default": { + "templateId": 1378062, + "globalData": { + "companyName": "Discovery Company", + "businessType": "dentist", + "siteLanguage": "EN", + "email": "discovery@example.com", + "phone": "+41 44 000 00 00", + "address": { + "line1": "Discovery Street 1", + "line2": "", + "city": "Zurich", + "region": "ZH", + "postalCode": "8000", + "country": "CH" + } + }, + "empty": false, + "toneOfVoice": "Professional", + "targetAudience": "B2B" + }, + "flattening_algorithm": { + "goal": "Aus GenerateContentData ein persistierbares Feldmanifest bauen", + "pseudocode": [ + "für jede section im top-level object", + " für jeden key/value in section object", + " path = section + '.' + key", + " sample = stringify(value)", + " field_kind = detectFieldKind(sample)", + " persist field" + ], + "field_kind_rules": [ + "sample == '#IMAGE#' => image", + "string => text", + "sonst => unknown" + ] + } + }, + "mapping_service_design": { + "summary": "Im MVP wird raw-key-mode verwendet: interne App arbeitet direkt mit template-spezifischen Roh-Keys.", + "interface": "type Service interface { AssembleAIData(fields []TemplateField, values map[string]string) (map[string]map[string]any, error) }", + "rules": [ + "Nur aktivierte Felder berücksichtigen", + "Nur bekannte Paths akzeptieren", + "Leere Strings standardmässig weglassen", + "image/unknown-Felder im MVP nicht senden", + "sections nur senden, wenn mindestens ein Feld vorhanden ist" + ], + "input_example": { + "fieldValues": { + "text.textTitle_m1820_149": "Zahnarztpraxis Muster in Zürich", + "text.textDescription_c3825_151": "Moderne Zahnmedizin mit persönlicher Betreuung.", + "services.servicesTitle_r4694_154": "Unsere Leistungen", + "services.servicesDescription_r4694_155": "Kontrollen, Dentalhygiene und ästhetische Zahnmedizin." + } + }, + "output_example": { + "text": { + "textTitle_m1820_149": "Zahnarztpraxis Muster in Zürich", + "textDescription_c3825_151": "Moderne Zahnmedizin mit persönlicher Betreuung." + }, + "services": { + "servicesTitle_r4694_154": "Unsere Leistungen", + "servicesDescription_r4694_155": "Kontrollen, Dentalhygiene und ästhetische Zahnmedizin." + } + } + }, + "site_build_strategy": { + "summary": "Produktiv wird nur POST /sites mit eigener aiData verwendet.", + "rules": [ + "Template muss AI sein", + "validiertes oder mindestens reviewed Manifest muss existieren", + "globalData.companyName und globalData.email sind Pflicht", + "globalData.username explizit setzen", + "accountId weglassen", + "colorPalette weglassen", + "content.files weglassen", + "nur textliche aiData senden" + ], + "final_payload_example": { + "templateId": 1378062, + "globalData": { + "companyName": "Muster AG", + "businessType": "dentist", + "username": "muster-ag-zuerich", + "email": "info@muster.ch", + "phone": "+41 44 123 45 67", + "siteLanguage": "DE", + "address": { + "line1": "Bahnhofstrasse 1", + "line2": "", + "city": "Zuerich", + "region": "ZH", + "zip": "8001", + "country": "CH" + } + }, + "content": { + "aiData": { + "text": { + "textTitle_m1820_149": "Zahnarztpraxis Muster in Zürich", + "textDescription_c3825_151": "Moderne Zahnmedizin mit persönlicher Betreuung im Herzen von Zürich." + }, + "services": { + "servicesTitle_r4694_154": "Unsere Leistungen", + "servicesDescription_r4694_155": "Kontrollen, Dentalhygiene und ästhetische Zahnmedizin." + }, + "testimonials": { + "testimonialsName_c3006_153": "Sabine M.", + "testimonialsDescription_c3006_152": "Freundlich, professionell und sehr angenehm." + } + } + } + } + }, + "build_service_design": { + "interface": "type Service interface { StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error); PollOnce(ctx context.Context, buildID string) error; FetchEditorURL(ctx context.Context, buildID string) error }", + "start_build_flow": [ + "1. Template laden", + "2. aktives Manifest laden", + "3. Input validieren", + "4. aiData via mapping service assemblieren", + "5. finales /sites Payload erzeugen", + "6. SiteBuild Datensatz im Status draft oder queued anlegen", + "7. POST /sites ausführen", + "8. jobId speichern und Status queued setzen", + "9. Polling-Worker triggern", + "10. BuildResult an UI zurückgeben" + ], + "StartBuildRequest_type": { + "fields": { + "TemplateID": "int64", + "RequestName": "string", + "GlobalData": "map[string]any", + "FieldValues": "map[string]string" + } + }, + "BuildResult_type": { + "fields": { + "BuildID": "string", + "QCJobID": "int64", + "Status": "string" + } + } + }, + "polling_service_design": { + "summary": "DB-backed Polling mit goroutines, aber ohne separate Queue im MVP.", + "worker_model": { + "mode": "ticker-based supervisor + per-build poll execution", + "default_interval_seconds": 5, + "default_timeout_seconds": 300 + }, + "algorithm": [ + "1. Supervisor sucht regelmässig site_builds mit qc_status IN ('queued','processing').", + "2. Für jeden Build PollOnce ausführen.", + "3. GET /jobs/{jobId} aufrufen.", + "4. Wenn result status done => previewUrl/siteId speichern, finished_at setzen, qc_status=done.", + "5. Wenn externer Fehler => qc_error_json speichern und qc_status=failed.", + "6. Wenn Timeout überschritten => qc_status=timeout." + ], + "concurrency_rules": [ + "Nicht denselben Build parallel pollen", + "optimistic locking oder advisory flag verwenden", + "max concurrent polls konfigurierbar" + ] + }, + "internal_http_api": { + "json_endpoints": [ + { + "method": "POST", + "path": "/api/settings/test-qc-connection", + "purpose": "Health/Templates-Test" + }, + { + "method": "POST", + "path": "/api/templates/sync", + "purpose": "AI-Templates synchronisieren" + }, + { + "method": "GET", + "path": "/api/templates", + "purpose": "lokale Templates" + }, + { + "method": "GET", + "path": "/api/templates/{id}", + "purpose": "Template inkl. Manifest und Fields" + }, + { + "method": "POST", + "path": "/api/templates/{id}/onboard", + "purpose": "Discovery ausführen" + }, + { + "method": "POST", + "path": "/api/templates/{id}/validate", + "purpose": "Minimaler Test-Build" + }, + { + "method": "PUT", + "path": "/api/templates/{id}/fields", + "purpose": "Field-Manifest anpassen" + }, + { + "method": "POST", + "path": "/api/site-builds", + "purpose": "Build starten" + }, + { + "method": "GET", + "path": "/api/site-builds/{id}", + "purpose": "Build-Details" + }, + { + "method": "POST", + "path": "/api/site-builds/{id}/poll", + "purpose": "manuelles Polling" + }, + { + "method": "POST", + "path": "/api/site-builds/{id}/fetch-editor-url", + "purpose": "optionale editorUrl laden" + } + ], + "html_routes": [ + "/settings", + "/templates", + "/templates/{id}", + "/builds/new", + "/builds/{id}" + ] + }, + "ui_specification": { + "mvp_style": "server rendered Go templates with optional HTMX partial updates", + "screens": [ + { + "name": "Settings", + "features": [ + "QC Base URL", + "Bearer Token speichern", + "Connection Test", + "Polling-Intervall konfigurieren", + "Timeout konfigurieren", + "Language Output Mode konfigurieren" + ] + }, + { + "name": "Templates", + "features": [ + "AI-Templates listen", + "Onboarding-Status anzeigen", + "Template Preview URL anzeigen", + "Sync-Button" + ] + }, + { + "name": "Template Detail", + "features": [ + "Raw Template JSON", + "Schema Raw JSON optional", + "Discovery starten", + "Felder anzeigen", + "Felder aktiv/deaktivieren", + "DisplayLabel und Reihenfolge pflegen", + "Manifest-Status" + ] + }, + { + "name": "New Build", + "features": [ + "Template auswählen", + "GlobalData Formular", + "alle aktivierten Textfelder als Formular rendern", + "finales aiData JSON vorab anzeigen", + "finales /sites Payload anzeigen", + "Build starten" + ] + }, + { + "name": "Build Detail", + "features": [ + "Build-Status", + "QC jobId", + "QC siteId", + "previewUrl", + "editorUrl optional", + "Request-/Response-Snapshots", + "Fehlerdetails" + ] + } + ] + }, + "validation_rules": { + "before_onboarding": [ + "QC Token vorhanden", + "Template existiert lokal oder kann geladen werden", + "Template.Type == 'AI'" + ], + "before_build": [ + "Template ist AI", + "aktives Manifest vorhanden", + "Manifeststatus in reviewed oder validated", + "companyName gesetzt", + "email gesetzt", + "username gesetzt", + "mindestens ein aktiver Textwert vorhanden" + ], + "field_rules": [ + "Unbekannte fieldValues-Keys ablehnen", + "deaktivierte Felder ignorieren oder ablehnen", + "nur field_kind=text im MVP senden", + "Leere Strings standardmässig nicht senden", + "nil/null nicht an Quick Creator senden" + ], + "language_rules": [ + "intern Sprache als uppercase 2-letter speichern", + "outgoing default uppercase", + "bei BAD_REQUEST optional 1 lower-case Retry" + ] + }, + "logging_and_observability": { + "logging": [ + "Structured JSON Logs", + "jede externe QC-Request mit redaktierten Headers loggen", + "jede externe QC-Response mit Status und Dauer loggen", + "Build-Zustandswechsel loggen", + "Discovery-Läufe vollständig loggen" + ], + "metrics": [ + "qc_requests_total", + "qc_request_duration_ms", + "template_sync_total", + "template_onboard_total", + "template_onboard_failed_total", + "site_build_started_total", + "site_build_done_total", + "site_build_failed_total", + "site_build_timeout_total" + ], + "redaction_rules": [ + "Authorization Header nie im Klartext loggen", + "Tokens in UI nie anzeigen", + "sensible Felder in Snapshots optional maskieren" + ] + }, + "security": { + "requirements": [ + "Token verschlüsselt speichern, z. B. AES-GCM mit App Key aus ENV", + "Admin-Zugriff absichern", + "CSRF-Schutz falls server-rendered Form-POSTs", + "Secure Cookies bei Session-basierter Admin-Auth", + "Role-based access für Settings und Logs" + ] + }, + "implementation_details_go": { + "config_struct": "type Config struct { HTTPAddr string `env:\"HTTP_ADDR\" envDefault\":8080\"`; DBDriver string `env:\"DB_DRIVER\" envDefault:\"postgres\"`; DBURL string `env:\"DB_URL,required\"`; AppSecret string `env:\"APP_SECRET,required\"`; QCBaseURL string `env:\"QC_BASE_URL\" envDefault:\"https://qc-api.yggdrasil.dev-mono.net/api/v1\"`; QCToken string `env:\"QC_TOKEN\"`; PollIntervalSeconds int `env:\"POLL_INTERVAL_SECONDS\" envDefault:\"5\"`; PollTimeoutSeconds int `env:\"POLL_TIMEOUT_SECONDS\" envDefault:\"300\"` }", + "http_server_setup": [ + "chi.NewRouter()", + "request id middleware", + "recoverer middleware", + "structured logger middleware", + "timeout middleware" + ], + "store_interfaces": { + "TemplateStore": "UpsertTemplates, GetTemplateByID, ListTemplates, SetTemplateManifestStatus", + "ManifestStore": "CreateManifest, GetActiveManifestByTemplateID, ReplaceFields, ListFieldsByManifestID, UpdateFieldSettings", + "BuildStore": "CreateBuild, UpdateBuildQueued, UpdateBuildDone, UpdateBuildFailed, GetBuildByID, ListActiveBuilds", + "SettingsStore": "GetSettings, SaveSettings" + }, + "error_style": { + "rule": "wrap errors with context using fmt.Errorf(\"...: %w\", err)", + "typed_errors": [ + "ErrUnauthorized", + "ErrBadRequest", + "ErrNotFound", + "ErrInvalidTemplateType", + "ErrManifestMissing", + "ErrBuildTimeout" + ] + } + }, + "testing_strategy": { + "unit_tests": [ + "Flatten GenerateContentData zu Fields", + "AssembleAIData mit enabled/disabled fields", + "Validation von fieldValues", + "Sprache-Normalisierung", + "Error-Mapping von QC-Responses" + ], + "integration_tests": [ + "ListAITemplates gegen echte Dev-API", + "Onboarding eines echten AI-Templates", + "CreateSite mit Minimalpayload", + "Polling bis done", + "Fetch editorUrl optional" + ], + "manual_tests": [ + "Settings speichern und Verbindung testen", + "Templates synchronisieren", + "Template onboarden", + "Labels im UI anpassen", + "Build mit eigenem Text starten", + "Preview öffnen und Texte visuell prüfen" + ] + }, + "acceptance_criteria": { + "must_have": [ + "Die Go-App kompiliert zu einem deploybaren Binary.", + "Die App kann mit Bearer-Token AI-Templates synchronisieren.", + "Die App kann ein AI-Template onboarden und die aiData-Key-Struktur speichern.", + "Die App kann eigene Texte auf gespeicherte Template-Keys mappen.", + "Die App kann eine Site über POST /sites bauen.", + "Die App kann den Jobstatus pollen und previewUrl anzeigen.", + "Alle QC-Requests/Responses werden redaktiert protokolliert.", + "Es wird kein DCM/EFL verwendet." + ], + "must_not": [ + "Kein produktiver Einsatz von /generate-content zur Texterzeugung", + "Keine Standard-Templates", + "Keine Bild-/Media-Integration", + "Keine unbekannten Keys im finalen aiData" + ] + }, + "implementation_plan": [ + { + "phase": 1, + "name": "Bootstrap", + "tasks": [ + "Go-Modul anlegen", + "Config laden", + "DB initialisieren", + "Migrationsstruktur anlegen", + "HTTP-Server Grundgerüst bauen" + ] + }, + { + "phase": 2, + "name": "QC Client", + "tasks": [ + "HTTP Client Wrapper bauen", + "Response Envelope-Typen bauen", + "Health/ListTemplates/GetTemplate/GetSchema implementieren", + "Logging/Retry/Error-Mapping implementieren" + ] + }, + { + "phase": 3, + "name": "Template Catalog & Onboarding", + "tasks": [ + "Template Sync Service bauen", + "Discovery via /generate-content bauen", + "Flattening und Manifest-Persistenz bauen", + "Template-Detail-UI bauen" + ] + }, + { + "phase": 4, + "name": "Build Flow", + "tasks": [ + "Mapping Service bauen", + "Build Form bauen", + "POST /sites integrieren", + "SiteBuild Persistenz bauen" + ] + }, + { + "phase": 5, + "name": "Polling & Detail View", + "tasks": [ + "Polling Worker bauen", + "Build Detail View bauen", + "previewUrl/editorUrl Handling ergänzen" + ] + }, + { + "phase": 6, + "name": "Härtung", + "tasks": [ + "Token-Verschlüsselung", + "CSRF/Auth falls nötig", + "Retry/Fallback Feinschliff", + "Tests und Fehlerbilder" + ] + } + ], + "prohibitions": [ + "Nicht /app/login als produktiven Auth-Pfad implementieren.", + "Nicht Standard-Templates zulassen.", + "Nicht DCM/EFL mit reinmischen.", + "Nicht unbekannte oder deaktivierte Keys an Quick Creator senden.", + "Nicht image-Felder im MVP befüllen.", + "Nicht Geschäftslogik in Handlern ablegen.", + "Nicht direkt aus UI gegen Quick Creator callen; immer über Service-Layer." + ], + "safe_defaults": { + "auth": "Bearer Token only", + "language": "uppercase on first try", + "manifest_source_of_truth": "/generate-content response", + "empty_values": "skip", + "images": "disabled", + "poll_interval_seconds": 5, + "poll_timeout_seconds": 300 + }, + "example_build_request_to_our_app": { + "templateId": 1378062, + "requestName": "Muster AG Zürich", + "globalData": { + "companyName": "Muster AG", + "businessType": "dentist", + "username": "muster-ag-zuerich", + "email": "info@muster.ch", + "phone": "+41 44 123 45 67", + "siteLanguage": "DE", + "address": { + "line1": "Bahnhofstrasse 1", + "line2": "", + "city": "Zuerich", + "region": "ZH", + "zip": "8001", + "country": "CH" + } + }, + "fieldValues": { + "text.textTitle_m1820_149": "Zahnarztpraxis Muster in Zürich", + "text.textDescription_c3825_151": "Moderne Zahnmedizin mit persönlicher Betreuung im Herzen von Zürich.", + "services.servicesTitle_r4694_154": "Unsere Leistungen", + "services.servicesDescription_r4694_155": "Kontrollen, Dentalhygiene und ästhetische Zahnmedizin.", + "testimonials.testimonialsName_c3006_153": "Sabine M.", + "testimonials.testimonialsDescription_c3006_152": "Freundlich, professionell und sehr angenehm." + } + }, + "example_final_qc_payload": { + "templateId": 1378062, + "globalData": { + "companyName": "Muster AG", + "businessType": "dentist", + "username": "muster-ag-zuerich", + "email": "info@muster.ch", + "phone": "+41 44 123 45 67", + "siteLanguage": "DE", + "address": { + "line1": "Bahnhofstrasse 1", + "line2": "", + "city": "Zuerich", + "region": "ZH", + "zip": "8001", + "country": "CH" + } + }, + "content": { + "aiData": { + "text": { + "textTitle_m1820_149": "Zahnarztpraxis Muster in Zürich", + "textDescription_c3825_151": "Moderne Zahnmedizin mit persönlicher Betreuung im Herzen von Zürich." + }, + "services": { + "servicesTitle_r4694_154": "Unsere Leistungen", + "servicesDescription_r4694_155": "Kontrollen, Dentalhygiene und ästhetische Zahnmedizin." + }, + "testimonials": { + "testimonialsName_c3006_153": "Sabine M.", + "testimonialsDescription_c3006_152": "Freundlich, professionell und sehr angenehm." + } + } + } + }, + "developer_summary": { + "instruction": "Implementiere die App vollständig in Go mit chi, sqlc/goose und einem klar getrennten Service-Layer. Verwende Quick Creator nur für AI-Template-Sync, Discovery der aiData-Key-Struktur und anschliessend Site-Builds mit eigenem Text über POST /sites.", + "first_milestone": "Template Sync + Onboarding eines AI-Templates + gespeichertes Field-Manifest", + "second_milestone": "Site-Build mit eigenem Text + Job-Polling + previewUrl" + } +} diff --git a/dist/qctextbuilder.exe b/dist/qctextbuilder.exe new file mode 100644 index 0000000..f73c024 Binary files /dev/null and b/dist/qctextbuilder.exe differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..389505a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module qctextbuilder + +go 1.24 + +require github.com/go-chi/chi/v5 v5.2.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5bd7be3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..d647a85 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "qctextbuilder/internal/buildsvc" + "qctextbuilder/internal/config" + "qctextbuilder/internal/httpserver" + "qctextbuilder/internal/httpserver/handlers" + "qctextbuilder/internal/httpserver/views" + "qctextbuilder/internal/logging" + "qctextbuilder/internal/mapping" + "qctextbuilder/internal/onboarding" + "qctextbuilder/internal/polling" + "qctextbuilder/internal/qcclient" + "qctextbuilder/internal/store/memory" + "qctextbuilder/internal/templatesvc" +) + +type App struct { + server *httpserver.Server + pollingSvc *polling.Service +} + +func New(cfg config.Config) (*App, error) { + logger := logging.New() + memStore := memory.New() + + qc := qcclient.New(cfg.QCBaseURL, cfg.QCToken, 15*time.Second, logger) + templateSvc := templatesvc.New(qc, memStore, memStore) + onboardSvc := onboarding.New(qc, memStore, memStore) + 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) + + 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) + + server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { + r.Get("/", ui.Home) + r.Get("/settings", ui.Settings) + r.Get("/templates", ui.Templates) + r.Post("/templates/sync", ui.SyncTemplates) + r.Get("/templates/{id}", ui.TemplateDetail) + r.Post("/templates/{id}/onboard", ui.OnboardTemplate) + r.Post("/templates/{id}/fields", ui.UpdateTemplateFields) + r.Get("/builds/new", ui.BuildNew) + r.Post("/builds", ui.CreateBuild) + r.Get("/builds/{id}", ui.BuildDetail) + r.Post("/builds/{id}/poll", ui.PollBuild) + r.Post("/builds/{id}/fetch-editor-url", ui.FetchEditorURL) + + r.Get("/healthz", api.Health) + r.Route("/api", func(r chi.Router) { + r.Post("/templates/sync", api.SyncTemplates) + r.Get("/templates", api.ListTemplates) + r.Get("/templates/{id}", api.GetTemplateDetail) + r.Post("/templates/{id}/onboard", api.OnboardTemplate) + r.Put("/templates/{id}/fields", api.UpdateTemplateFields) + r.Post("/site-builds", api.StartBuild) + r.Get("/site-builds/{id}", api.GetBuild) + r.Post("/site-builds/{id}/poll", api.PollBuildOnce) + r.Post("/site-builds/{id}/fetch-editor-url", api.FetchBuildEditorURL) + }) + }) + + return &App{server: server, pollingSvc: pollingSvc}, nil +} + +func (a *App) Run(ctx context.Context) error { + go func() { + if err := a.pollingSvc.Start(ctx); err != nil { + // polling is best-effort in milestone-2; request flow works without supervisor + } + }() + + errCh := make(chan error, 1) + go func() { + if err := a.server.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("http run: %w", err) + } + close(errCh) + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return a.server.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} diff --git a/internal/buildsvc/global_data.go b/internal/buildsvc/global_data.go new file mode 100644 index 0000000..e4320d5 --- /dev/null +++ b/internal/buildsvc/global_data.go @@ -0,0 +1,103 @@ +package buildsvc + +import "strings" + +type GlobalDataInput struct { + 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 +} + +func BuildGlobalData(input GlobalDataInput) map[string]any { + globalData := map[string]any{} + + setIfNotEmpty(globalData, "companyName", input.CompanyName) + setIfNotEmpty(globalData, "businessType", input.BusinessType) + setIfNotEmpty(globalData, "username", input.Username) + setIfNotEmpty(globalData, "email", input.Email) + setIfNotEmpty(globalData, "phone", input.Phone) + setIfNotEmpty(globalData, "orgNumber", input.OrgNumber) + setIfNotEmpty(globalData, "startDate", input.StartDate) + setIfNotEmpty(globalData, "mission", input.Mission) + setIfNotEmpty(globalData, "descriptionShort", input.DescriptionShort) + setIfNotEmpty(globalData, "descriptionLong", input.DescriptionLong) + setIfNotEmpty(globalData, "siteLanguage", input.SiteLanguage) + + address := map[string]any{} + setIfNotEmpty(address, "line1", input.AddressLine1) + setIfNotEmpty(address, "line2", input.AddressLine2) + setIfNotEmpty(address, "city", input.AddressCity) + setIfNotEmpty(address, "region", input.AddressRegion) + setIfNotEmpty(address, "zip", input.AddressZIP) + setIfNotEmpty(address, "country", input.AddressCountry) + if len(address) > 0 { + globalData["address"] = address + } + + return globalData +} + +func FilterGlobalData(input map[string]any) map[string]any { + if len(input) == 0 { + return map[string]any{} + } + + globalData := map[string]any{} + setStringIfPresent(globalData, "companyName", input["companyName"]) + setStringIfPresent(globalData, "businessType", input["businessType"]) + setStringIfPresent(globalData, "username", input["username"]) + setStringIfPresent(globalData, "email", input["email"]) + setStringIfPresent(globalData, "phone", input["phone"]) + setStringIfPresent(globalData, "orgNumber", input["orgNumber"]) + setStringIfPresent(globalData, "startDate", input["startDate"]) + setStringIfPresent(globalData, "mission", input["mission"]) + setStringIfPresent(globalData, "descriptionShort", input["descriptionShort"]) + setStringIfPresent(globalData, "descriptionLong", input["descriptionLong"]) + setStringIfPresent(globalData, "siteLanguage", input["siteLanguage"]) + + address := map[string]any{} + if rawAddress, ok := input["address"]; ok { + if sourceAddress, ok := rawAddress.(map[string]any); ok { + setStringIfPresent(address, "line1", sourceAddress["line1"]) + setStringIfPresent(address, "line2", sourceAddress["line2"]) + setStringIfPresent(address, "city", sourceAddress["city"]) + setStringIfPresent(address, "region", sourceAddress["region"]) + setStringIfPresent(address, "zip", sourceAddress["zip"]) + setStringIfPresent(address, "country", sourceAddress["country"]) + } + } + if len(address) > 0 { + globalData["address"] = address + } + + return globalData +} + +func setIfNotEmpty(m map[string]any, key, value string) { + if strings.TrimSpace(value) == "" { + return + } + m[key] = strings.TrimSpace(value) +} + +func setStringIfPresent(target map[string]any, key string, raw any) { + value, ok := raw.(string) + if !ok { + return + } + setIfNotEmpty(target, key, value) +} diff --git a/internal/buildsvc/global_data_test.go b/internal/buildsvc/global_data_test.go new file mode 100644 index 0000000..8d29b86 --- /dev/null +++ b/internal/buildsvc/global_data_test.go @@ -0,0 +1,132 @@ +package buildsvc + +import "testing" + +func TestBuildGlobalData_OptionalFieldsAreOmittedWhenEmpty(t *testing.T) { + got := BuildGlobalData(GlobalDataInput{ + CompanyName: "Acme AG", + Email: "hello@acme.test", + Username: "acme-admin", + AddressLine1: " ", + }) + + if got["companyName"] != "Acme AG" { + t.Fatalf("companyName mismatch: got=%v", got["companyName"]) + } + if got["email"] != "hello@acme.test" { + t.Fatalf("email mismatch: got=%v", got["email"]) + } + if got["username"] != "acme-admin" { + t.Fatalf("username mismatch: got=%v", got["username"]) + } + if _, ok := got["phone"]; ok { + t.Fatalf("phone should be omitted when empty") + } + if _, ok := got["address"]; ok { + t.Fatalf("address should be omitted when empty") + } +} + +func TestBuildGlobalData_AddressAndOptionalFields(t *testing.T) { + got := BuildGlobalData(GlobalDataInput{ + CompanyName: "Acme AG", + BusinessType: "Healthcare", + Username: "acme-admin", + Email: "hello@acme.test", + Phone: "+41 79 000 00 00", + OrgNumber: "CHE-123.456.789", + StartDate: "2019-08-01", + Mission: "Make dental care simple.", + DescriptionShort: "Modern clinic.", + DescriptionLong: "Full-service clinic in central Zurich.", + SiteLanguage: "de", + AddressLine1: "Main Street 1", + AddressLine2: "2nd Floor", + AddressCity: "Zurich", + AddressRegion: "ZH", + AddressZIP: "8000", + AddressCountry: "Switzerland", + }) + + addressRaw, ok := got["address"] + if !ok { + t.Fatalf("address should be present") + } + address, ok := addressRaw.(map[string]any) + if !ok { + t.Fatalf("address type mismatch: %T", addressRaw) + } + if address["line1"] != "Main Street 1" { + t.Fatalf("line1 mismatch: got=%v", address["line1"]) + } + if address["line2"] != "2nd Floor" { + t.Fatalf("line2 mismatch: got=%v", address["line2"]) + } + if address["region"] != "ZH" { + t.Fatalf("region mismatch: got=%v", address["region"]) + } + if got["businessType"] != "Healthcare" { + t.Fatalf("businessType mismatch: got=%v", got["businessType"]) + } + if got["phone"] != "+41 79 000 00 00" { + t.Fatalf("phone mismatch: got=%v", got["phone"]) + } + if got["siteLanguage"] != "de" { + t.Fatalf("siteLanguage mismatch: got=%v", got["siteLanguage"]) + } + if _, ok := got["business_category"]; ok { + t.Fatalf("business_category must not be present") + } +} + +func TestFilterGlobalData_RemovesUnsupportedKeys(t *testing.T) { + got := FilterGlobalData(map[string]any{ + "companyName": " Acme AG ", + "username": " admin ", + "email": " hi@example.test ", + "mobile": "+41 79 000 00 00", + "business_category": "Healthcare", + "siteLanguage": " de ", + "address": map[string]any{ + "line1": " Main Street 1 ", + "line2": "", + "city": " Zurich ", + "region": " ZH ", + "zip": " 8000 ", + "country": " Switzerland ", + "country_code": "CH", + "street": "Unsupported Street Key", + }, + }) + + if got["companyName"] != "Acme AG" { + t.Fatalf("companyName mismatch: got=%v", got["companyName"]) + } + if got["siteLanguage"] != "de" { + t.Fatalf("siteLanguage mismatch: got=%v", got["siteLanguage"]) + } + if _, ok := got["mobile"]; ok { + t.Fatalf("mobile must not be present") + } + if _, ok := got["business_category"]; ok { + t.Fatalf("business_category must not be present") + } + + addressRaw, ok := got["address"] + if !ok { + t.Fatalf("address should be present") + } + address, ok := addressRaw.(map[string]any) + if !ok { + t.Fatalf("address type mismatch: %T", addressRaw) + } + if address["line1"] != "Main Street 1" { + t.Fatalf("line1 mismatch: got=%v", address["line1"]) + } + if _, ok := address["country_code"]; ok { + t.Fatalf("address.country_code must not be present") + } + if _, ok := address["street"]; ok { + t.Fatalf("address.street must not be present") + } +} diff --git a/internal/buildsvc/service.go b/internal/buildsvc/service.go new file mode 100644 index 0000000..626b06a --- /dev/null +++ b/internal/buildsvc/service.go @@ -0,0 +1,329 @@ +package buildsvc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "qctextbuilder/internal/domain" + "qctextbuilder/internal/mapping" + "qctextbuilder/internal/qcclient" + "qctextbuilder/internal/store" + "qctextbuilder/internal/validation" +) + +type StartBuildRequest struct { + TemplateID int64 `json:"templateId"` + RequestName string `json:"requestName"` + GlobalData map[string]any `json:"globalData"` + FieldValues map[string]string `json:"fieldValues"` +} + +type BuildResult struct { + BuildID string `json:"buildId"` + QCJobID int64 `json:"qcJobId"` + Status string `json:"status"` +} + +type Service interface { + StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) + PollOnce(ctx context.Context, buildID string) error + FetchEditorURL(ctx context.Context, buildID string) error + GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) +} + +type BuildService struct { + qc qcclient.Client + templateStore store.TemplateStore + manifestStore store.ManifestStore + buildStore store.BuildStore + mapping mapping.Service + pollTimeout time.Duration + mu sync.Mutex + inFlightPolls map[string]struct{} +} + +func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore, buildStore store.BuildStore, mappingSvc mapping.Service, pollTimeout time.Duration) *BuildService { + if pollTimeout <= 0 { + pollTimeout = 5 * time.Minute + } + return &BuildService{ + qc: qc, + templateStore: templateStore, + manifestStore: manifestStore, + buildStore: buildStore, + mapping: mappingSvc, + pollTimeout: pollTimeout, + inFlightPolls: make(map[string]struct{}), + } +} + +func (s *BuildService) StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) { + template, err := s.templateStore.GetTemplateByID(ctx, req.TemplateID) + if err != nil { + return nil, fmt.Errorf("get template: %w", err) + } + if !template.IsAITemplate { + return nil, errors.New("only ai templates are allowed") + } + + manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, req.TemplateID) + if err != nil { + return nil, fmt.Errorf("get active manifest: %w", err) + } + if !isBuildAllowed(template.ManifestStatus) { + return nil, fmt.Errorf("template manifest status must be reviewed or validated, got %q", template.ManifestStatus) + } + + filteredGlobalData := FilterGlobalData(req.GlobalData) + if err := validation.ValidateBuildGlobalData(filteredGlobalData); err != nil { + return nil, err + } + + fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) + if err != nil { + return nil, fmt.Errorf("list manifest fields: %w", err) + } + + aiData, err := s.mapping.AssembleAIData(fields, req.FieldValues) + if err != nil { + return nil, fmt.Errorf("assemble aiData: %w", err) + } + if len(aiData) == 0 { + return nil, errors.New("at least one enabled text field value is required") + } + + qcReq := qcclient.CreateSiteRequest{ + TemplateID: req.TemplateID, + GlobalData: filteredGlobalData, + Content: qcclient.CreateSiteContent{ + AIData: aiData, + }, + } + + requestName := strings.TrimSpace(req.RequestName) + if requestName == "" { + requestName = "build-" + strconv.FormatInt(time.Now().Unix(), 10) + } + buildID := strconv.FormatInt(time.Now().UnixNano(), 10) + globalJSON, _ := json.Marshal(filteredGlobalData) + aiDataJSON, _ := json.Marshal(aiData) + finalPayload, _ := json.Marshal(qcReq) + now := time.Now().UTC() + + build := domain.SiteBuild{ + ID: buildID, + TemplateID: req.TemplateID, + ManifestID: manifest.ID, + RequestName: requestName, + GlobalDataJSON: globalJSON, + AIDataJSON: aiDataJSON, + FinalSitesPayload: finalPayload, + QCStatus: "draft", + } + if err := s.buildStore.CreateBuild(ctx, build); err != nil { + return nil, fmt.Errorf("create build: %w", err) + } + + siteResp, siteRaw, err := s.qc.CreateSite(ctx, qcReq) + if err != nil { + _ = s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) + return nil, fmt.Errorf("post /sites: %w", err) + } + + if err := s.buildStore.MarkBuildSubmitted(ctx, buildID, siteResp.JobID, normalizeQCStatus(siteResp.Status), siteRaw, now); err != nil { + return nil, fmt.Errorf("save site submission: %w", err) + } + + return &BuildResult{ + BuildID: buildID, + QCJobID: siteResp.JobID, + Status: normalizeQCStatus(siteResp.Status), + }, nil +} + +func (s *BuildService) PollOnce(ctx context.Context, buildID string) error { + if !s.acquirePollLease(buildID) { + return nil + } + defer s.releasePollLease(buildID) + + build, err := s.buildStore.GetBuildByID(ctx, buildID) + if err != nil { + return fmt.Errorf("get build: %w", err) + } + if isTerminalStatus(build.QCStatus) { + return nil + } + if build.QCJobID == nil { + return errors.New("build has no qcJobId") + } + + now := time.Now().UTC() + if hasExceededTimeout(build, now, s.pollTimeout) { + return s.buildStore.UpdateBuildFromJob( + ctx, + buildID, + "timeout", + build.QCSiteID, + preservePreviewURL(build.QCPreviewURL, ""), + nil, + wrapErrorMessage("poll timeout exceeded"), + &now, + ) + } + + job, jobRaw, err := s.qc.GetJob(ctx, *build.QCJobID) + now = time.Now().UTC() + if err != nil { + return s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) + } + + status := normalizeQCStatus(job.Status) + if isTerminalStatus(build.QCStatus) { + status = build.QCStatus + } + var finishedAt *time.Time + var siteID *int64 + previewURL := strings.TrimSpace(job.Result.PreviewURL) + if previewURL == "" { + previewURL = build.QCPreviewURL + } + if job.Result.SiteID > 0 { + id := job.Result.SiteID + siteID = &id + } else if build.QCSiteID != nil { + siteID = build.QCSiteID + } + if isTerminalStatus(status) { + finishedAt = &now + } + + if err := s.buildStore.UpdateBuildFromJob(ctx, buildID, status, siteID, previewURL, jobRaw, nil, finishedAt); err != nil { + return err + } + if status == "done" && siteID != nil && strings.TrimSpace(build.QCEditorURL) == "" { + _ = s.fetchAndStoreEditorURL(ctx, buildID, *siteID) + } + return nil +} + +func (s *BuildService) FetchEditorURL(ctx context.Context, buildID string) error { + build, err := s.buildStore.GetBuildByID(ctx, buildID) + if err != nil { + return fmt.Errorf("get build: %w", err) + } + if !isTerminalStatus(build.QCStatus) { + return fmt.Errorf("editor url can only be fetched for terminal build status, got %q", build.QCStatus) + } + if build.QCSiteID == nil { + return errors.New("build has no qcSiteId") + } + if strings.TrimSpace(build.QCEditorURL) != "" { + return nil + } + + return s.fetchAndStoreEditorURL(ctx, buildID, *build.QCSiteID) +} + +func (s *BuildService) GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) { + return s.buildStore.GetBuildByID(ctx, buildID) +} + +func isBuildAllowed(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "reviewed", "validated": + return true + default: + return false + } +} + +func normalizeQCStatus(status string) string { + s := strings.ToLower(strings.TrimSpace(status)) + switch s { + case "", "unknown": + return "queued" + case "in_progress", "running": + return "processing" + case "success", "succeeded", "completed": + return "done" + case "error": + return "failed" + case "queued", "processing", "done", "failed", "timeout": + return s + default: + return "processing" + } +} + +func wrapErrorRaw(err error) json.RawMessage { + return wrapErrorMessage(err.Error()) +} + +func wrapErrorMessage(msg string) json.RawMessage { + payload, marshalErr := json.Marshal(map[string]any{"error": msg}) + if marshalErr != nil { + return nil + } + return payload +} + +func preservePreviewURL(existing, next string) string { + if strings.TrimSpace(next) != "" { + return next + } + return existing +} + +func isTerminalStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "done", "failed", "timeout": + return true + default: + return false + } +} + +func hasExceededTimeout(build *domain.SiteBuild, now time.Time, pollTimeout time.Duration) bool { + if pollTimeout <= 0 || build.StartedAt == nil { + return false + } + if isTerminalStatus(build.QCStatus) { + return false + } + return now.Sub(*build.StartedAt) > pollTimeout +} + +func (s *BuildService) fetchAndStoreEditorURL(ctx context.Context, buildID string, siteID int64) error { + editor, raw, err := s.qc.GetEditorURL(ctx, siteID) + if err != nil { + return fmt.Errorf("get editor url: %w", err) + } + loginURL := strings.TrimSpace(editor.LoginURL) + if loginURL == "" { + return errors.New("empty editor login url") + } + return s.buildStore.UpdateBuildEditorURL(ctx, buildID, loginURL, raw) +} + +func (s *BuildService) acquirePollLease(buildID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.inFlightPolls[buildID]; ok { + return false + } + s.inFlightPolls[buildID] = struct{}{} + return true +} + +func (s *BuildService) releasePollLease(buildID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.inFlightPolls, buildID) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..67d6734 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + HTTPAddr string + DBDriver string + DBURL string + AppSecret string + QCBaseURL string + QCToken string + PollIntervalSeconds int + PollTimeoutSeconds int + PollMaxConcurrent int +} + +func Load() Config { + return Config{ + HTTPAddr: getenv("HTTP_ADDR", ":8080"), + DBDriver: getenv("DB_DRIVER", "postgres"), + DBURL: os.Getenv("DB_URL"), + AppSecret: os.Getenv("APP_SECRET"), + QCBaseURL: getenv("QC_BASE_URL", "https://qc-api.yggdrasil.dev-mono.net/api/v1"), + QCToken: os.Getenv("QC_TOKEN"), + PollIntervalSeconds: getenvInt("POLL_INTERVAL_SECONDS", 5), + PollTimeoutSeconds: getenvInt("POLL_TIMEOUT_SECONDS", 300), + PollMaxConcurrent: getenvInt("POLL_MAX_CONCURRENT", 4), + } +} + +func getenv(key, fallback string) string { + v := os.Getenv(key) + if v == "" { + return fallback + } + return v +} + +func getenvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..82d1b56 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,21 @@ +package crypto + +import "errors" + +var ErrMissingAppSecret = errors.New("missing app secret") + +func Encrypt(secret, plaintext string) (string, error) { + if secret == "" { + return "", ErrMissingAppSecret + } + // TODO(milestone-6): replace with AES-GCM encryption. + return plaintext, nil +} + +func Decrypt(secret, ciphertext string) (string, error) { + if secret == "" { + return "", ErrMissingAppSecret + } + // TODO(milestone-6): replace with AES-GCM decryption. + return ciphertext, nil +} diff --git a/internal/domain/models.go b/internal/domain/models.go new file mode 100644 index 0000000..83332f9 --- /dev/null +++ b/internal/domain/models.go @@ -0,0 +1,79 @@ +package domain + +import ( + "encoding/json" + "time" +) + +type Template struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Locale string `json:"locale"` + ThumbnailURL string `json:"thumbnailUrl"` + TemplatePreviewURL string `json:"templatePreviewUrl"` + Type string `json:"type"` + PaletteReady bool `json:"paletteReady"` + RawJSON json.RawMessage `json:"rawTemplateJson"` + IsAITemplate bool `json:"isAiTemplate"` + IsOnboarded bool `json:"isOnboarded"` + ManifestStatus string `json:"manifestStatus"` + LastDiscoveredAt *time.Time `json:"lastDiscoveredAt,omitempty"` +} + +type TemplateManifest struct { + ID string `json:"id"` + TemplateID int64 `json:"templateId"` + Version int `json:"version"` + Source string `json:"source"` + LanguageUsedDiscovery string `json:"languageUsedForDiscovery"` + DiscoveryPayloadJSON json.RawMessage `json:"discoveryPayloadJson"` + DiscoveryResponseJSON json.RawMessage `json:"discoveryResponseJson"` + FlattenedManifestJSON json.RawMessage `json:"flattenedManifestJson"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type TemplateField struct { + ID string `json:"id"` + TemplateID int64 `json:"templateId"` + ManifestID string `json:"manifestId"` + Section string `json:"section"` + KeyName string `json:"keyName"` + Path string `json:"path"` + FieldKind string `json:"fieldKind"` + SampleValue string `json:"sampleValue"` + IsEnabled bool `json:"isEnabled"` + IsRequiredByUs bool `json:"isRequiredByUs"` + DisplayLabel string `json:"displayLabel"` + DisplayOrder int `json:"displayOrder"` + Notes string `json:"notes"` +} + +type SiteBuild struct { + ID string `json:"id"` + TemplateID int64 `json:"templateId"` + ManifestID string `json:"manifestId"` + RequestName string `json:"requestName"` + GlobalDataJSON json.RawMessage `json:"globalDataJson"` + AIDataJSON json.RawMessage `json:"aiDataJson"` + FinalSitesPayload json.RawMessage `json:"finalSitesPayloadJson"` + QCJobID *int64 `json:"qcJobId,omitempty"` + QCSiteID *int64 `json:"qcSiteId,omitempty"` + QCStatus string `json:"qcStatus"` + QCPreviewURL string `json:"qcPreviewUrl"` + QCEditorURL string `json:"qcEditorUrl"` + QCResultJSON json.RawMessage `json:"qcResultJson"` + QCErrorJSON json.RawMessage `json:"qcErrorJson"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` +} + +type AppSettings struct { + QCBaseURL string `json:"qcBaseUrl"` + QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` + LanguageOutputMode string `json:"languageOutputMode"` + JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` + JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` +} diff --git a/internal/httpserver/handlers/handlers.go b/internal/httpserver/handlers/handlers.go new file mode 100644 index 0000000..737c552 --- /dev/null +++ b/internal/httpserver/handlers/handlers.go @@ -0,0 +1,204 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "qctextbuilder/internal/buildsvc" + "qctextbuilder/internal/onboarding" + "qctextbuilder/internal/templatesvc" +) + +type API struct { + templateSvc *templatesvc.Service + onboardSvc *onboarding.Service + buildSvc buildsvc.Service +} + +func NewAPI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, buildSvc buildsvc.Service) *API { + return &API{ + templateSvc: templateSvc, + onboardSvc: onboardSvc, + buildSvc: buildSvc, + } +} + +func (a *API) Health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) +} + +func (a *API) SyncTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := a.templateSvc.SyncAITemplates(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates}) +} + +func (a *API) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := a.templateSvc.ListTemplates(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates}) +} + +func (a *API) GetTemplateDetail(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + templateID, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) + return + } + + detail, err := a.templateSvc.GetTemplateDetail(r.Context(), templateID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, detail) +} + +func (a *API) OnboardTemplate(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + templateID, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) + return + } + + manifest, fields, err := a.onboardSvc.OnboardTemplate(r.Context(), templateID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "manifestId": manifest.ID, + "fieldCount": len(fields), + "status": "reviewed", + }) +} + +type updateTemplateFieldsRequest struct { + ManifestID string `json:"manifestId"` + Fields []updateTemplateFieldItem `json:"fields"` +} + +type updateTemplateFieldItem struct { + Path string `json:"path"` + IsEnabled *bool `json:"isEnabled,omitempty"` + IsRequiredByUs *bool `json:"isRequiredByUs,omitempty"` + DisplayLabel *string `json:"displayLabel,omitempty"` + DisplayOrder *int `json:"displayOrder,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +func (a *API) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + templateID, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"}) + return + } + + var req updateTemplateFieldsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) + return + } + if len(req.Fields) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "fields is required"}) + return + } + + patches := make([]onboarding.FieldPatch, 0, len(req.Fields)) + for _, f := range req.Fields { + patches = append(patches, onboarding.FieldPatch{ + Path: f.Path, + IsEnabled: f.IsEnabled, + IsRequiredByUs: f.IsRequiredByUs, + DisplayLabel: f.DisplayLabel, + DisplayOrder: f.DisplayOrder, + Notes: f.Notes, + }) + } + + manifest, fields, err := a.onboardSvc.UpdateTemplateFields(r.Context(), templateID, req.ManifestID, patches) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "templateId": templateID, + "manifestId": manifest.ID, + "fieldCount": len(fields), + "fields": fields, + }) +} + +func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) { + var req buildsvc.StartBuildRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) + return + } + + result, err := a.buildSvc.StartBuild(r.Context(), req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusAccepted, result) +} + +func (a *API) GetBuild(w http.ResponseWriter, r *http.Request) { + buildID := chi.URLParam(r, "id") + build, err := a.buildSvc.GetBuild(r.Context(), buildID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, build) +} + +func (a *API) PollBuildOnce(w http.ResponseWriter, r *http.Request) { + buildID := chi.URLParam(r, "id") + if err := a.buildSvc.PollOnce(r.Context(), buildID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + build, err := a.buildSvc.GetBuild(r.Context(), buildID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, build) +} + +func (a *API) FetchBuildEditorURL(w http.ResponseWriter, r *http.Request) { + buildID := chi.URLParam(r, "id") + if err := a.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + build, err := a.buildSvc.GetBuild(r.Context(), buildID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, build) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go new file mode 100644 index 0000000..df72e12 --- /dev/null +++ b/internal/httpserver/handlers/ui.go @@ -0,0 +1,474 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "qctextbuilder/internal/buildsvc" + "qctextbuilder/internal/config" + "qctextbuilder/internal/domain" + "qctextbuilder/internal/onboarding" + "qctextbuilder/internal/templatesvc" +) + +type UI struct { + templateSvc *templatesvc.Service + onboardSvc *onboarding.Service + buildSvc buildsvc.Service + cfg config.Config + render htmlRenderer +} + +type htmlRenderer interface { + Render(w http.ResponseWriter, name string, data any) +} + +type pageData struct { + Title string + Msg string + Err string + Current string +} + +type homePageData struct { + pageData + TemplateCount int +} + +type settingsPageData struct { + pageData + QCBaseURL string + PollIntervalSeconds int + PollTimeoutSeconds int + PollMaxConcurrent int + TokenConfigured bool + LanguageOutputMode string +} + +type templatesPageData struct { + pageData + Templates []domain.Template +} + +type templateFieldView struct { + Path string + FieldKind string + IsEnabled bool + IsRequiredByUs bool + DisplayLabel string + DisplayOrder int + Notes string + SampleValue string +} + +type templateDetailPageData struct { + pageData + Detail *templatesvc.TemplateDetail + Fields []templateFieldView +} + +type buildFieldView struct { + Path string + DisplayLabel string + SampleValue string + Value string +} + +type buildNewPageData struct { + pageData + Templates []domain.Template + SelectedTemplateID int64 + SelectedManifestID string + EnabledFields []buildFieldView + Form buildFormInput +} + +type buildFormInput struct { + 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 +} + +type buildDetailPageData struct { + pageData + Build *domain.SiteBuild + EffectiveGlobal []byte + CanPoll bool + CanFetchEditorURL bool + 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 (u *UI) Home(w http.ResponseWriter, r *http.Request) { + templates, err := u.templateSvc.ListTemplates(r.Context()) + if err != nil { + u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0}) + return + } + u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)}) +} + +func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { + u.render.Render(w, "settings", settingsPageData{ + pageData: basePageData(r, "Settings", "/settings"), + QCBaseURL: u.cfg.QCBaseURL, + PollIntervalSeconds: u.cfg.PollIntervalSeconds, + PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, + PollMaxConcurrent: u.cfg.PollMaxConcurrent, + TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", + LanguageOutputMode: "EN", + }) +} + +func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { + templates, err := u.templateSvc.ListTemplates(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates}) +} + +func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) { + if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil { + http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther) +} + +func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) { + templateID, ok := parseTemplateID(w, r) + if !ok { + return + } + detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + fields := make([]templateFieldView, 0, len(detail.Fields)) + for _, f := range detail.Fields { + fields = append(fields, templateFieldView{ + Path: f.Path, + FieldKind: f.FieldKind, + IsEnabled: f.IsEnabled, + IsRequiredByUs: f.IsRequiredByUs, + DisplayLabel: f.DisplayLabel, + DisplayOrder: f.DisplayOrder, + Notes: f.Notes, + SampleValue: f.SampleValue, + }) + } + u.render.Render(w, "template_detail", templateDetailPageData{pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields}) +} + +func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) { + templateID, ok := parseTemplateID(w, r) + if !ok { + return + } + if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil { + http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) + return + } + http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther) +} + +func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { + templateID, ok := parseTemplateID(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther) + return + } + + count, _ := strconv.Atoi(r.FormValue("field_count")) + patches := make([]onboarding.FieldPatch, 0, count) + for i := 0; i < count; i++ { + path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) + if path == "" { + continue + } + enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on" + required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on" + label := r.FormValue(fmt.Sprintf("field_label_%d", i)) + notes := r.FormValue(fmt.Sprintf("field_notes_%d", i)) + order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i)))) + if err != nil { + http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther) + return + } + patches = append(patches, onboarding.FieldPatch{ + Path: path, + IsEnabled: boolPtr(enabled), + IsRequiredByUs: boolPtr(required), + DisplayLabel: strPtr(label), + DisplayOrder: intPtr(order), + Notes: strPtr(notes), + }) + } + + manifestID := r.FormValue("manifest_id") + if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil { + http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) + return + } + http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther) +} + +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) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + u.render.Render(w, "build_new", data) +} + +func (u *UI) CreateBuild(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 + } + + 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, + }), + FieldValues: fieldValues, + }) + if err != nil { + data, loadErr := u.loadBuildNewPageData(r, pageData{ + Title: "New Build", + Err: err.Error(), + Current: "/builds/new", + }, 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/%s?msg=build+started", result.BuildID), 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) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + status := strings.ToLower(strings.TrimSpace(build.QCStatus)) + canPoll := status == "queued" || status == "processing" + canFetchEditor := (status == "done" || status == "failed" || status == "timeout") && + build.QCSiteID != nil && + strings.TrimSpace(build.QCEditorURL) == "" + autoRefresh := 0 + if canPoll && u.cfg.PollIntervalSeconds > 0 { + autoRefresh = u.cfg.PollIntervalSeconds + } + effectiveGlobal := build.GlobalDataJSON + if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 { + effectiveGlobal = payloadGlobal + } + u.render.Render(w, "build_detail", buildDetailPageData{ + pageData: basePageData(r, "Build Detail", "/builds"), + Build: build, + EffectiveGlobal: effectiveGlobal, + CanPoll: canPoll, + CanFetchEditorURL: canFetchEditor, + AutoRefreshSeconds: autoRefresh, + }) +} + +func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) { + buildID := strings.TrimSpace(chi.URLParam(r, "id")) + if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil { + http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) + return + } + http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther) +} + +func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) { + buildID := strings.TrimSpace(chi.URLParam(r, "id")) + if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { + http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) + return + } + http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther) +} + +func basePageData(r *http.Request, title, current string) pageData { + q := r.URL.Query() + return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current} +} + +func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) { + rawID := chi.URLParam(r, "id") + templateID, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + http.Error(w, "invalid template id", http.StatusBadRequest) + return 0, false + } + return templateID, true +} + +func urlQuery(s string) string { + return url.QueryEscape(s) +} + +func boolPtr(v bool) *bool { return &v } +func intPtr(v int) *int { return &v } +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) { + templates, err := u.templateSvc.ListTemplates(r.Context()) + if err != nil { + return buildNewPageData{}, err + } + + data := buildNewPageData{ + pageData: page, + Templates: templates, + SelectedTemplateID: selectedTemplateID, + Form: form, + } + if selectedTemplateID <= 0 { + return data, nil + } + + detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID) + if err != nil || detail.Manifest == nil { + return data, nil + } + data.SelectedManifestID = detail.Manifest.ID + for _, f := range detail.Fields { + if !f.IsEnabled || f.FieldKind != "text" { + continue + } + data.EnabledFields = append(data.EnabledFields, buildFieldView{ + Path: f.Path, + DisplayLabel: f.DisplayLabel, + SampleValue: f.SampleValue, + Value: strings.TrimSpace(fieldValues[f.Path]), + }) + } + return data, nil +} + +func buildFormInputFromRequest(r *http.Request) buildFormInput { + return buildFormInput{ + 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")), + } +} + +func parseBuildFieldValues(r *http.Request) map[string]string { + fieldValues := map[string]string{} + count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) + for i := 0; i < count; i++ { + path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) + value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i))) + if path != "" { + fieldValues[path] = value + } + } + return fieldValues +} + +func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) { + if len(raw) == 0 { + return nil, nil + } + var payload struct { + GlobalData map[string]any `json:"globalData"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + if len(payload.GlobalData) == 0 { + return nil, nil + } + data, err := json.Marshal(payload.GlobalData) + if err != nil { + return nil, err + } + return data, nil +} diff --git a/internal/httpserver/middleware/logger.go b/internal/httpserver/middleware/logger.go new file mode 100644 index 0000000..780a97b --- /dev/null +++ b/internal/httpserver/middleware/logger.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" +) + +func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + logger.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "duration_ms", time.Since(start).Milliseconds(), + ) + }) + } +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go new file mode 100644 index 0000000..781f106 --- /dev/null +++ b/internal/httpserver/server.go @@ -0,0 +1,41 @@ +package httpserver + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + appmw "qctextbuilder/internal/httpserver/middleware" +) + +type Server struct { + httpServer *http.Server +} + +func New(addr string, logger *slog.Logger, registerRoutes func(r chi.Router)) *Server { + r := chi.NewRouter() + r.Use(chimw.RequestID) + r.Use(chimw.Recoverer) + r.Use(chimw.Timeout(30 * time.Second)) + r.Use(appmw.RequestLogger(logger)) + registerRoutes(r) + + return &Server{ + httpServer: &http.Server{ + Addr: addr, + Handler: r, + }, + } +} + +func (s *Server) Run() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} diff --git a/internal/httpserver/views/doc.go b/internal/httpserver/views/doc.go new file mode 100644 index 0000000..0a18eea --- /dev/null +++ b/internal/httpserver/views/doc.go @@ -0,0 +1,3 @@ +package views + +// Package views contains server-rendered templates for the admin UI. diff --git a/internal/httpserver/views/renderer.go b/internal/httpserver/views/renderer.go new file mode 100644 index 0000000..33f1cd5 --- /dev/null +++ b/internal/httpserver/views/renderer.go @@ -0,0 +1,40 @@ +package views + +import ( + "bytes" + "encoding/json" + "html/template" + "net/http" +) + +type Renderer struct { + tpl *template.Template +} + +func NewRenderer(pattern string) (*Renderer, error) { + tpl, err := template.New("ui").Funcs(template.FuncMap{ + "prettyJSON": prettyJSON, + }).ParseGlob(pattern) + if err != nil { + return nil, err + } + return &Renderer{tpl: tpl}, nil +} + +func (r *Renderer) Render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := r.tpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "template rendering failed", http.StatusInternalServerError) + } +} + +func prettyJSON(raw []byte) string { + if len(raw) == 0 { + return "" + } + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..814e575 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,10 @@ +package logging + +import ( + "log/slog" + "os" +) + +func New() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stdout, nil)) +} diff --git a/internal/mapping/service.go b/internal/mapping/service.go new file mode 100644 index 0000000..1d8b111 --- /dev/null +++ b/internal/mapping/service.go @@ -0,0 +1,44 @@ +package mapping + +import ( + "fmt" + "strings" + + "qctextbuilder/internal/domain" +) + +type Service interface { + AssembleAIData(fields []domain.TemplateField, values map[string]string) (map[string]map[string]any, error) +} + +type RawKeyService struct{} + +func New() *RawKeyService { + return &RawKeyService{} +} + +func (s *RawKeyService) AssembleAIData(fields []domain.TemplateField, values map[string]string) (map[string]map[string]any, error) { + allowed := make(map[string]domain.TemplateField, len(fields)) + for _, f := range fields { + if !f.IsEnabled || f.FieldKind != "text" { + continue + } + allowed[f.Path] = f + } + + out := make(map[string]map[string]any) + for path, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + f, ok := allowed[path] + if !ok { + return nil, fmt.Errorf("unknown or disabled field path: %s", path) + } + if out[f.Section] == nil { + out[f.Section] = make(map[string]any) + } + out[f.Section][f.KeyName] = value + } + return out, nil +} diff --git a/internal/onboarding/service.go b/internal/onboarding/service.go new file mode 100644 index 0000000..49d0ec0 --- /dev/null +++ b/internal/onboarding/service.go @@ -0,0 +1,262 @@ +package onboarding + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "qctextbuilder/internal/domain" + "qctextbuilder/internal/qcclient" + "qctextbuilder/internal/store" +) + +var imagePlaceholderPattern = regexp.MustCompile(`^\[\s*(image|img|photo|picture)(\s+(image|img|photo|picture))*\s*\]$`) + +var imageLikePathHints = []string{ + "image", + "img", + "photo", + "picture", + "thumbnail", + "gallery", + "logo", + "icon", + "avatar", + "background", + "banner", +} + +type Service struct { + qc qcclient.Client + templateStore store.TemplateStore + manifestStore store.ManifestStore +} + +type FieldPatch struct { + Path string + IsEnabled *bool + IsRequiredByUs *bool + DisplayLabel *string + DisplayOrder *int + Notes *string +} + +func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service { + return &Service{ + qc: qc, + templateStore: templateStore, + manifestStore: manifestStore, + } +} + +func (s *Service) OnboardTemplate(ctx context.Context, templateID int64) (*domain.TemplateManifest, []domain.TemplateField, error) { + template, err := s.templateStore.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, nil, fmt.Errorf("get template: %w", err) + } + if !template.IsAITemplate { + return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed") + } + + req := defaultDiscoveryRequest(templateID) + data, raw, err := s.qc.GenerateContent(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("generate discovery content: %w", err) + } + + manifestID := strconv.FormatInt(time.Now().UnixNano(), 10) + now := time.Now().UTC() + fields := flattenDiscovery(templateID, manifestID, data) + flattened, _ := json.Marshal(fields) + reqRaw, _ := json.Marshal(req) + + manifest := domain.TemplateManifest{ + ID: manifestID, + TemplateID: templateID, + Version: 1, + Source: "generate-content", + LanguageUsedDiscovery: "EN", + DiscoveryPayloadJSON: reqRaw, + DiscoveryResponseJSON: raw, + FlattenedManifestJSON: flattened, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.manifestStore.CreateManifest(ctx, manifest, fields); err != nil { + return nil, nil, fmt.Errorf("save manifest: %w", err) + } + if err := s.templateStore.SetTemplateManifestStatus(ctx, templateID, "reviewed", true); err != nil { + return nil, nil, fmt.Errorf("update template status: %w", err) + } + return &manifest, fields, nil +} + +func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, manifestID string, patches []FieldPatch) (*domain.TemplateManifest, []domain.TemplateField, error) { + template, err := s.templateStore.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, nil, fmt.Errorf("get template: %w", err) + } + if !template.IsAITemplate { + return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed") + } + + manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID) + if err != nil { + return nil, nil, fmt.Errorf("get active manifest: %w", err) + } + if strings.TrimSpace(manifestID) != "" && manifest.ID != manifestID { + return nil, nil, fmt.Errorf("manifest mismatch: active=%s requested=%s", manifest.ID, manifestID) + } + + fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) + if err != nil { + return nil, nil, fmt.Errorf("list fields: %w", err) + } + + byPath := make(map[string]int, len(fields)) + for i := range fields { + byPath[fields[i].Path] = i + } + + for _, patch := range patches { + path := strings.TrimSpace(patch.Path) + if path == "" { + return nil, nil, fmt.Errorf("field patch path is required") + } + idx, ok := byPath[path] + if !ok { + return nil, nil, fmt.Errorf("unknown field path: %s", path) + } + + if patch.IsEnabled != nil { + if *patch.IsEnabled && fields[idx].FieldKind != "text" { + return nil, nil, fmt.Errorf("field %s cannot be enabled for kind %s", path, fields[idx].FieldKind) + } + fields[idx].IsEnabled = *patch.IsEnabled + } + if patch.IsRequiredByUs != nil { + fields[idx].IsRequiredByUs = *patch.IsRequiredByUs + } + if patch.DisplayLabel != nil { + fields[idx].DisplayLabel = strings.TrimSpace(*patch.DisplayLabel) + } + if patch.DisplayOrder != nil { + fields[idx].DisplayOrder = *patch.DisplayOrder + } + if patch.Notes != nil { + fields[idx].Notes = strings.TrimSpace(*patch.Notes) + } + } + + if err := s.manifestStore.UpdateFields(ctx, manifest.ID, fields); err != nil { + return nil, nil, fmt.Errorf("update fields: %w", err) + } + + sort.Slice(fields, func(i, j int) bool { + if fields[i].DisplayOrder == fields[j].DisplayOrder { + return fields[i].Path < fields[j].Path + } + return fields[i].DisplayOrder < fields[j].DisplayOrder + }) + return manifest, fields, nil +} + +func defaultDiscoveryRequest(templateID int64) qcclient.GenerateContentRequest { + return qcclient.GenerateContentRequest{ + TemplateID: templateID, + GlobalData: map[string]any{ + "companyName": "Discovery Company", + "businessType": "dentist", + "siteLanguage": "EN", + "email": "discovery@example.com", + "phone": "+41 44 000 00 00", + "address": map[string]any{ + "line1": "Discovery Street 1", + "line2": "", + "city": "Zurich", + "region": "ZH", + "postalCode": "8000", + "country": "CH", + }, + }, + Empty: false, + ToneOfVoice: "Professional", + TargetAudience: "B2B", + } +} + +func flattenDiscovery(templateID int64, manifestID string, data qcclient.GenerateContentData) []domain.TemplateField { + fields := make([]domain.TemplateField, 0) + order := 0 + for section, kv := range data { + for key, value := range kv { + sample := fmt.Sprint(value) + path := section + "." + key + kind := detectFieldKind(path, sample) + enabled := kind == "text" + + fields = append(fields, domain.TemplateField{ + ID: fmt.Sprintf("%s-%d", manifestID, order+1), + TemplateID: templateID, + ManifestID: manifestID, + Section: section, + KeyName: key, + Path: path, + FieldKind: kind, + SampleValue: sample, + IsEnabled: enabled, + DisplayLabel: path, + DisplayOrder: order, + Notes: "", + }) + order++ + } + } + return fields +} + +func detectFieldKind(path string, sample string) string { + sampleTrim := strings.TrimSpace(sample) + if sampleTrim == "" { + return "unknown" + } + + if strings.EqualFold(sampleTrim, "#IMAGE#") { + return "image" + } + if isLikelyImagePlaceholder(sampleTrim) { + return "image" + } + if isLikelyImagePath(path) { + return "image" + } + return "text" +} + +func isLikelyImagePlaceholder(sample string) bool { + normalized := strings.ToLower(strings.TrimSpace(sample)) + if imagePlaceholderPattern.MatchString(normalized) { + return true + } + if normalized == "image" || normalized == "img" || normalized == "photo" || normalized == "picture" { + return true + } + return strings.Contains(normalized, " image image ") +} + +func isLikelyImagePath(path string) bool { + normalized := strings.ToLower(strings.TrimSpace(path)) + for _, hint := range imageLikePathHints { + if strings.Contains(normalized, hint) { + return true + } + } + return false +} diff --git a/internal/onboarding/service_test.go b/internal/onboarding/service_test.go new file mode 100644 index 0000000..8ab36b6 --- /dev/null +++ b/internal/onboarding/service_test.go @@ -0,0 +1,95 @@ +package onboarding + +import ( + "testing" + + "qctextbuilder/internal/qcclient" +) + +func TestDetectFieldKind(t *testing.T) { + tests := []struct { + name string + path string + sample string + want string + }{ + { + name: "exact image token", + path: "hero.image", + sample: "#IMAGE#", + want: "image", + }, + { + name: "image placeholder bracketed", + path: "gallery.galleryImage_m4178_15", + sample: "[image image image image]", + want: "image", + }, + { + name: "image path hint even with text sample", + path: "section.thumbnailUrl", + sample: "headline", + want: "image", + }, + { + name: "empty sample is unknown", + path: "hero.title", + sample: "", + want: "unknown", + }, + { + name: "plain text field", + path: "hero.title", + sample: "This is a headline", + want: "text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectFieldKind(tt.path, tt.sample) + if got != tt.want { + t.Fatalf("detectFieldKind() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) { + data := qcclient.GenerateContentData{ + "gallery": { + "galleryImage_m4178_15": "[image image image image]", + }, + "hero": { + "title": "Welcome", + }, + } + + fields := flattenDiscovery(99, "manifest-1", data) + if len(fields) != 2 { + t.Fatalf("flattenDiscovery() field count = %d, want 2", len(fields)) + } + + byPath := map[string]string{} + enabled := map[string]bool{} + for _, f := range fields { + byPath[f.Path] = f.FieldKind + enabled[f.Path] = f.IsEnabled + } + + imagePath := "gallery.galleryImage_m4178_15" + if byPath[imagePath] != "image" { + t.Fatalf("field %s kind = %q, want image", imagePath, byPath[imagePath]) + } + if enabled[imagePath] { + t.Fatalf("field %s should be disabled by default", imagePath) + } + + textPath := "hero.title" + if byPath[textPath] != "text" { + t.Fatalf("field %s kind = %q, want text", textPath, byPath[textPath]) + } + if !enabled[textPath] { + t.Fatalf("field %s should be enabled by default", textPath) + } +} diff --git a/internal/polling/service.go b/internal/polling/service.go new file mode 100644 index 0000000..e4d4076 --- /dev/null +++ b/internal/polling/service.go @@ -0,0 +1,84 @@ +package polling + +import ( + "context" + "log/slog" + "sync" + "time" + + "qctextbuilder/internal/buildsvc" + "qctextbuilder/internal/store" +) + +type Service struct { + buildSvc buildsvc.Service + store store.BuildStore + interval time.Duration + maxPolls int + logger *slog.Logger +} + +func New(buildSvc buildsvc.Service, buildStore store.BuildStore, interval time.Duration, maxPolls int, logger *slog.Logger) *Service { + if interval <= 0 { + interval = 5 * time.Second + } + if maxPolls <= 0 { + maxPolls = 4 + } + return &Service{ + buildSvc: buildSvc, + store: buildStore, + interval: interval, + maxPolls: maxPolls, + logger: logger, + } +} + +func (s *Service) Start(ctx context.Context) error { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + s.PollPendingOnce(ctx, 20) + } + } +} + +func (s *Service) PollPendingOnce(ctx context.Context, limit int) { + builds, err := s.store.ListBuildsByStatuses(ctx, []string{"queued", "processing"}, limit) + if err != nil { + s.logger.Error("list pending builds failed", "error", err) + return + } + + semSize := s.maxPolls + if semSize <= 0 { + semSize = 1 + } + sem := make(chan struct{}, semSize) + var wg sync.WaitGroup + + for _, build := range builds { + select { + case <-ctx.Done(): + return + default: + } + + wg.Add(1) + sem <- struct{}{} + go func(buildID string) { + defer wg.Done() + defer func() { <-sem }() + if err := s.buildSvc.PollOnce(ctx, buildID); err != nil { + s.logger.Error("poll build failed", "buildId", buildID, "error", err) + } + }(build.ID) + } + + wg.Wait() +} diff --git a/internal/qcclient/client.go b/internal/qcclient/client.go new file mode 100644 index 0000000..15610e3 --- /dev/null +++ b/internal/qcclient/client.go @@ -0,0 +1,195 @@ +package qcclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +type Client interface { + Health(ctx context.Context) error + ListAITemplates(ctx context.Context) ([]Template, error) + GetTemplate(ctx context.Context, templateID int64) (*Template, error) + GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) + GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) + CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) + GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) + GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) +} + +type HTTPClient struct { + baseURL string + token string + client *http.Client + logger *slog.Logger +} + +func New(baseURL, token string, timeout time.Duration, logger *slog.Logger) *HTTPClient { + if timeout <= 0 { + timeout = 10 * time.Second + } + return &HTTPClient{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + client: &http.Client{Timeout: timeout}, + logger: logger, + } +} + +func (c *HTTPClient) Health(ctx context.Context) error { + _, _, err := c.doJSON(ctx, http.MethodGet, "/health", nil) + return err +} + +func (c *HTTPClient) ListAITemplates(ctx context.Context) ([]Template, error) { + body, _, err := c.doJSON(ctx, http.MethodGet, "/templates?type=ai", nil) + if err != nil { + return nil, err + } + var rsp APIResponse[[]Template] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, fmt.Errorf("decode templates response: %w", err) + } + return rsp.Data, nil +} + +func (c *HTTPClient) GetTemplate(ctx context.Context, templateID int64) (*Template, error) { + body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d", templateID), nil) + if err != nil { + return nil, err + } + var rsp APIResponse[Template] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, fmt.Errorf("decode template response: %w", err) + } + return &rsp.Data, nil +} + +func (c *HTTPClient) GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) { + body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d/schema", templateID), nil) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +func (c *HTTPClient) GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) { + body, raw, err := c.doJSON(ctx, http.MethodPost, "/generate-content", req) + if err != nil { + return nil, nil, err + } + var rsp APIResponse[GenerateContentData] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, raw, fmt.Errorf("decode generate-content response: %w", err) + } + return rsp.Data, raw, nil +} + +func (c *HTTPClient) CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) { + body, raw, err := c.doJSON(ctx, http.MethodPost, "/sites", req) + if err != nil { + return nil, nil, err + } + var rsp APIResponse[CreateSiteResponseData] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, raw, fmt.Errorf("decode create site response: %w", err) + } + return &rsp.Data, raw, nil +} + +func (c *HTTPClient) GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) { + body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/jobs/%d", jobID), nil) + if err != nil { + return nil, nil, err + } + var rsp APIResponse[JobStatusData] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, raw, fmt.Errorf("decode job response: %w", err) + } + return &rsp.Data, raw, nil +} + +func (c *HTTPClient) GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) { + body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/sites/%d/editor-url", siteID), nil) + if err != nil { + return nil, nil, err + } + var rsp APIResponse[SiteEditorLoginData] + if err := json.Unmarshal(body, &rsp); err != nil { + return nil, raw, fmt.Errorf("decode editor-url response: %w", err) + } + return &rsp.Data, raw, nil +} + +func (c *HTTPClient) doJSON(ctx context.Context, method, path string, payload any) ([]byte, json.RawMessage, error) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, nil, fmt.Errorf("marshal request: %w", err) + } + body = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + start := time.Now() + res, err := c.client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("do request: %w", err) + } + defer res.Body.Close() + + respBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, nil, fmt.Errorf("read response: %w", err) + } + + c.logger.Info("qc request", + "method", method, + "path", path, + "status", res.StatusCode, + "duration_ms", time.Since(start).Milliseconds(), + "auth", "Bearer [REDACTED]", + ) + + if res.StatusCode >= 400 { + return nil, json.RawMessage(respBody), c.mapHTTPError(res.StatusCode, respBody) + } + return respBody, json.RawMessage(respBody), nil +} + +func (c *HTTPClient) mapHTTPError(statusCode int, raw []byte) error { + var apiErr APIError + _ = json.Unmarshal(raw, &apiErr) + + err := &HTTPError{ + StatusCode: statusCode, + APIError: &apiErr, + RawBody: raw, + } + switch statusCode { + case http.StatusBadRequest: + return fmt.Errorf("%w: %w", ErrBadRequest, err) + case http.StatusUnauthorized: + return fmt.Errorf("%w: %w", ErrUnauthorized, err) + case http.StatusNotFound: + return fmt.Errorf("%w: %w", ErrNotFound, err) + default: + return err + } +} diff --git a/internal/qcclient/errors.go b/internal/qcclient/errors.go new file mode 100644 index 0000000..a7b8e83 --- /dev/null +++ b/internal/qcclient/errors.go @@ -0,0 +1,22 @@ +package qcclient + +import "errors" + +var ( + ErrUnauthorized = errors.New("qc unauthorized") + ErrBadRequest = errors.New("qc bad request") + ErrNotFound = errors.New("qc not found") +) + +type HTTPError struct { + StatusCode int + APIError *APIError + RawBody []byte +} + +func (e *HTTPError) Error() string { + if e.APIError != nil && e.APIError.Message != "" { + return e.APIError.Message + } + return "qc request failed" +} diff --git a/internal/qcclient/types.go b/internal/qcclient/types.go new file mode 100644 index 0000000..2021b81 --- /dev/null +++ b/internal/qcclient/types.go @@ -0,0 +1,140 @@ +package qcclient + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type APIResponse[T any] struct { + Status string `json:"status"` + Data T `json:"data"` +} + +type APIError struct { + Status string `json:"status"` + Message string `json:"message"` + Code string `json:"code"` + Details map[string]any `json:"details,omitempty"` +} + +type Template struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Locale string `json:"locale"` + ThumbnailURL string `json:"thumbnailUrl"` + TemplatePreviewURL string `json:"templatePreviewUrl"` + Type string `json:"type"` + PaletteReady Boolish `json:"paletteReady"` + GlobalColors map[string]any `json:"globalColors"` + TextsWithGlobalColors map[string]any `json:"textsWithGlobalColors"` + TemplateHeadings []TemplateHeading `json:"templateHeadings,omitempty"` + Raw map[string]interface{} `json:"-"` +} + +type Boolish bool + +func (b Boolish) Bool() bool { + return bool(b) +} + +func (b Boolish) MarshalJSON() ([]byte, error) { + return json.Marshal(bool(b)) +} + +func (b *Boolish) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" || strings.EqualFold(trimmed, "null") { + *b = false + return nil + } + + var boolVal bool + if err := json.Unmarshal(data, &boolVal); err == nil { + *b = Boolish(boolVal) + return nil + } + + var numVal float64 + if err := json.Unmarshal(data, &numVal); err == nil { + *b = Boolish(numVal != 0) + return nil + } + + var stringVal string + if err := json.Unmarshal(data, &stringVal); err == nil { + parsed, err := parseBoolishString(stringVal) + if err != nil { + return err + } + *b = Boolish(parsed) + return nil + } + + return fmt.Errorf("unsupported boolish value: %s", trimmed) +} + +func parseBoolishString(v string) (bool, error) { + normalized := strings.ToLower(strings.TrimSpace(v)) + switch normalized { + case "", "0", "false", "f", "no", "n", "off": + return false, nil + case "1", "true", "t", "yes", "y", "on": + return true, nil + } + + numeric, err := strconv.ParseFloat(normalized, 64) + if err == nil { + return numeric != 0, nil + } + + return false, fmt.Errorf("unsupported boolish string value: %q", v) +} + +type TemplateHeading struct { + TranslationKey string `json:"translationKey"` +} + +type GenerateContentRequest struct { + TemplateID int64 `json:"templateId"` + GlobalData map[string]any `json:"globalData"` + Empty bool `json:"empty"` + ToneOfVoice string `json:"toneOfVoice,omitempty"` + TargetAudience string `json:"targetAudience,omitempty"` + CustomTemplateData map[string]any `json:"customTemplateData,omitempty"` +} + +type GenerateContentData map[string]map[string]any + +type CreateSiteRequest struct { + TemplateID int64 `json:"templateId"` + GlobalData map[string]any `json:"globalData"` + Content CreateSiteContent `json:"content"` +} + +type CreateSiteContent struct { + AIData map[string]map[string]any `json:"aiData"` +} + +type CreateSiteResponseData struct { + Status string `json:"status"` + JobID int64 `json:"jobId"` +} + +type JobStatusResult struct { + SiteID int64 `json:"siteId"` + PreviewURL string `json:"previewUrl"` +} + +type JobStatusData struct { + JobID int64 `json:"jobId"` + Status string `json:"status"` + CreatedAt int64 `json:"createdAt"` + Result JobStatusResult `json:"result"` +} + +type SiteEditorLoginData struct { + LoginURL string `json:"loginUrl"` +} diff --git a/internal/qcclient/types_test.go b/internal/qcclient/types_test.go new file mode 100644 index 0000000..9d622b4 --- /dev/null +++ b/internal/qcclient/types_test.go @@ -0,0 +1,79 @@ +package qcclient + +import ( + "encoding/json" + "testing" +) + +func TestBoolish_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want bool + expectErr bool + }{ + {name: "bool true", input: `true`, want: true}, + {name: "bool false", input: `false`, want: false}, + {name: "number one", input: `1`, want: true}, + {name: "number zero", input: `0`, want: false}, + {name: "number float", input: `2.5`, want: true}, + {name: "string one", input: `"1"`, want: true}, + {name: "string zero", input: `"0"`, want: false}, + {name: "string true", input: `"true"`, want: true}, + {name: "string false", input: `"false"`, want: false}, + {name: "string yes", input: `"yes"`, want: true}, + {name: "string no", input: `"no"`, want: false}, + {name: "string numeric", input: `"0.0"`, want: false}, + {name: "null", input: `null`, want: false}, + {name: "invalid string", input: `"maybe"`, expectErr: true}, + {name: "invalid object", input: `{}`, expectErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got Boolish + err := json.Unmarshal([]byte(tc.input), &got) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error for input %s", tc.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error for input %s: %v", tc.input, err) + } + if got.Bool() != tc.want { + t.Fatalf("got %v, want %v for input %s", got.Bool(), tc.want, tc.input) + } + }) + } +} + +func TestTemplateUnmarshal_PaletteReadyVariants(t *testing.T) { + payload := `{ + "status":"ok", + "data":[ + {"id":1,"name":"a","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":true,"globalColors":{},"textsWithGlobalColors":{}}, + {"id":2,"name":"b","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":1,"globalColors":{},"textsWithGlobalColors":{}}, + {"id":3,"name":"c","description":"","locale":"en","thumbnailUrl":"","templatePreviewUrl":"","type":"ai","paletteReady":"0","globalColors":{},"textsWithGlobalColors":{}} + ] + }` + + var rsp APIResponse[[]Template] + if err := json.Unmarshal([]byte(payload), &rsp); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rsp.Data) != 3 { + t.Fatalf("unexpected template count: got %d, want 3", len(rsp.Data)) + } + if !rsp.Data[0].PaletteReady.Bool() { + t.Fatalf("template 1 paletteReady should be true") + } + if !rsp.Data[1].PaletteReady.Bool() { + t.Fatalf("template 2 paletteReady should be true") + } + if rsp.Data[2].PaletteReady.Bool() { + t.Fatalf("template 3 paletteReady should be false") + } +} diff --git a/internal/store/interfaces.go b/internal/store/interfaces.go new file mode 100644 index 0000000..ec33586 --- /dev/null +++ b/internal/store/interfaces.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "qctextbuilder/internal/domain" +) + +type TemplateStore interface { + UpsertTemplates(ctx context.Context, templates []domain.Template) error + GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) + ListTemplates(ctx context.Context) ([]domain.Template, error) + SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error +} + +type ManifestStore interface { + CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error + GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) + ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) + UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error +} + +type BuildStore interface { + CreateBuild(ctx context.Context, build domain.SiteBuild) error + GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) + ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) + MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error + UpdateBuildFromJob(ctx context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error + UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error +} + +type SettingsStore interface{} diff --git a/internal/store/memory/store.go b/internal/store/memory/store.go new file mode 100644 index 0000000..b68ad11 --- /dev/null +++ b/internal/store/memory/store.go @@ -0,0 +1,237 @@ +package memory + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "qctextbuilder/internal/domain" +) + +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 +} + +func New() *Store { + return &Store{ + templates: make(map[int64]domain.Template), + manifests: make(map[int64]domain.TemplateManifest), + manifestField: make(map[string][]domain.TemplateField), + builds: make(map[string]domain.SiteBuild), + } +} + +func (s *Store) UpsertTemplates(_ context.Context, templates []domain.Template) error { + s.mu.Lock() + defer s.mu.Unlock() + + for _, t := range templates { + existing, ok := s.templates[t.ID] + if ok { + t.IsOnboarded = existing.IsOnboarded + t.ManifestStatus = existing.ManifestStatus + t.LastDiscoveredAt = existing.LastDiscoveredAt + } + s.templates[t.ID] = t + } + return nil +} + +func (s *Store) GetTemplateByID(_ context.Context, id int64) (*domain.Template, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + t, ok := s.templates[id] + if !ok { + return nil, ErrNotFound + } + copy := t + return ©, nil +} + +func (s *Store) ListTemplates(_ context.Context) ([]domain.Template, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]domain.Template, 0, len(s.templates)) + for _, t := range s.templates { + out = append(out, t) + } + return out, nil +} + +func (s *Store) SetTemplateManifestStatus(_ context.Context, templateID int64, status string, onboarded bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + t, ok := s.templates[templateID] + if !ok { + return ErrNotFound + } + t.ManifestStatus = status + t.IsOnboarded = onboarded + s.templates[templateID] = t + return nil +} + +func (s *Store) CreateManifest(_ context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.manifests[manifest.TemplateID] = manifest + s.manifestField[manifest.ID] = fields + return nil +} + +func (s *Store) GetActiveManifestByTemplateID(_ context.Context, templateID int64) (*domain.TemplateManifest, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + m, ok := s.manifests[templateID] + if !ok { + return nil, ErrNotFound + } + copy := m + return ©, nil +} + +func (s *Store) ListFieldsByManifestID(_ context.Context, manifestID string) ([]domain.TemplateField, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + fields, ok := s.manifestField[manifestID] + if !ok { + return nil, ErrNotFound + } + out := make([]domain.TemplateField, 0, len(fields)) + out = append(out, fields...) + return out, nil +} + +func (s *Store) UpdateFields(_ context.Context, manifestID string, fields []domain.TemplateField) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.manifestField[manifestID]; !ok { + return ErrNotFound + } + next := make([]domain.TemplateField, len(fields)) + copy(next, fields) + s.manifestField[manifestID] = next + return nil +} + +func (s *Store) CreateBuild(_ context.Context, build domain.SiteBuild) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.builds[build.ID]; exists { + return errors.New("build already exists") + } + s.builds[build.ID] = build + return nil +} + +func (s *Store) GetBuildByID(_ context.Context, id string) (*domain.SiteBuild, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + build, ok := s.builds[id] + if !ok { + return nil, ErrNotFound + } + copy := build + return ©, nil +} + +func (s *Store) ListBuildsByStatuses(_ context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + allowed := make(map[string]struct{}, len(statuses)) + for _, status := range statuses { + allowed[status] = struct{}{} + } + + out := make([]domain.SiteBuild, 0) + for _, build := range s.builds { + if len(allowed) > 0 { + if _, ok := allowed[build.QCStatus]; !ok { + continue + } + } + out = append(out, build) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *Store) MarkBuildSubmitted(_ context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + build, ok := s.builds[buildID] + if !ok { + return ErrNotFound + } + build.QCJobID = &jobID + build.QCStatus = status + build.QCResultJSON = cloneRaw(qcResult) + build.StartedAt = &startedAt + s.builds[buildID] = build + return nil +} + +func (s *Store) UpdateBuildFromJob(_ context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + build, ok := s.builds[buildID] + if !ok { + return ErrNotFound + } + build.QCStatus = status + build.QCResultJSON = cloneRaw(qcResult) + build.QCErrorJSON = cloneRaw(qcError) + build.QCPreviewURL = previewURL + if siteID != nil { + id := *siteID + build.QCSiteID = &id + } + build.FinishedAt = finishedAt + s.builds[buildID] = build + return nil +} + +func (s *Store) UpdateBuildEditorURL(_ context.Context, buildID string, editorURL string, qcResult json.RawMessage) error { + s.mu.Lock() + defer s.mu.Unlock() + + build, ok := s.builds[buildID] + if !ok { + return ErrNotFound + } + build.QCEditorURL = editorURL + build.QCResultJSON = cloneRaw(qcResult) + s.builds[buildID] = build + return nil +} + +func cloneRaw(raw json.RawMessage) json.RawMessage { + if raw == nil { + return nil + } + out := make([]byte, len(raw)) + copy(out, raw) + return json.RawMessage(out) +} diff --git a/internal/store/postgres/store.go b/internal/store/postgres/store.go new file mode 100644 index 0000000..1b07040 --- /dev/null +++ b/internal/store/postgres/store.go @@ -0,0 +1,3 @@ +package postgres + +// TODO(milestone-2): postgres-backed store implementation. diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go new file mode 100644 index 0000000..b10e5d3 --- /dev/null +++ b/internal/store/sqlite/store.go @@ -0,0 +1,3 @@ +package sqlite + +// TODO(milestone-2): sqlite-backed store implementation for local development. diff --git a/internal/templatesvc/service.go b/internal/templatesvc/service.go new file mode 100644 index 0000000..3ce345a --- /dev/null +++ b/internal/templatesvc/service.go @@ -0,0 +1,117 @@ +package templatesvc + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "qctextbuilder/internal/domain" + "qctextbuilder/internal/qcclient" + "qctextbuilder/internal/store" +) + +type Service struct { + qc qcclient.Client + templateStore store.TemplateStore + manifestStore store.ManifestStore +} + +type TemplateDetail struct { + Template *domain.Template `json:"template"` + Manifest *domain.TemplateManifest `json:"manifest,omitempty"` + Fields []domain.TemplateField `json:"fields"` +} + +func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service { + return &Service{ + qc: qc, + templateStore: templateStore, + manifestStore: manifestStore, + } +} + +func (s *Service) SyncAITemplates(ctx context.Context) ([]domain.Template, error) { + templates, err := s.qc.ListAITemplates(ctx) + if err != nil { + return nil, err + } + + out := make([]domain.Template, 0, len(templates)) + for _, t := range templates { + raw, _ := json.Marshal(t) + out = append(out, domain.Template{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Locale: strings.ToUpper(t.Locale), + ThumbnailURL: t.ThumbnailURL, + TemplatePreviewURL: t.TemplatePreviewURL, + Type: t.Type, + PaletteReady: t.PaletteReady.Bool(), + RawJSON: raw, + IsAITemplate: strings.EqualFold(t.Type, "ai"), + ManifestStatus: "missing", + }) + } + + if err := s.templateStore.UpsertTemplates(ctx, out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Service) ListTemplates(ctx context.Context) ([]domain.Template, error) { + templates, err := s.templateStore.ListTemplates(ctx) + if err != nil { + return nil, fmt.Errorf("list templates: %w", err) + } + + sort.Slice(templates, func(i, j int) bool { + if templates[i].Name == templates[j].Name { + return templates[i].ID < templates[j].ID + } + return templates[i].Name < templates[j].Name + }) + return templates, nil +} + +func (s *Service) GetTemplateDetail(ctx context.Context, templateID int64) (*TemplateDetail, error) { + template, err := s.templateStore.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, fmt.Errorf("get template: %w", err) + } + + detail := &TemplateDetail{ + Template: template, + Fields: []domain.TemplateField{}, + } + + manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID) + if err != nil { + if isNotFoundErr(err) { + return detail, nil + } + return nil, fmt.Errorf("get active manifest: %w", err) + } + + fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) + if err != nil { + return nil, fmt.Errorf("list fields: %w", err) + } + sort.Slice(fields, func(i, j int) bool { + if fields[i].DisplayOrder == fields[j].DisplayOrder { + return fields[i].Path < fields[j].Path + } + return fields[i].DisplayOrder < fields[j].DisplayOrder + }) + + detail.Manifest = manifest + detail.Fields = fields + return detail, nil +} + +func isNotFoundErr(err error) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "not found") +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..9594e14 --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,29 @@ +package validation + +import ( + "errors" + "strings" +) + +var ( + ErrMissingCompanyName = errors.New("globalData.companyName is required") + ErrMissingEmail = errors.New("globalData.email is required") + ErrMissingUsername = errors.New("globalData.username is required") +) + +func ValidateBuildGlobalData(globalData map[string]any) error { + company, _ := globalData["companyName"].(string) + email, _ := globalData["email"].(string) + username, _ := globalData["username"].(string) + + if strings.TrimSpace(company) == "" { + return ErrMissingCompanyName + } + if strings.TrimSpace(email) == "" { + return ErrMissingEmail + } + if strings.TrimSpace(username) == "" { + return ErrMissingUsername + } + return nil +} diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/run-local.ps1 b/run-local.ps1 new file mode 100644 index 0000000..8fe567b --- /dev/null +++ b/run-local.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$envFile = Join-Path $projectRoot '.env.local' + +if (-not (Test-Path $envFile)) { + throw "Missing .env.local at $envFile" +} + +Get-Content $envFile | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + + $parts = $line -split '=', 2 + if ($parts.Count -ne 2) { return } + + $name = $parts[0].Trim() + $value = $parts[1] + [System.Environment]::SetEnvironmentVariable($name, $value, 'Process') +} + +Write-Host "Starting QC Text Builder on $env:HTTP_ADDR" -ForegroundColor Green +Set-Location $projectRoot + +go run ./cmd/qctextbuilder diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/web/static/.keep b/web/static/.keep new file mode 100644 index 0000000..e69de29 diff --git a/web/templates/.keep b/web/templates/.keep new file mode 100644 index 0000000..e69de29 diff --git a/web/templates/_shared.gohtml b/web/templates/_shared.gohtml new file mode 100644 index 0000000..4d5170e --- /dev/null +++ b/web/templates/_shared.gohtml @@ -0,0 +1,49 @@ +{{define "nav"}} + +{{end}} + +{{define "head"}} + + + +{{end}} diff --git a/web/templates/build_detail.gohtml b/web/templates/build_detail.gohtml new file mode 100644 index 0000000..8b90a5e --- /dev/null +++ b/web/templates/build_detail.gohtml @@ -0,0 +1,60 @@ +{{define "build_detail"}} + + + + {{.Title}} + {{if gt .AutoRefreshSeconds 0}}{{end}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

Build Detail

+ + + + + + + + + + + + + +
Build ID{{.Build.ID}}
Template ID{{.Build.TemplateID}}
Manifest ID{{.Build.ManifestID}}
Request Name{{.Build.RequestName}}
Status{{.Build.QCStatus}}
QC Job ID{{if .Build.QCJobID}}{{.Build.QCJobID}}{{else}}-{{end}}
QC Site ID{{if .Build.QCSiteID}}{{.Build.QCSiteID}}{{else}}-{{end}}
Preview URL{{if .Build.QCPreviewURL}}open{{else}}-{{end}}
Editor URL{{if .Build.QCEditorURL}}open{{else}}-{{end}}
Started At{{if .Build.StartedAt}}{{.Build.StartedAt}}{{else}}-{{end}}
Finished At{{if .Build.FinishedAt}}{{.Build.FinishedAt}}{{else}}-{{end}}
+ + {{if .CanPoll}} +

Build is active. This page auto-refreshes every {{.AutoRefreshSeconds}} seconds.

+
+ +
+ {{end}} + {{if .CanFetchEditorURL}} +
+ +
+ {{end}} + +

Effective Global Data (sent to /sites)

+

{{prettyJSON .EffectiveGlobal}}

+ +

Stored Global Data JSON

+

{{prettyJSON .Build.GlobalDataJSON}}

+ +

AI Data JSON

+

{{prettyJSON .Build.AIDataJSON}}

+ +

Final /sites Payload

+

{{prettyJSON .Build.FinalSitesPayload}}

+ +

QC Result JSON

+

{{prettyJSON .Build.QCResultJSON}}

+ +

QC Error JSON

+

{{prettyJSON .Build.QCErrorJSON}}

+ + +{{end}} diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml new file mode 100644 index 0000000..1269b4f --- /dev/null +++ b/web/templates/build_new.gohtml @@ -0,0 +1,93 @@ +{{define "build_new"}} + + + + {{.Title}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

New Build

+ +
+ + + +
+ + {{if gt .SelectedTemplateID 0}} +
+ + + + +

Global Data

+
+ +

Basis / Firma

+
+
+
+
+
+
+ +

Kontakt

+
+
+
+
+ +

Firmeninformation

+
+
+
+
+
+
+
+ +

Adresse

+
+
+
+
+
+
+
+
+ +

Enabled Text Fields

+ + + + + + {{range $i, $f := .EnabledFields}} + + + + + + {{else}} + + {{end}} + +
FieldValueSample
+ + {{$f.DisplayLabel}}
{{$f.Path}} +
{{$f.SampleValue}}
No enabled text fields found for this template.
+ + +
+ {{end}} + + +{{end}} diff --git a/web/templates/home.gohtml b/web/templates/home.gohtml new file mode 100644 index 0000000..d696a8b --- /dev/null +++ b/web/templates/home.gohtml @@ -0,0 +1,22 @@ +{{define "home"}} + + + + {{.Title}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

QC Text Builder

+

Minimal admin UI for AI template workflow.

+ + +
Available templates{{.TemplateCount}}
+

+ Open Templates +

+ + +{{end}} diff --git a/web/templates/settings.gohtml b/web/templates/settings.gohtml new file mode 100644 index 0000000..545d487 --- /dev/null +++ b/web/templates/settings.gohtml @@ -0,0 +1,24 @@ +{{define "settings"}} + + + + {{.Title}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

Settings

+

Read-only summary for milestone 4.

+ + + + + + + +
QC Base URL{{.QCBaseURL}}
Bearer token configured{{if .TokenConfigured}}yes{{else}}no{{end}}
Poll interval (seconds){{.PollIntervalSeconds}}
Poll timeout (seconds){{.PollTimeoutSeconds}}
Poll max concurrent{{.PollMaxConcurrent}}
Language output mode{{.LanguageOutputMode}}
+ + +{{end}} diff --git a/web/templates/template_detail.gohtml b/web/templates/template_detail.gohtml new file mode 100644 index 0000000..59815c5 --- /dev/null +++ b/web/templates/template_detail.gohtml @@ -0,0 +1,83 @@ +{{define "template_detail"}} + + + + {{.Title}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

Template Detail

+ + + + + + + + + + +
ID{{.Detail.Template.ID}}
Name{{.Detail.Template.Name}}
Type{{.Detail.Template.Type}}
Manifest status{{.Detail.Template.ManifestStatus}}
Thumbnail + {{if .Detail.Template.ThumbnailURL}} + + open thumbnail + Template thumbnail for {{.Detail.Template.Name}} + + {{else}}none{{end}} +
Active manifest{{if .Detail.Manifest}}{{.Detail.Manifest.ID}}{{else}}none{{end}}
+ +
+ +
+ + {{if .Detail.Manifest}} +

Manifest

+

{{prettyJSON .Detail.Manifest.FlattenedManifestJSON}}

+ +

Fields

+
+ + + + + + + + + + + + + + + + + {{range $i, $f := .Fields}} + + + + + + + + + + + {{end}} + +
PathKindEnabledRequiredLabelOrderNotesSample
+ + {{$f.Path}} + {{$f.FieldKind}}{{$f.SampleValue}}
+ +
+ {{end}} + +

Raw Template JSON

+

{{prettyJSON .Detail.Template.RawJSON}}

+ + +{{end}} diff --git a/web/templates/templates.gohtml b/web/templates/templates.gohtml new file mode 100644 index 0000000..2aa6d1c --- /dev/null +++ b/web/templates/templates.gohtml @@ -0,0 +1,54 @@ +{{define "templates"}} + + + + {{.Title}} + {{template "head" .}} + + + {{template "nav" .}} + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +

Templates

+
+ +
+ + + + + + + + + + + + + + {{range .Templates}} + + + + + + + + + + {{else}} + + {{end}} + +
IDNameLocaleAIManifest StatusOnboardedPreview
{{.ID}}{{.Name}}{{.Locale}}{{if .IsAITemplate}}yes{{else}}no{{end}}{{.ManifestStatus}}{{if .IsOnboarded}}yes{{else}}no{{end}} + {{if .TemplatePreviewURL}}open{{end}} + {{if .ThumbnailURL}} + + thumbnail + Template thumbnail for {{.Name}} + + {{end}} +
No templates available. Run sync.
+ + +{{end}}