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 }