mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
548 lines
14 KiB
Go
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
|
|
}
|