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 ─────────────────────────────────────────────────────────────── // InsertArticleIfNew insère l'article uniquement s'il n'existe pas déjà (par URL). // Retourne (article, true, nil) si inséré, (nil, false, nil) si déjà présent. func (r *Repository) InsertArticleIfNew(sourceID, title, content, url string, publishedAt *time.Time) (*Article, bool, error) { var pa sql.NullTime if publishedAt != nil { pa = sql.NullTime{Time: *publishedAt, Valid: true} } a := &Article{} err := r.db.QueryRow(` INSERT INTO articles (source_id, title, content, url, published_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (url) DO NOTHING 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) if err == sql.ErrNoRows { return nil, false, nil // déjà présent } if err != nil { return nil, false, err } return a, true, nil } 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 COALESCE(a.published_at, 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 } // ── Schedule ─────────────────────────────────────────────────────────────── func (r *Repository) ListScheduleSlots() ([]ScheduleSlot, error) { rows, err := r.db.Query(` SELECT id, day_of_week, hour, minute FROM scrape_schedules ORDER BY day_of_week, hour, minute`) if err != nil { return nil, err } defer rows.Close() var slots []ScheduleSlot for rows.Next() { var s ScheduleSlot if err := rows.Scan(&s.ID, &s.DayOfWeek, &s.Hour, &s.Minute); err != nil { return nil, err } slots = append(slots, s) } return slots, nil } func (r *Repository) ReplaceSchedule(slots []ScheduleSlot) error { tx, err := r.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM scrape_schedules`); err != nil { return err } for _, s := range slots { if _, err := tx.Exec( `INSERT INTO scrape_schedules (day_of_week, hour, minute) VALUES ($1, $2, $3) ON CONFLICT (day_of_week, hour, minute) DO NOTHING`, s.DayOfWeek, s.Hour, s.Minute, ); err != nil { return err } } return tx.Commit() } // ── Settings ─────────────────────────────────────────────────────────────── 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 } // ── Reports ──────────────────────────────────────────────────────────────── func (r *Repository) CreatePendingReport(userID string, summaryID *string, excerpt, question string) (*Report, error) { rep := &Report{} err := r.db.QueryRow(` INSERT INTO reports (user_id, summary_id, context_excerpt, question, answer, status) VALUES ($1, $2, $3, $4, '', 'generating') RETURNING id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at`, userID, summaryID, excerpt, question, ).Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt) return rep, err } func (r *Repository) UpdateReport(id, status, answer, errorMsg string) error { _, err := r.db.Exec(` UPDATE reports SET status=$1, answer=$2, error_msg=$3 WHERE id=$4`, status, answer, errorMsg, id) return err } func (r *Repository) ListReports(userID string) ([]Report, error) { rows, err := r.db.Query(` SELECT id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at FROM reports WHERE user_id=$1 ORDER BY created_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() var reports []Report for rows.Next() { var rep Report if err := rows.Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt); err != nil { return nil, err } reports = append(reports, rep) } return reports, nil } func (r *Repository) DeleteReport(id, userID string) error { _, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID) return err }