Files
FlowReader/internal/repository/article.go
T

548 lines
14 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/michael/flowreader/internal/domain"
)
// ArticleRepository implements domain.ArticleRepository using PostgreSQL.
type ArticleRepository struct {
pool *pgxpool.Pool
}
// NewArticleRepository creates a new article repository.
func NewArticleRepository(pool *pgxpool.Pool) *ArticleRepository {
return &ArticleRepository{pool: pool}
}
// Create inserts a new article into the database.
func (r *ArticleRepository) Create(article *domain.Article) error {
ctx := context.Background()
query := `
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (feed_id, guid) DO NOTHING
`
_, err := r.pool.Exec(ctx, query,
article.ID,
article.FeedID,
article.GUID,
article.Title,
nullString(article.URL),
nullString(article.Content),
nullString(article.Summary),
nullString(article.AISummary),
nullString(article.Author),
nullString(article.ImageURL),
article.PublishedAt,
article.CreatedAt,
)
if err != nil {
return fmt.Errorf("creating article: %w", err)
}
return nil
}
// CreateBatch inserts multiple articles into the database.
func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
ctx := context.Background()
batch := &pgx.Batch{}
query := `
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (feed_id, guid) DO NOTHING
`
for _, article := range articles {
batch.Queue(query,
article.ID,
article.FeedID,
article.GUID,
article.Title,
nullString(article.URL),
nullString(article.Content),
nullString(article.Summary),
nullString(article.AISummary),
nullString(article.Author),
nullString(article.ImageURL),
article.PublishedAt,
article.CreatedAt,
)
}
results := r.pool.SendBatch(ctx, batch)
defer results.Close()
for range articles {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("batch insert: %w", err)
}
}
return nil
}
// GetByID retrieves an article by its ID.
func (r *ArticleRepository) GetByID(id uuid.UUID) (*domain.Article, error) {
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE a.id = $1
`
article, err := r.scanArticle(r.pool.QueryRow(ctx, query, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting article by ID: %w", err)
}
return article, nil
}
// GetByFeedID retrieves articles for a specific feed.
func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([]*domain.Article, error) {
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE a.feed_id = $1
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, feedID, limit, offset)
if err != nil {
return nil, fmt.Errorf("querying articles: %w", err)
}
defer rows.Close()
return r.scanArticles(rows)
}
// GetByUserID retrieves articles for all user's feeds.
func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unreadOnly bool) ([]*domain.Article, error) {
ctx := context.Background()
var query string
if unreadOnly {
query = `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1 AND a.is_read = false
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
LIMIT $2 OFFSET $3
`
} else {
query = `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
LIMIT $2 OFFSET $3
`
}
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("querying articles: %w", err)
}
defer rows.Close()
return r.scanArticles(rows)
}
// GetByGUID retrieves an article by its GUID within a feed.
func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Article, error) {
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE a.feed_id = $1 AND a.guid = $2
`
article, err := r.scanArticle(r.pool.QueryRow(ctx, query, feedID, guid))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting article by GUID: %w", err)
}
return article, nil
}
// MarkAsRead marks an article as read.
func (r *ArticleRepository) MarkAsRead(id uuid.UUID) error {
ctx := context.Background()
query := `UPDATE articles SET is_read = true, read_at = $2 WHERE id = $1`
_, err := r.pool.Exec(ctx, query, id, time.Now())
if err != nil {
return fmt.Errorf("marking as read: %w", err)
}
return nil
}
// MarkAsUnread marks an article as unread.
func (r *ArticleRepository) MarkAsUnread(id uuid.UUID) error {
ctx := context.Background()
query := `UPDATE articles SET is_read = false, read_at = NULL WHERE id = $1`
_, err := r.pool.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("marking as unread: %w", err)
}
return nil
}
// MarkAllAsRead marks all articles in a feed as read.
func (r *ArticleRepository) MarkAllAsRead(feedID uuid.UUID) error {
ctx := context.Background()
query := `UPDATE articles SET is_read = true, read_at = $2 WHERE feed_id = $1 AND is_read = false`
_, err := r.pool.Exec(ctx, query, feedID, time.Now())
if err != nil {
return fmt.Errorf("marking all as read: %w", err)
}
return nil
}
// MarkAllAsReadGlobal marks all articles for a user as read.
func (r *ArticleRepository) MarkAllAsReadGlobal(userID uuid.UUID) error {
ctx := context.Background()
query := `
UPDATE articles
SET is_read = true, read_at = NOW()
WHERE feed_id IN (SELECT id FROM feeds WHERE user_id = $1) AND is_read = false
`
_, err := r.pool.Exec(ctx, query, userID)
if err != nil {
return fmt.Errorf("marking all articles as read globally: %w", err)
}
return nil
}
// ToggleFavorite toggles the favorite status of an article.
func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error {
ctx := context.Background()
query := `UPDATE articles SET is_favorite = NOT is_favorite WHERE id = $1`
_, err := r.pool.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("toggling favorite: %w", err)
}
return nil
}
// GetFavorites retrieves favorited articles for a user.
func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([]*domain.Article, error) {
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1 AND a.is_favorite = true
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("querying favorites: %w", err)
}
defer rows.Close()
return r.scanArticles(rows)
}
// CountUnread counts unread articles for a feed.
func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) {
ctx := context.Background()
query := `SELECT COUNT(*) FROM articles WHERE feed_id = $1 AND is_read = false`
var count int
err := r.pool.QueryRow(ctx, query, feedID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting unread: %w", err)
}
return count, nil
}
// scanArticle scans a single article row.
func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
var article domain.Article
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
err := row.Scan(
&article.ID,
&article.FeedID,
&article.GUID,
&article.Title,
&url,
&content,
&summary,
&aiSummary,
&author,
&imageURL,
&publishedAt,
&article.IsRead,
&article.IsFavorite,
&readAt,
&article.CreatedAt,
&feedTitle,
)
if err != nil {
return nil, err
}
if url != nil {
article.URL = *url
}
if content != nil {
article.Content = *content
}
if summary != nil {
article.Summary = *summary
}
if aiSummary != nil {
article.AISummary = *aiSummary
}
if author != nil {
article.Author = *author
}
if imageURL != nil {
article.ImageURL = *imageURL
}
if feedTitle != nil {
article.FeedTitle = *feedTitle
}
article.PublishedAt = publishedAt
article.ReadAt = readAt
return &article, nil
}
// scanArticles scans multiple article rows.
func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, error) {
var articles []*domain.Article
for rows.Next() {
var article domain.Article
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
err := rows.Scan(
&article.ID,
&article.FeedID,
&article.GUID,
&article.Title,
&url,
&content,
&summary,
&aiSummary,
&author,
&imageURL,
&publishedAt,
&article.IsRead,
&article.IsFavorite,
&readAt,
&article.CreatedAt,
&feedTitle,
)
if err != nil {
return nil, fmt.Errorf("scanning article: %w", err)
}
if url != nil {
article.URL = *url
}
if content != nil {
article.Content = *content
}
if summary != nil {
article.Summary = *summary
}
if aiSummary != nil {
article.AISummary = *aiSummary
}
if author != nil {
article.Author = *author
}
if imageURL != nil {
article.ImageURL = *imageURL
}
if feedTitle != nil {
article.FeedTitle = *feedTitle
}
article.PublishedAt = publishedAt
article.ReadAt = readAt
articles = append(articles, &article)
}
return articles, nil
}
// Search performs a full-text search on articles for a specific user.
func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset int) ([]*domain.Article, error) {
ctx := context.Background()
// Use plainto_tsquery or websearch_to_tsquery for natural language search
sql := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title,
ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1 AND a.tsv @@ websearch_to_tsquery('french', $2)
ORDER BY rank DESC, a.published_at DESC
LIMIT $3 OFFSET $4
`
rows, err := r.pool.Query(ctx, sql, userID, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("searching articles: %w", err)
}
defer rows.Close()
return r.scanArticlesWithRank(rows)
}
// scanArticlesWithRank scans multiple article rows with their rank.
func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Article, error) {
var articles []*domain.Article
for rows.Next() {
var article domain.Article
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
var rank float32
err := rows.Scan(
&article.ID,
&article.FeedID,
&article.GUID,
&article.Title,
&url,
&content,
&summary,
&aiSummary,
&author,
&imageURL,
&publishedAt,
&article.IsRead,
&article.IsFavorite,
&readAt,
&article.CreatedAt,
&feedTitle,
&rank,
)
if err != nil {
return nil, fmt.Errorf("scanning article with rank: %w", err)
}
if url != nil {
article.URL = *url
}
if content != nil {
article.Content = *content
}
if summary != nil {
article.Summary = *summary
}
if aiSummary != nil {
article.AISummary = *aiSummary
}
if author != nil {
article.Author = *author
}
if imageURL != nil {
article.ImageURL = *imageURL
}
if feedTitle != nil {
article.FeedTitle = *feedTitle
}
article.PublishedAt = publishedAt
article.ReadAt = readAt
articles = append(articles, &article)
}
return articles, nil
}
// UpdateAISummary updates the AI-generated summary of an article.
func (r *ArticleRepository) UpdateAISummary(id uuid.UUID, summary string) error {
ctx := context.Background()
query := `UPDATE articles SET ai_summary = $2 WHERE id = $1`
_, err := r.pool.Exec(ctx, query, id, summary)
if err != nil {
return fmt.Errorf("updating AI summary: %w", err)
}
return nil
}
// DeleteOldArticles removes articles older than the specified duration, except for favorites.
func (r *ArticleRepository) DeleteOldArticles(ctx context.Context, olderThan time.Duration) (int64, error) {
threshold := time.Now().Add(-olderThan)
query := `
DELETE FROM articles
WHERE created_at < $1 AND is_favorite = false
`
result, err := r.pool.Exec(ctx, query, threshold)
if err != nil {
return 0, fmt.Errorf("deleting old articles: %w", err)
}
return result.RowsAffected(), nil
}
// nullString returns nil if string is empty.
func nullString(s string) *string {
if s == "" {
return nil
}
return &s
}