package app import ( "context" "encoding/json" "strconv" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" ) type ArticleService struct { pool *pgxpool.Pool } func NewArticleService(pool *pgxpool.Pool) *ArticleService { return &ArticleService{pool: pool} } type ArticleListParams struct { Page int PageSize int GenerateStatus *string PublishStatus *string SourceType *string TemplateID *int64 Keyword *string } type ArticleListItem struct { ID int64 `json:"id"` SourceType string `json:"source_type"` TemplateName *string `json:"template_name"` GenerateStatus string `json:"generate_status"` PublishStatus string `json:"publish_status"` Title *string `json:"title"` WordCount int `json:"word_count"` SourceLabel *string `json:"source_label"` CreatedAt time.Time `json:"created_at"` } type ArticleListResponse struct { Items []ArticleListItem `json:"items"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) { actor := auth.MustActor(ctx) if params.Page < 1 { params.Page = 1 } if params.PageSize < 1 || params.PageSize > 100 { params.PageSize = 20 } offset := (params.Page - 1) * params.PageSize // Count var total int64 countArgs := []interface{}{actor.TenantID} countQuery := `SELECT COUNT(*) FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id WHERE a.tenant_id = $1 AND a.deleted_at IS NULL` argIdx := 2 if params.GenerateStatus != nil { countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *params.GenerateStatus) argIdx++ } if params.PublishStatus != nil { countQuery += ` AND a.publish_status = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *params.PublishStatus) argIdx++ } if params.SourceType != nil { countQuery += ` AND a.source_type = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *params.SourceType) argIdx++ } if params.TemplateID != nil { countQuery += ` AND a.template_id = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *params.TemplateID) argIdx++ } if params.Keyword != nil { countQuery += ` AND av.title ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'` countArgs = append(countArgs, *params.Keyword) } if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") } // List listQuery := `SELECT a.id, a.source_type, a.generate_status, a.publish_status, a.created_at, av.title, COALESCE(av.word_count, 0), av.source_label, t.template_name FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id LEFT JOIN article_templates t ON t.id = a.template_id WHERE a.tenant_id = $1 AND a.deleted_at IS NULL` listArgs := []interface{}{actor.TenantID} listIdx := 2 if params.GenerateStatus != nil { listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *params.GenerateStatus) listIdx++ } if params.PublishStatus != nil { listQuery += ` AND a.publish_status = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *params.PublishStatus) listIdx++ } if params.SourceType != nil { listQuery += ` AND a.source_type = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *params.SourceType) listIdx++ } if params.TemplateID != nil { listQuery += ` AND a.template_id = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *params.TemplateID) listIdx++ } if params.Keyword != nil { listQuery += ` AND av.title ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'` listArgs = append(listArgs, *params.Keyword) listIdx++ } listQuery += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(listIdx) + ` OFFSET $` + strconv.Itoa(listIdx+1) listArgs = append(listArgs, params.PageSize, offset) rows, err := s.pool.Query(ctx, listQuery, listArgs...) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list articles") } defer rows.Close() var items []ArticleListItem for rows.Next() { var item ArticleListItem if err := rows.Scan(&item.ID, &item.SourceType, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt, &item.Title, &item.WordCount, &item.SourceLabel, &item.TemplateName); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } items = append(items, item) } if items == nil { items = []ArticleListItem{} } return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil } type ArticleDetailResponse struct { ID int64 `json:"id"` SourceType string `json:"source_type"` TemplateID *int64 `json:"template_id"` TemplateName *string `json:"template_name"` GenerateStatus string `json:"generate_status"` PublishStatus string `json:"publish_status"` Title *string `json:"title"` HTMLContent *string `json:"html_content"` MarkdownContent *string `json:"markdown_content"` WordCount int `json:"word_count"` SourceLabel *string `json:"source_label"` VersionNo *int `json:"version_no"` GenerationErrorMessage *string `json:"generation_error_message"` CreatedAt time.Time `json:"created_at"` } func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) { actor := auth.MustActor(ctx) var d ArticleDetailResponse err := s.pool.QueryRow(ctx, ` SELECT a.id, a.source_type, a.template_id, a.generate_status, a.publish_status, a.created_at, av.title, av.html_content, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, av.version_no, t.template_name, gt.error_message FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id LEFT JOIN article_templates t ON t.id = a.template_id LEFT JOIN LATERAL ( SELECT error_message FROM generation_tasks WHERE article_id = a.id ORDER BY created_at DESC LIMIT 1 ) gt ON true WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL `, id, actor.TenantID).Scan(&d.ID, &d.SourceType, &d.TemplateID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt, &d.Title, &d.HTMLContent, &d.MarkdownContent, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage) if err != nil { return nil, response.ErrNotFound(40411, "article_not_found", "article not found") } return &d, nil } type VersionItem struct { ID int64 `json:"id"` VersionNo int `json:"version_no"` Title *string `json:"title"` WordCount int `json:"word_count"` SourceLabel *string `json:"source_label"` CreatedAt time.Time `json:"created_at"` } func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]VersionItem, error) { actor := auth.MustActor(ctx) // Verify ownership var exists bool _ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`, articleID, actor.TenantID).Scan(&exists) if !exists { return nil, response.ErrNotFound(40411, "article_not_found", "article not found") } rows, err := s.pool.Query(ctx, ` SELECT id, version_no, title, word_count, source_label, created_at FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC `, articleID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list versions") } defer rows.Close() var versions []VersionItem for rows.Next() { var v VersionItem if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } versions = append(versions, v) } if versions == nil { versions = []VersionItem{} } return versions, nil } func (s *ArticleService) Delete(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) // Read before_json for audit var beforeJSON []byte _ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID).Scan(&beforeJSON) tag, err := s.pool.Exec(ctx, ` UPDATE articles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40411, "article_not_found", "article not found") } // Audit log _, _ = s.pool.Exec(ctx, ` INSERT INTO audit_logs (operator_id, tenant_id, module, action, before_json, result) VALUES ($1, $2, 'article', 'delete', $3, 'success') `, actor.UserID, actor.TenantID, json.RawMessage(beforeJSON)) return nil }