package repository import ( "context" "strings" "github.com/geo-platform/tenant-api/internal/shared/contentstats" "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" "github.com/jackc/pgx/v5" ) type CreateArticleInput struct { TenantID int64 SourceType string TemplateID *int64 KolPromptID *int64 } type CreateArticleVersionInput struct { ArticleID int64 VersionNo int Title string HTMLContent string MarkdownContent string WordCount int SourceLabel string } type ArticleRepository interface { CreateArticle(ctx context.Context, input CreateArticleInput) (int64, error) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error UpdateArticleGenerateStatus(ctx context.Context, articleID, tenantID int64, status string) error } type articleRepository struct { db generated.DBTX q generated.Querier } func NewArticleRepository(db generated.DBTX) ArticleRepository { return &articleRepository{db: db, q: newQuerier(db)} } func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArticleInput) (int64, error) { return r.q.CreateArticle(ctx, generated.CreateArticleParams{ TenantID: input.TenantID, SourceType: input.SourceType, TemplateID: pgInt8(input.TemplateID), KolPromptID: pgInt8(input.KolPromptID), }) } func NextArticleVersionNo(ctx context.Context, db generated.DBTX, articleID int64) (int, error) { var nextVersionNo int err := db.QueryRow(ctx, ` SELECT COALESCE(MAX(version_no), 0) + 1 FROM article_versions WHERE article_id = $1 `, articleID).Scan(&nextVersionNo) return nextVersionNo, err } func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) { if input.VersionNo <= 0 { nextVersionNo, err := NextArticleVersionNo(ctx, r.db, input.ArticleID) if err != nil { return 0, err } input.VersionNo = nextVersionNo } previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID) if err != nil { return 0, err } storedHTML, storedMarkdown := buildArticleVersionDiffStorage(*previous, ArticleVersionContent{ HTMLContent: stringPtr(input.HTMLContent), MarkdownContent: stringPtr(input.MarkdownContent), }) var versionID int64 err = r.db.QueryRow(ctx, ` INSERT INTO article_versions ( article_id, version_no, title, html_content, markdown_content, word_count, source_label, plaintext_snapshot ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, input.ArticleID, input.VersionNo, pgText(&input.Title), pgText(storedHTML), pgText(storedMarkdown), input.WordCount, pgText(&input.SourceLabel), strings.TrimSpace(input.Title+"\n"+ExtractArticlePlaintext(input.MarkdownContent)), ).Scan(&versionID) if err != nil { return 0, err } return versionID, nil } func ExtractArticlePlaintext(markdown string) string { markdown = strings.TrimSpace(markdown) if markdown == "" { return "" } var builder strings.Builder builder.Grow(len(markdown)) inFence := false lines := strings.Split(markdown, "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { inFence = !inFence continue } if inFence { builder.WriteString(trimmed) builder.WriteByte('\n') continue } line = stripMarkdownLine(line) if strings.TrimSpace(line) == "" { continue } builder.WriteString(line) builder.WriteByte('\n') } return strings.TrimSpace(builder.String()) } func stripMarkdownLine(line string) string { replacer := strings.NewReplacer( "#", " ", "*", " ", "_", " ", "`", " ", ">", " ", "[", " ", "]", " ", "(", " ", ")", " ", "!", " ", "|", " ", ) line = replacer.Replace(line) line = strings.TrimLeft(line, "-+0123456789. \t") return strings.Join(strings.Fields(line), " ") } func CountArticlePlaintextWords(markdown string) int { return contentstats.CountWords(ExtractArticlePlaintext(markdown)) } func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error { tag, err := r.db.Exec(ctx, ` UPDATE articles SET current_version_id = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL `, pgInt8(&versionID), articleID, tenantID) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } func (r *articleRepository) UpdateArticleGenerateStatus(ctx context.Context, articleID, tenantID int64, status string) error { tag, err := r.db.Exec(ctx, ` UPDATE articles SET generate_status = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL `, status, articleID, tenantID) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil }