package admin import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } func (r *UserRepository) List(ctx context.Context, page, perPage int, search string) ([]User, int, error) { offset := (page - 1) * perPage countQuery := `SELECT COUNT(*) FROM users WHERE 1=1` listQuery := `SELECT id, email, display_name, avatar_url, role, tier, is_active, last_login_at, created_at, updated_at FROM users WHERE 1=1` args := []interface{}{} argIndex := 1 if search != "" { searchCondition := fmt.Sprintf(` AND (email ILIKE $%d OR display_name ILIKE $%d)`, argIndex, argIndex+1) countQuery += searchCondition listQuery += searchCondition searchPattern := "%" + search + "%" args = append(args, searchPattern, searchPattern) argIndex += 2 } listQuery += fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argIndex, argIndex+1) args = append(args, perPage, offset) var total int if err := r.db.QueryRowContext(ctx, countQuery, args[:len(args)-2]...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, listQuery, args...) if err != nil { return nil, 0, err } defer rows.Close() var users []User for rows.Next() { var u User var lastLoginAt sql.NullTime var avatarURL sql.NullString if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &avatarURL, &u.Role, &u.Tier, &u.IsActive, &lastLoginAt, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, 0, err } if lastLoginAt.Valid { u.LastLoginAt = &lastLoginAt.Time } if avatarURL.Valid { u.AvatarURL = avatarURL.String } users = append(users, u) } return users, total, nil } func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) { query := `SELECT id, email, display_name, avatar_url, role, tier, is_active, last_login_at, created_at, updated_at FROM users WHERE id = $1` var u User var lastLoginAt sql.NullTime var avatarURL sql.NullString err := r.db.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Email, &u.DisplayName, &avatarURL, &u.Role, &u.Tier, &u.IsActive, &lastLoginAt, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } if lastLoginAt.Valid { u.LastLoginAt = &lastLoginAt.Time } if avatarURL.Valid { u.AvatarURL = avatarURL.String } return &u, nil } func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) { query := `SELECT id, email, password_hash, display_name, avatar_url, role, tier, is_active, last_login_at, created_at, updated_at FROM users WHERE email = $1` var u User var lastLoginAt sql.NullTime var avatarURL sql.NullString err := r.db.QueryRowContext(ctx, query, email).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &avatarURL, &u.Role, &u.Tier, &u.IsActive, &lastLoginAt, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } if lastLoginAt.Valid { u.LastLoginAt = &lastLoginAt.Time } if avatarURL.Valid { u.AvatarURL = avatarURL.String } return &u, nil } func (r *UserRepository) Create(ctx context.Context, req *UserCreateRequest) (*User, error) { id := uuid.New().String() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } role := req.Role if role == "" { role = RoleUser } tier := req.Tier if tier == "" { tier = TierFree } query := `INSERT INTO users (id, email, password_hash, display_name, role, tier, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, true, NOW(), NOW()) RETURNING id, email, display_name, role, tier, is_active, created_at, updated_at` var u User err = r.db.QueryRowContext(ctx, query, id, req.Email, string(hashedPassword), req.DisplayName, role, tier). Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Tier, &u.IsActive, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } return &u, nil } func (r *UserRepository) Update(ctx context.Context, id string, req *UserUpdateRequest) (*User, error) { sets := []string{} args := []interface{}{} argIndex := 1 if req.Email != nil { sets = append(sets, fmt.Sprintf("email = $%d", argIndex)) args = append(args, *req.Email) argIndex++ } if req.DisplayName != nil { sets = append(sets, fmt.Sprintf("display_name = $%d", argIndex)) args = append(args, *req.DisplayName) argIndex++ } if req.Role != nil { sets = append(sets, fmt.Sprintf("role = $%d", argIndex)) args = append(args, *req.Role) argIndex++ } if req.Tier != nil { sets = append(sets, fmt.Sprintf("tier = $%d", argIndex)) args = append(args, *req.Tier) argIndex++ } if req.IsActive != nil { sets = append(sets, fmt.Sprintf("is_active = $%d", argIndex)) args = append(args, *req.IsActive) argIndex++ } if len(sets) == 0 { return r.GetByID(ctx, id) } sets = append(sets, "updated_at = NOW()") args = append(args, id) query := fmt.Sprintf(`UPDATE users SET %s WHERE id = $%d RETURNING id, email, display_name, avatar_url, role, tier, is_active, last_login_at, created_at, updated_at`, strings.Join(sets, ", "), argIndex) var u User var lastLoginAt sql.NullTime var avatarURL sql.NullString err := r.db.QueryRowContext(ctx, query, args...).Scan(&u.ID, &u.Email, &u.DisplayName, &avatarURL, &u.Role, &u.Tier, &u.IsActive, &lastLoginAt, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } if lastLoginAt.Valid { u.LastLoginAt = &lastLoginAt.Time } if avatarURL.Valid { u.AvatarURL = avatarURL.String } return &u, nil } func (r *UserRepository) Delete(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id) return err } func (r *UserRepository) Count(ctx context.Context, filter string) (int, error) { query := `SELECT COUNT(*) FROM users` if filter != "" { query += ` WHERE role = $1` var count int err := r.db.QueryRowContext(ctx, query, filter).Scan(&count) return count, err } var count int err := r.db.QueryRowContext(ctx, query).Scan(&count) return count, err } func (r *UserRepository) CountActive(ctx context.Context) (int, error) { query := `SELECT COUNT(*) FROM users WHERE is_active = true AND last_login_at > NOW() - INTERVAL '30 days'` var count int err := r.db.QueryRowContext(ctx, query).Scan(&count) return count, err } type PostRepository struct { db *sql.DB } func NewPostRepository(db *sql.DB) *PostRepository { return &PostRepository{db: db} } func (r *PostRepository) List(ctx context.Context, page, perPage int, status, category string) ([]Post, int, error) { offset := (page - 1) * perPage countQuery := `SELECT COUNT(*) FROM posts WHERE 1=1` listQuery := `SELECT p.id, p.title, p.slug, p.content, p.excerpt, p.cover_image, p.author_id, COALESCE(u.display_name, u.email, 'Unknown') as author_name, p.category, p.tags, p.status, p.view_count, p.published_at, p.created_at, p.updated_at FROM posts p LEFT JOIN users u ON p.author_id = u.id WHERE 1=1` args := []interface{}{} argIndex := 1 if status != "" { condition := fmt.Sprintf(` AND p.status = $%d`, argIndex) countQuery += strings.Replace(condition, "p.", "", 1) listQuery += condition args = append(args, status) argIndex++ } if category != "" { condition := fmt.Sprintf(` AND p.category = $%d`, argIndex) countQuery += strings.Replace(condition, "p.", "", 1) listQuery += condition args = append(args, category) argIndex++ } listQuery += fmt.Sprintf(` ORDER BY p.created_at DESC LIMIT $%d OFFSET $%d`, argIndex, argIndex+1) args = append(args, perPage, offset) var total int countArgs := args[:len(args)-2] if len(countArgs) == 0 { countArgs = nil } if err := r.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, listQuery, args...) if err != nil { return nil, 0, err } defer rows.Close() var posts []Post for rows.Next() { var p Post var publishedAt sql.NullTime var excerpt, coverImage sql.NullString var tagsJSON []byte if err := rows.Scan(&p.ID, &p.Title, &p.Slug, &p.Content, &excerpt, &coverImage, &p.AuthorID, &p.AuthorName, &p.Category, &tagsJSON, &p.Status, &p.ViewCount, &publishedAt, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, 0, err } if publishedAt.Valid { p.PublishedAt = &publishedAt.Time } if excerpt.Valid { p.Excerpt = excerpt.String } if coverImage.Valid { p.CoverImage = coverImage.String } if tagsJSON != nil { json.Unmarshal(tagsJSON, &p.Tags) } posts = append(posts, p) } return posts, total, nil } func (r *PostRepository) GetByID(ctx context.Context, id string) (*Post, error) { query := `SELECT p.id, p.title, p.slug, p.content, p.excerpt, p.cover_image, p.author_id, COALESCE(u.display_name, u.email, 'Unknown') as author_name, p.category, p.tags, p.status, p.view_count, p.published_at, p.created_at, p.updated_at FROM posts p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = $1` var p Post var publishedAt sql.NullTime var excerpt, coverImage sql.NullString var tagsJSON []byte err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Title, &p.Slug, &p.Content, &excerpt, &coverImage, &p.AuthorID, &p.AuthorName, &p.Category, &tagsJSON, &p.Status, &p.ViewCount, &publishedAt, &p.CreatedAt, &p.UpdatedAt) if err != nil { return nil, err } if publishedAt.Valid { p.PublishedAt = &publishedAt.Time } if excerpt.Valid { p.Excerpt = excerpt.String } if coverImage.Valid { p.CoverImage = coverImage.String } if tagsJSON != nil { json.Unmarshal(tagsJSON, &p.Tags) } return &p, nil } func (r *PostRepository) Create(ctx context.Context, authorID string, req *PostCreateRequest) (*Post, error) { id := uuid.New().String() slug := generateSlug(req.Title) tagsJSON, _ := json.Marshal(req.Tags) status := req.Status if status == "" { status = string(PostStatusDraft) } query := `INSERT INTO posts (id, title, slug, content, excerpt, cover_image, author_id, category, tags, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING id, title, slug, content, excerpt, cover_image, author_id, category, tags, status, view_count, published_at, created_at, updated_at` var p Post var publishedAt sql.NullTime var excerpt, coverImage sql.NullString var returnedTags []byte err := r.db.QueryRowContext(ctx, query, id, req.Title, slug, req.Content, req.Excerpt, req.CoverImage, authorID, req.Category, tagsJSON, status). Scan(&p.ID, &p.Title, &p.Slug, &p.Content, &excerpt, &coverImage, &p.AuthorID, &p.Category, &returnedTags, &p.Status, &p.ViewCount, &publishedAt, &p.CreatedAt, &p.UpdatedAt) if err != nil { return nil, err } if publishedAt.Valid { p.PublishedAt = &publishedAt.Time } if excerpt.Valid { p.Excerpt = excerpt.String } if coverImage.Valid { p.CoverImage = coverImage.String } if returnedTags != nil { json.Unmarshal(returnedTags, &p.Tags) } return &p, nil } func (r *PostRepository) Update(ctx context.Context, id string, req *PostUpdateRequest) (*Post, error) { sets := []string{} args := []interface{}{} argIndex := 1 if req.Title != nil { sets = append(sets, fmt.Sprintf("title = $%d", argIndex)) args = append(args, *req.Title) argIndex++ sets = append(sets, fmt.Sprintf("slug = $%d", argIndex)) args = append(args, generateSlug(*req.Title)) argIndex++ } if req.Content != nil { sets = append(sets, fmt.Sprintf("content = $%d", argIndex)) args = append(args, *req.Content) argIndex++ } if req.Excerpt != nil { sets = append(sets, fmt.Sprintf("excerpt = $%d", argIndex)) args = append(args, *req.Excerpt) argIndex++ } if req.CoverImage != nil { sets = append(sets, fmt.Sprintf("cover_image = $%d", argIndex)) args = append(args, *req.CoverImage) argIndex++ } if req.Category != nil { sets = append(sets, fmt.Sprintf("category = $%d", argIndex)) args = append(args, *req.Category) argIndex++ } if req.Tags != nil { tagsJSON, _ := json.Marshal(*req.Tags) sets = append(sets, fmt.Sprintf("tags = $%d", argIndex)) args = append(args, tagsJSON) argIndex++ } if req.Status != nil { sets = append(sets, fmt.Sprintf("status = $%d", argIndex)) args = append(args, *req.Status) argIndex++ } if len(sets) == 0 { return r.GetByID(ctx, id) } sets = append(sets, "updated_at = NOW()") args = append(args, id) query := fmt.Sprintf(`UPDATE posts SET %s WHERE id = $%d RETURNING id`, strings.Join(sets, ", "), argIndex) var returnedID string if err := r.db.QueryRowContext(ctx, query, args...).Scan(&returnedID); err != nil { return nil, err } return r.GetByID(ctx, id) } func (r *PostRepository) Delete(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM posts WHERE id = $1`, id) return err } func (r *PostRepository) Publish(ctx context.Context, id string) (*Post, error) { query := `UPDATE posts SET status = 'published', published_at = NOW(), updated_at = NOW() WHERE id = $1 RETURNING id` var returnedID string if err := r.db.QueryRowContext(ctx, query, id).Scan(&returnedID); err != nil { return nil, err } return r.GetByID(ctx, id) } func (r *PostRepository) Count(ctx context.Context, status string) (int, error) { query := `SELECT COUNT(*) FROM posts` if status != "" { query += ` WHERE status = $1` var count int err := r.db.QueryRowContext(ctx, query, status).Scan(&count) return count, err } var count int err := r.db.QueryRowContext(ctx, query).Scan(&count) return count, err } type SettingsRepository struct { db *sql.DB } func NewSettingsRepository(db *sql.DB) *SettingsRepository { return &SettingsRepository{db: db} } func (r *SettingsRepository) Get(ctx context.Context) (*PlatformSettings, error) { query := `SELECT id, site_name, site_url, logo_url, favicon_url, description, support_email, features, llm_settings, search_settings, metadata, updated_at FROM platform_settings LIMIT 1` var s PlatformSettings var logoURL, faviconURL, description, supportEmail sql.NullString var featuresJSON, llmJSON, searchJSON, metadataJSON []byte err := r.db.QueryRowContext(ctx, query).Scan(&s.ID, &s.SiteName, &s.SiteURL, &logoURL, &faviconURL, &description, &supportEmail, &featuresJSON, &llmJSON, &searchJSON, &metadataJSON, &s.UpdatedAt) if err == sql.ErrNoRows { return r.createDefault(ctx) } if err != nil { return nil, err } if logoURL.Valid { s.LogoURL = logoURL.String } if faviconURL.Valid { s.FaviconURL = faviconURL.String } if description.Valid { s.Description = description.String } if supportEmail.Valid { s.SupportEmail = supportEmail.String } json.Unmarshal(featuresJSON, &s.Features) json.Unmarshal(llmJSON, &s.LLMSettings) json.Unmarshal(searchJSON, &s.SearchSettings) json.Unmarshal(metadataJSON, &s.Metadata) return &s, nil } func (r *SettingsRepository) createDefault(ctx context.Context) (*PlatformSettings, error) { s := &PlatformSettings{ ID: uuid.New().String(), SiteName: "GooSeek", SiteURL: "https://gooseek.ru", Features: FeatureFlags{ EnableRegistration: true, EnableDiscover: true, EnableFinance: true, EnableLearning: true, EnableTravel: true, EnableMedicine: true, EnableFileUploads: true, MaintenanceMode: false, }, LLMSettings: LLMSettings{ DefaultProvider: "timeweb", DefaultModel: "gpt-4o-mini", MaxTokens: 4096, Temperature: 0.7, }, SearchSettings: SearchSettings{ DefaultEngine: "searxng", SafeSearch: true, MaxResults: 10, EnabledCategories: []string{"general", "news", "images"}, }, UpdatedAt: time.Now(), } featuresJSON, _ := json.Marshal(s.Features) llmJSON, _ := json.Marshal(s.LLMSettings) searchJSON, _ := json.Marshal(s.SearchSettings) metadataJSON, _ := json.Marshal(s.Metadata) query := `INSERT INTO platform_settings (id, site_name, site_url, features, llm_settings, search_settings, metadata, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())` _, err := r.db.ExecContext(ctx, query, s.ID, s.SiteName, s.SiteURL, featuresJSON, llmJSON, searchJSON, metadataJSON) if err != nil { return nil, err } return s, nil } func (r *SettingsRepository) Update(ctx context.Context, s *PlatformSettings) (*PlatformSettings, error) { featuresJSON, _ := json.Marshal(s.Features) llmJSON, _ := json.Marshal(s.LLMSettings) searchJSON, _ := json.Marshal(s.SearchSettings) metadataJSON, _ := json.Marshal(s.Metadata) query := `UPDATE platform_settings SET site_name = COALESCE(NULLIF($1, ''), site_name), site_url = COALESCE(NULLIF($2, ''), site_url), logo_url = $3, favicon_url = $4, description = $5, support_email = $6, features = $7, llm_settings = $8, search_settings = $9, metadata = $10, updated_at = NOW()` _, err := r.db.ExecContext(ctx, query, s.SiteName, s.SiteURL, s.LogoURL, s.FaviconURL, s.Description, s.SupportEmail, featuresJSON, llmJSON, searchJSON, metadataJSON) if err != nil { return nil, err } return r.Get(ctx) } func (r *SettingsRepository) GetFeatures(ctx context.Context) (*FeatureFlags, error) { settings, err := r.Get(ctx) if err != nil { return nil, err } return &settings.Features, nil } func (r *SettingsRepository) UpdateFeatures(ctx context.Context, features *FeatureFlags) error { featuresJSON, _ := json.Marshal(features) _, err := r.db.ExecContext(ctx, `UPDATE platform_settings SET features = $1, updated_at = NOW()`, featuresJSON) return err } type DiscoverConfigRepository struct { db *sql.DB } func NewDiscoverConfigRepository(db *sql.DB) *DiscoverConfigRepository { return &DiscoverConfigRepository{db: db} } func (r *DiscoverConfigRepository) ListCategories(ctx context.Context) ([]DiscoverCategory, error) { query := `SELECT id, name, name_ru, icon, color, keywords, regions, is_active, sort_order, created_at, updated_at FROM discover_categories ORDER BY sort_order ASC, name ASC` rows, err := r.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var categories []DiscoverCategory for rows.Next() { var c DiscoverCategory var keywordsJSON, regionsJSON []byte if err := rows.Scan(&c.ID, &c.Name, &c.NameRu, &c.Icon, &c.Color, &keywordsJSON, ®ionsJSON, &c.IsActive, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } json.Unmarshal(keywordsJSON, &c.Keywords) json.Unmarshal(regionsJSON, &c.Regions) categories = append(categories, c) } if len(categories) == 0 { return r.seedDefaultCategories(ctx) } return categories, nil } func (r *DiscoverConfigRepository) seedDefaultCategories(ctx context.Context) ([]DiscoverCategory, error) { defaults := []DiscoverCategoryCreateRequest{ {Name: "tech", NameRu: "Технологии", Icon: "💻", Color: "#3B82F6", Keywords: []string{"technology", "AI", "software"}, Regions: []string{"world", "russia"}}, {Name: "finance", NameRu: "Финансы", Icon: "💰", Color: "#10B981", Keywords: []string{"finance", "economy", "stocks"}, Regions: []string{"world", "russia"}}, {Name: "sports", NameRu: "Спорт", Icon: "⚽", Color: "#F59E0B", Keywords: []string{"sports", "football", "hockey"}, Regions: []string{"world", "russia"}}, {Name: "politics", NameRu: "Политика", Icon: "🏛️", Color: "#6366F1", Keywords: []string{"politics", "government"}, Regions: []string{"world", "russia"}}, {Name: "science", NameRu: "Наука", Icon: "🔬", Color: "#8B5CF6", Keywords: []string{"science", "research"}, Regions: []string{"world", "russia"}}, {Name: "health", NameRu: "Здоровье", Icon: "🏥", Color: "#EC4899", Keywords: []string{"health", "medicine"}, Regions: []string{"world", "russia"}}, {Name: "entertainment", NameRu: "Развлечения", Icon: "🎬", Color: "#F97316", Keywords: []string{"entertainment", "movies"}, Regions: []string{"world", "russia"}}, {Name: "world", NameRu: "В мире", Icon: "🌍", Color: "#14B8A6", Keywords: []string{"world", "international"}, Regions: []string{"world"}}, {Name: "business", NameRu: "Бизнес", Icon: "📊", Color: "#6B7280", Keywords: []string{"business", "startups"}, Regions: []string{"world", "russia"}}, {Name: "culture", NameRu: "Культура", Icon: "🎭", Color: "#A855F7", Keywords: []string{"culture", "art"}, Regions: []string{"world", "russia"}}, } var categories []DiscoverCategory for i, d := range defaults { c, err := r.createCategoryInternal(ctx, &d, i) if err != nil { continue } categories = append(categories, *c) } return categories, nil } func (r *DiscoverConfigRepository) createCategoryInternal(ctx context.Context, req *DiscoverCategoryCreateRequest, sortOrder int) (*DiscoverCategory, error) { id := uuid.New().String() keywordsJSON, _ := json.Marshal(req.Keywords) regionsJSON, _ := json.Marshal(req.Regions) query := `INSERT INTO discover_categories (id, name, name_ru, icon, color, keywords, regions, is_active, sort_order, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, NOW(), NOW()) RETURNING id, name, name_ru, icon, color, keywords, regions, is_active, sort_order, created_at, updated_at` var c DiscoverCategory var retKeywords, retRegions []byte err := r.db.QueryRowContext(ctx, query, id, req.Name, req.NameRu, req.Icon, req.Color, keywordsJSON, regionsJSON, sortOrder). Scan(&c.ID, &c.Name, &c.NameRu, &c.Icon, &c.Color, &retKeywords, &retRegions, &c.IsActive, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, err } json.Unmarshal(retKeywords, &c.Keywords) json.Unmarshal(retRegions, &c.Regions) return &c, nil } func (r *DiscoverConfigRepository) CreateCategory(ctx context.Context, req *DiscoverCategoryCreateRequest) (*DiscoverCategory, error) { var maxOrder int r.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), 0) FROM discover_categories`).Scan(&maxOrder) return r.createCategoryInternal(ctx, req, maxOrder+1) } func (r *DiscoverConfigRepository) UpdateCategory(ctx context.Context, id string, req *DiscoverCategoryUpdateRequest) (*DiscoverCategory, error) { sets := []string{} args := []interface{}{} argIndex := 1 if req.Name != nil { sets = append(sets, fmt.Sprintf("name = $%d", argIndex)) args = append(args, *req.Name) argIndex++ } if req.NameRu != nil { sets = append(sets, fmt.Sprintf("name_ru = $%d", argIndex)) args = append(args, *req.NameRu) argIndex++ } if req.Icon != nil { sets = append(sets, fmt.Sprintf("icon = $%d", argIndex)) args = append(args, *req.Icon) argIndex++ } if req.Color != nil { sets = append(sets, fmt.Sprintf("color = $%d", argIndex)) args = append(args, *req.Color) argIndex++ } if req.Keywords != nil { keywordsJSON, _ := json.Marshal(*req.Keywords) sets = append(sets, fmt.Sprintf("keywords = $%d", argIndex)) args = append(args, keywordsJSON) argIndex++ } if req.Regions != nil { regionsJSON, _ := json.Marshal(*req.Regions) sets = append(sets, fmt.Sprintf("regions = $%d", argIndex)) args = append(args, regionsJSON) argIndex++ } if req.IsActive != nil { sets = append(sets, fmt.Sprintf("is_active = $%d", argIndex)) args = append(args, *req.IsActive) argIndex++ } if req.SortOrder != nil { sets = append(sets, fmt.Sprintf("sort_order = $%d", argIndex)) args = append(args, *req.SortOrder) argIndex++ } if len(sets) == 0 { return r.getCategoryByID(ctx, id) } sets = append(sets, "updated_at = NOW()") args = append(args, id) query := fmt.Sprintf(`UPDATE discover_categories SET %s WHERE id = $%d RETURNING id`, strings.Join(sets, ", "), argIndex) var returnedID string if err := r.db.QueryRowContext(ctx, query, args...).Scan(&returnedID); err != nil { return nil, err } return r.getCategoryByID(ctx, id) } func (r *DiscoverConfigRepository) getCategoryByID(ctx context.Context, id string) (*DiscoverCategory, error) { query := `SELECT id, name, name_ru, icon, color, keywords, regions, is_active, sort_order, created_at, updated_at FROM discover_categories WHERE id = $1` var c DiscoverCategory var keywordsJSON, regionsJSON []byte err := r.db.QueryRowContext(ctx, query, id).Scan(&c.ID, &c.Name, &c.NameRu, &c.Icon, &c.Color, &keywordsJSON, ®ionsJSON, &c.IsActive, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, err } json.Unmarshal(keywordsJSON, &c.Keywords) json.Unmarshal(regionsJSON, &c.Regions) return &c, nil } func (r *DiscoverConfigRepository) DeleteCategory(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM discover_categories WHERE id = $1`, id) return err } func (r *DiscoverConfigRepository) ReorderCategories(ctx context.Context, order []string) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() for i, id := range order { if _, err := tx.ExecContext(ctx, `UPDATE discover_categories SET sort_order = $1 WHERE id = $2`, i, id); err != nil { return err } } return tx.Commit() } func (r *DiscoverConfigRepository) ListSources(ctx context.Context) ([]DiscoverSource, error) { query := `SELECT id, name, url, logo_url, categories, trust_score, is_active, description, created_at, updated_at FROM discover_sources ORDER BY trust_score DESC, name ASC` rows, err := r.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var sources []DiscoverSource for rows.Next() { var s DiscoverSource var logoURL, description sql.NullString var categoriesJSON []byte if err := rows.Scan(&s.ID, &s.Name, &s.URL, &logoURL, &categoriesJSON, &s.TrustScore, &s.IsActive, &description, &s.CreatedAt, &s.UpdatedAt); err != nil { return nil, err } if logoURL.Valid { s.LogoURL = logoURL.String } if description.Valid { s.Description = description.String } json.Unmarshal(categoriesJSON, &s.Categories) sources = append(sources, s) } return sources, nil } func (r *DiscoverConfigRepository) CreateSource(ctx context.Context, req *DiscoverSourceCreateRequest) (*DiscoverSource, error) { id := uuid.New().String() categoriesJSON, _ := json.Marshal(req.Categories) query := `INSERT INTO discover_sources (id, name, url, logo_url, categories, trust_score, is_active, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, true, $7, NOW(), NOW()) RETURNING id, name, url, logo_url, categories, trust_score, is_active, description, created_at, updated_at` var s DiscoverSource var logoURL, description sql.NullString var retCategories []byte err := r.db.QueryRowContext(ctx, query, id, req.Name, req.URL, req.LogoURL, categoriesJSON, req.TrustScore, req.Description). Scan(&s.ID, &s.Name, &s.URL, &logoURL, &retCategories, &s.TrustScore, &s.IsActive, &description, &s.CreatedAt, &s.UpdatedAt) if err != nil { return nil, err } if logoURL.Valid { s.LogoURL = logoURL.String } if description.Valid { s.Description = description.String } json.Unmarshal(retCategories, &s.Categories) return &s, nil } func (r *DiscoverConfigRepository) DeleteSource(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM discover_sources WHERE id = $1`, id) return err } type AuditRepository struct { db *sql.DB } func NewAuditRepository(db *sql.DB) *AuditRepository { return &AuditRepository{db: db} } func (r *AuditRepository) Create(ctx context.Context, log *AuditLog) error { id := uuid.New().String() detailsJSON, _ := json.Marshal(log.Details) query := `INSERT INTO audit_logs (id, user_id, user_email, action, resource, resource_id, details, ip_address, user_agent, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())` _, err := r.db.ExecContext(ctx, query, id, log.UserID, log.UserEmail, log.Action, log.Resource, log.ResourceID, detailsJSON, log.IPAddress, log.UserAgent) return err } func (r *AuditRepository) List(ctx context.Context, page, perPage int, action, resource string) ([]AuditLog, int, error) { offset := (page - 1) * perPage countQuery := `SELECT COUNT(*) FROM audit_logs WHERE 1=1` listQuery := `SELECT id, user_id, user_email, action, resource, resource_id, details, ip_address, user_agent, created_at FROM audit_logs WHERE 1=1` args := []interface{}{} argIndex := 1 if action != "" { condition := fmt.Sprintf(` AND action = $%d`, argIndex) countQuery += condition listQuery += condition args = append(args, action) argIndex++ } if resource != "" { condition := fmt.Sprintf(` AND resource = $%d`, argIndex) countQuery += condition listQuery += condition args = append(args, resource) argIndex++ } listQuery += fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argIndex, argIndex+1) args = append(args, perPage, offset) var total int countArgs := args[:len(args)-2] if len(countArgs) == 0 { countArgs = nil } if err := r.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, listQuery, args...) if err != nil { return nil, 0, err } defer rows.Close() var logs []AuditLog for rows.Next() { var l AuditLog var resourceID, ipAddress, userAgent sql.NullString var detailsJSON []byte if err := rows.Scan(&l.ID, &l.UserID, &l.UserEmail, &l.Action, &l.Resource, &resourceID, &detailsJSON, &ipAddress, &userAgent, &l.CreatedAt); err != nil { return nil, 0, err } if resourceID.Valid { l.ResourceID = resourceID.String } if ipAddress.Valid { l.IPAddress = ipAddress.String } if userAgent.Valid { l.UserAgent = userAgent.String } if detailsJSON != nil { json.Unmarshal(detailsJSON, &l.Details) } logs = append(logs, l) } return logs, total, nil } func generateSlug(title string) string { slug := strings.ToLower(title) slug = strings.ReplaceAll(slug, " ", "-") slug = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || (r >= 0x0400 && r <= 0x04FF) { return r } return -1 }, slug) for strings.Contains(slug, "--") { slug = strings.ReplaceAll(slug, "--", "-") } slug = strings.Trim(slug, "-") if len(slug) > 100 { slug = slug[:100] } return slug + "-" + uuid.New().String()[:8] }