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
324 lines
9.0 KiB
Go
324 lines
9.0 KiB
Go
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
|
|
}
|