feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
This commit is contained in:
323
backend/internal/db/user_interests_repo.go
Normal file
323
backend/internal/db/user_interests_repo.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserInterestsData struct {
|
||||
UserID string `json:"userId"`
|
||||
Topics json.RawMessage `json:"topics"`
|
||||
Sources json.RawMessage `json:"sources"`
|
||||
Keywords json.RawMessage `json:"keywords"`
|
||||
ViewHistory json.RawMessage `json:"viewHistory"`
|
||||
SavedArticles json.RawMessage `json:"savedArticles"`
|
||||
BlockedSources json.RawMessage `json:"blockedSources"`
|
||||
BlockedTopics json.RawMessage `json:"blockedTopics"`
|
||||
PreferredLang string `json:"preferredLang"`
|
||||
Region string `json:"region"`
|
||||
ReadingLevel string `json:"readingLevel"`
|
||||
Notifications json.RawMessage `json:"notifications"`
|
||||
CustomCategories json.RawMessage `json:"customCategories"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserInterestsRepository struct {
|
||||
db *PostgresDB
|
||||
}
|
||||
|
||||
func NewUserInterestsRepository(db *PostgresDB) *UserInterestsRepository {
|
||||
return &UserInterestsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) createTable(ctx context.Context) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS user_interests (
|
||||
user_id VARCHAR(255) PRIMARY KEY,
|
||||
topics JSONB DEFAULT '{}',
|
||||
sources JSONB DEFAULT '{}',
|
||||
keywords JSONB DEFAULT '{}',
|
||||
view_history JSONB DEFAULT '[]',
|
||||
saved_articles JSONB DEFAULT '[]',
|
||||
blocked_sources JSONB DEFAULT '[]',
|
||||
blocked_topics JSONB DEFAULT '[]',
|
||||
preferred_lang VARCHAR(10) DEFAULT 'ru',
|
||||
region VARCHAR(50) DEFAULT 'russia',
|
||||
reading_level VARCHAR(20) DEFAULT 'general',
|
||||
notifications JSONB DEFAULT '{}',
|
||||
custom_categories JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_interests_updated ON user_interests(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_interests_region ON user_interests(region);
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) Get(ctx context.Context, userID string) (*UserInterestsData, error) {
|
||||
query := `
|
||||
SELECT user_id, topics, sources, keywords, view_history, saved_articles,
|
||||
blocked_sources, blocked_topics, preferred_lang, region, reading_level,
|
||||
notifications, custom_categories, created_at, updated_at
|
||||
FROM user_interests
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
row := r.db.DB().QueryRowContext(ctx, query, userID)
|
||||
|
||||
var data UserInterestsData
|
||||
err := row.Scan(
|
||||
&data.UserID, &data.Topics, &data.Sources, &data.Keywords,
|
||||
&data.ViewHistory, &data.SavedArticles, &data.BlockedSources,
|
||||
&data.BlockedTopics, &data.PreferredLang, &data.Region,
|
||||
&data.ReadingLevel, &data.Notifications, &data.CustomCategories,
|
||||
&data.CreatedAt, &data.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) Save(ctx context.Context, data *UserInterestsData) error {
|
||||
query := `
|
||||
INSERT INTO user_interests (
|
||||
user_id, topics, sources, keywords, view_history, saved_articles,
|
||||
blocked_sources, blocked_topics, preferred_lang, region, reading_level,
|
||||
notifications, custom_categories, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
topics = EXCLUDED.topics,
|
||||
sources = EXCLUDED.sources,
|
||||
keywords = EXCLUDED.keywords,
|
||||
view_history = EXCLUDED.view_history,
|
||||
saved_articles = EXCLUDED.saved_articles,
|
||||
blocked_sources = EXCLUDED.blocked_sources,
|
||||
blocked_topics = EXCLUDED.blocked_topics,
|
||||
preferred_lang = EXCLUDED.preferred_lang,
|
||||
region = EXCLUDED.region,
|
||||
reading_level = EXCLUDED.reading_level,
|
||||
notifications = EXCLUDED.notifications,
|
||||
custom_categories = EXCLUDED.custom_categories,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
if data.CreatedAt.IsZero() {
|
||||
data.CreatedAt = now
|
||||
}
|
||||
data.UpdatedAt = now
|
||||
|
||||
if data.Topics == nil {
|
||||
data.Topics = json.RawMessage("{}")
|
||||
}
|
||||
if data.Sources == nil {
|
||||
data.Sources = json.RawMessage("{}")
|
||||
}
|
||||
if data.Keywords == nil {
|
||||
data.Keywords = json.RawMessage("{}")
|
||||
}
|
||||
if data.ViewHistory == nil {
|
||||
data.ViewHistory = json.RawMessage("[]")
|
||||
}
|
||||
if data.SavedArticles == nil {
|
||||
data.SavedArticles = json.RawMessage("[]")
|
||||
}
|
||||
if data.BlockedSources == nil {
|
||||
data.BlockedSources = json.RawMessage("[]")
|
||||
}
|
||||
if data.BlockedTopics == nil {
|
||||
data.BlockedTopics = json.RawMessage("[]")
|
||||
}
|
||||
if data.Notifications == nil {
|
||||
data.Notifications = json.RawMessage("{}")
|
||||
}
|
||||
if data.CustomCategories == nil {
|
||||
data.CustomCategories = json.RawMessage("[]")
|
||||
}
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query,
|
||||
data.UserID, data.Topics, data.Sources, data.Keywords,
|
||||
data.ViewHistory, data.SavedArticles, data.BlockedSources,
|
||||
data.BlockedTopics, data.PreferredLang, data.Region,
|
||||
data.ReadingLevel, data.Notifications, data.CustomCategories,
|
||||
data.CreatedAt, data.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) Delete(ctx context.Context, userID string) error {
|
||||
query := `DELETE FROM user_interests WHERE user_id = $1`
|
||||
_, err := r.db.DB().ExecContext(ctx, query, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) AddViewEvent(ctx context.Context, userID string, event json.RawMessage) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET view_history = CASE
|
||||
WHEN jsonb_array_length(view_history) >= 500
|
||||
THEN jsonb_build_array($2) || view_history[0:499]
|
||||
ELSE jsonb_build_array($2) || view_history
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
result, err := r.db.DB().ExecContext(ctx, query, userID, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
insertQuery := `
|
||||
INSERT INTO user_interests (user_id, view_history, updated_at)
|
||||
VALUES ($1, jsonb_build_array($2), NOW())
|
||||
`
|
||||
_, err = r.db.DB().ExecContext(ctx, insertQuery, userID, event)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) UpdateTopicScore(ctx context.Context, userID, topic string, delta float64) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET topics = topics || jsonb_build_object($2, COALESCE((topics->>$2)::float, 0) + $3),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
result, err := r.db.DB().ExecContext(ctx, query, userID, topic, delta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
insertQuery := `
|
||||
INSERT INTO user_interests (user_id, topics, updated_at)
|
||||
VALUES ($1, jsonb_build_object($2, $3), NOW())
|
||||
`
|
||||
_, err = r.db.DB().ExecContext(ctx, insertQuery, userID, topic, delta)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) SaveArticle(ctx context.Context, userID, articleURL string) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET saved_articles = CASE
|
||||
WHEN NOT saved_articles ? $2
|
||||
THEN saved_articles || jsonb_build_array($2)
|
||||
ELSE saved_articles
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query, userID, articleURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) UnsaveArticle(ctx context.Context, userID, articleURL string) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET saved_articles = saved_articles - $2,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query, userID, articleURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) BlockSource(ctx context.Context, userID, source string) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET blocked_sources = CASE
|
||||
WHEN NOT blocked_sources ? $2
|
||||
THEN blocked_sources || jsonb_build_array($2)
|
||||
ELSE blocked_sources
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query, userID, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) UnblockSource(ctx context.Context, userID, source string) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET blocked_sources = blocked_sources - $2,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query, userID, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) GetTopUsers(ctx context.Context, limit int) ([]string, error) {
|
||||
query := `
|
||||
SELECT user_id FROM user_interests
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
rows, err := r.db.DB().QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []string
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
|
||||
return userIDs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UserInterestsRepository) DecayAllInterests(ctx context.Context, decayFactor float64) error {
|
||||
query := `
|
||||
UPDATE user_interests
|
||||
SET topics = (
|
||||
SELECT jsonb_object_agg(key, (value::text::float * $1))
|
||||
FROM jsonb_each(topics) WHERE (value::text::float * $1) > 0.01
|
||||
),
|
||||
sources = (
|
||||
SELECT jsonb_object_agg(key, (value::text::float * $1))
|
||||
FROM jsonb_each(sources) WHERE (value::text::float * $1) > 0.01
|
||||
),
|
||||
keywords = (
|
||||
SELECT jsonb_object_agg(key, (value::text::float * $1))
|
||||
FROM jsonb_each(keywords) WHERE (value::text::float * $1) > 0.01
|
||||
),
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
_, err := r.db.DB().ExecContext(ctx, query, decayFactor)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user