feat: add frontend + backend + database to retrieve and compute news from Yahoo

This commit is contained in:
2026-04-18 23:53:57 +02:00
parent f9b6d35c49
commit 93668273ff
84 changed files with 15431 additions and 0 deletions

View File

@ -0,0 +1,538 @@
package models
import (
"database/sql"
"fmt"
"strings"
"time"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// ── Users ──────────────────────────────────────────────────────────────────
func (r *Repository) CreateUser(email, passwordHash string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, passwordHash, role,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) GetUserByEmail(email string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE email = $1`, email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) GetUserByID(id string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) ListUsers() ([]User, error) {
rows, err := r.db.Query(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func (r *Repository) UpdateUser(id, email string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
UPDATE users SET email=$1, role=$2, updated_at=NOW()
WHERE id=$3
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, role, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) DeleteUser(id string) error {
_, err := r.db.Exec(`DELETE FROM users WHERE id=$1`, id)
return err
}
func (r *Repository) CountAdmins() (int, error) {
var count int
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&count)
return count, err
}
// ── User Assets ────────────────────────────────────────────────────────────
func (r *Repository) GetUserAssets(userID string) ([]UserAsset, error) {
rows, err := r.db.Query(`
SELECT id, user_id, symbol, name, created_at
FROM user_assets WHERE user_id=$1 ORDER BY symbol`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var assets []UserAsset
for rows.Next() {
var a UserAsset
if err := rows.Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt); err != nil {
return nil, err
}
assets = append(assets, a)
}
return assets, nil
}
func (r *Repository) AddUserAsset(userID, symbol, name string) (*UserAsset, error) {
a := &UserAsset{}
err := r.db.QueryRow(`
INSERT INTO user_assets (user_id, symbol, name)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, symbol) DO UPDATE SET name=EXCLUDED.name
RETURNING id, user_id, symbol, name, created_at`,
userID, strings.ToUpper(symbol), name,
).Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt)
return a, err
}
func (r *Repository) RemoveUserAsset(userID, symbol string) error {
_, err := r.db.Exec(`
DELETE FROM user_assets WHERE user_id=$1 AND symbol=$2`,
userID, strings.ToUpper(symbol))
return err
}
func (r *Repository) GetAllWatchedSymbols() ([]string, error) {
rows, err := r.db.Query(`SELECT DISTINCT symbol FROM user_assets ORDER BY symbol`)
if err != nil {
return nil, err
}
defer rows.Close()
var symbols []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
symbols = append(symbols, s)
}
return symbols, nil
}
// ── Sources ────────────────────────────────────────────────────────────────
func (r *Repository) ListSources() ([]Source, error) {
rows, err := r.db.Query(`SELECT id, name, type, enabled, created_at FROM sources ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []Source
for rows.Next() {
var s Source
if err := rows.Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt); err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, nil
}
func (r *Repository) GetSourceByType(sourceType string) (*Source, error) {
s := &Source{}
err := r.db.QueryRow(`
SELECT id, name, type, enabled, created_at FROM sources WHERE type=$1`, sourceType,
).Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return s, err
}
func (r *Repository) UpdateSource(id string, enabled bool) error {
_, err := r.db.Exec(`UPDATE sources SET enabled=$1 WHERE id=$2`, enabled, id)
return err
}
// ── Articles ───────────────────────────────────────────────────────────────
func (r *Repository) UpsertArticle(sourceID, title, content, url string, publishedAt *time.Time) (*Article, error) {
a := &Article{}
var pa sql.NullTime
if publishedAt != nil {
pa = sql.NullTime{Time: *publishedAt, Valid: true}
}
err := r.db.QueryRow(`
INSERT INTO articles (source_id, title, content, url, published_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (url) DO UPDATE SET title=EXCLUDED.title, content=EXCLUDED.content
RETURNING id, source_id, title, content, url, published_at, created_at`,
sourceID, title, content, url, pa,
).Scan(&a.ID, &a.SourceID, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
return a, err
}
func (r *Repository) AddArticleSymbol(articleID, symbol string) error {
_, err := r.db.Exec(`
INSERT INTO article_symbols (article_id, symbol)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
articleID, strings.ToUpper(symbol))
return err
}
func (r *Repository) ListArticles(symbol string, limit, offset int) ([]Article, error) {
query := `
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id`
args := []interface{}{}
if symbol != "" {
query += `
JOIN article_symbols asy ON asy.article_id = a.id AND asy.symbol = $1`
args = append(args, strings.ToUpper(symbol))
}
query += ` ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetArticleByID(id string) (*Article, error) {
a := &Article{}
err := r.db.QueryRow(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a JOIN sources s ON s.id=a.source_id
WHERE a.id=$1`, id,
).Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (r *Repository) GetRecentArticles(hours int) ([]Article, error) {
rows, err := r.db.Query(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
WHERE a.created_at > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`, hours)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetRecentArticlesForSymbols(symbols []string, hours int) ([]Article, error) {
if len(symbols) == 0 {
return nil, nil
}
placeholders := make([]string, len(symbols))
args := []interface{}{hours}
for i, s := range symbols {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, strings.ToUpper(s))
}
query := fmt.Sprintf(`
SELECT DISTINCT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
JOIN article_symbols asy ON asy.article_id = a.id
WHERE asy.symbol IN (%s)
AND a.created_at > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`,
strings.Join(placeholders, ","))
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
// ── Scrape Credentials ─────────────────────────────────────────────────────
func (r *Repository) GetCredentials(sourceID string) (*ScrapeCredential, error) {
c := &ScrapeCredential{}
err := r.db.QueryRow(`
SELECT id, source_id, username, password_encrypted, updated_at
FROM scrape_credentials WHERE source_id=$1`, sourceID,
).Scan(&c.ID, &c.SourceID, &c.Username, &c.PasswordEncrypted, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
func (r *Repository) UpsertCredentials(sourceID, username, passwordEncrypted string) error {
_, err := r.db.Exec(`
INSERT INTO scrape_credentials (source_id, username, password_encrypted)
VALUES ($1, $2, $3)
ON CONFLICT (source_id) DO UPDATE
SET username=EXCLUDED.username, password_encrypted=EXCLUDED.password_encrypted, updated_at=NOW()`,
sourceID, username, passwordEncrypted)
return err
}
// ── Scrape Jobs ────────────────────────────────────────────────────────────
func (r *Repository) CreateScrapeJob(sourceID string) (*ScrapeJob, error) {
j := &ScrapeJob{}
err := r.db.QueryRow(`
INSERT INTO scrape_jobs (source_id) VALUES ($1)
RETURNING id, source_id, status, started_at, finished_at, articles_found, error_msg, created_at`,
sourceID,
).Scan(&j.ID, &j.SourceID, &j.Status, &j.StartedAt, &j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt)
return j, err
}
func (r *Repository) UpdateScrapeJob(id, status string, articlesFound int, errMsg string) error {
var finishedAt *time.Time
if status == "done" || status == "error" {
now := time.Now()
finishedAt = &now
}
_, err := r.db.Exec(`
UPDATE scrape_jobs
SET status=$1, articles_found=$2, error_msg=$3, finished_at=$4,
started_at=CASE WHEN status='pending' THEN NOW() ELSE started_at END
WHERE id=$5`,
status, articlesFound, errMsg, finishedAt, id)
return err
}
func (r *Repository) ListScrapeJobs(limit int) ([]ScrapeJob, error) {
rows, err := r.db.Query(`
SELECT j.id, j.source_id, s.name, j.status, j.started_at, j.finished_at,
j.articles_found, j.error_msg, j.created_at
FROM scrape_jobs j JOIN sources s ON s.id=j.source_id
ORDER BY j.created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var jobs []ScrapeJob
for rows.Next() {
var j ScrapeJob
if err := rows.Scan(&j.ID, &j.SourceID, &j.SourceName, &j.Status, &j.StartedAt,
&j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt); err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, nil
}
// ── AI Providers ───────────────────────────────────────────────────────────
func (r *Repository) ListAIProviders() ([]AIProvider, error) {
rows, err := r.db.Query(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var providers []AIProvider
for rows.Next() {
var p AIProvider
if err := rows.Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt); err != nil {
return nil, err
}
providers = append(providers, p)
}
return providers, nil
}
func (r *Repository) GetAIProviderByID(id string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE id=$1`, id,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) GetActiveAIProvider() (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE is_active=TRUE LIMIT 1`,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) CreateAIProvider(name, apiKeyEncrypted, model, endpoint string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
INSERT INTO ai_providers (name, api_key_encrypted, model, endpoint)
VALUES ($1, $2, $3, $4)
RETURNING id, name, api_key_encrypted, model, endpoint, is_active, created_at`,
name, apiKeyEncrypted, model, endpoint,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
return p, err
}
func (r *Repository) UpdateAIProvider(id, apiKeyEncrypted, model, endpoint string) error {
_, err := r.db.Exec(`
UPDATE ai_providers SET api_key_encrypted=$1, model=$2, endpoint=$3 WHERE id=$4`,
apiKeyEncrypted, model, endpoint, id)
return err
}
func (r *Repository) SetActiveAIProvider(id string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=FALSE`); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=TRUE WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) DeleteAIProvider(id string) error {
_, err := r.db.Exec(`DELETE FROM ai_providers WHERE id=$1`, id)
return err
}
// ── Summaries ──────────────────────────────────────────────────────────────
func (r *Repository) CreateSummary(userID, content string, providerID *string) (*Summary, error) {
s := &Summary{}
err := r.db.QueryRow(`
INSERT INTO summaries (user_id, content, ai_provider_id)
VALUES ($1, $2, $3)
RETURNING id, user_id, content, ai_provider_id, generated_at`,
userID, content, providerID,
).Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt)
return s, err
}
func (r *Repository) ListSummaries(userID string, limit int) ([]Summary, error) {
rows, err := r.db.Query(`
SELECT id, user_id, content, ai_provider_id, generated_at
FROM summaries WHERE user_id=$1
ORDER BY generated_at DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []Summary
for rows.Next() {
var s Summary
if err := rows.Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt); err != nil {
return nil, err
}
summaries = append(summaries, s)
}
return summaries, nil
}
// ── Settings ───────────────────────────────────────────────────────────────
func (r *Repository) GetSetting(key string) (string, error) {
var value string
err := r.db.QueryRow(`SELECT value FROM settings WHERE key=$1`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func (r *Repository) SetSetting(key, value string) error {
_, err := r.db.Exec(`
INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`,
key, value)
return err
}
func (r *Repository) ListSettings() ([]Setting, error) {
rows, err := r.db.Query(`SELECT key, value FROM settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
var settings []Setting
for rows.Next() {
var s Setting
if err := rows.Scan(&s.Key, &s.Value); err != nil {
return nil, err
}
settings = append(settings, s)
}
return settings, nil
}