Files
gooseek/backend/internal/admin/repositories.go
home a0e3748dde feat: auth service + security audit fixes + cleanup legacy services
Major changes:
- Add auth-svc: JWT auth, register/login/refresh, password reset
- Add auth UI: modals, pages (/login, /register, /forgot-password)
- Add usage tracking (usage_metrics table, daily limits)
- Add tiered rate limiting (free/pro/business)
- Add LLM usage limits per tier

Security fixes:
- All repos now require userID for Update/Delete operations
- JWT middleware in chat-svc, llm-svc, agent-svc, discover-svc
- ErrNotFound/ErrForbidden errors for proper access control

Cleanup:
- Remove legacy TypeScript services/ directory
- Remove computer-svc (to be reimplemented)
- Remove old deploy/docker configs

New files:
- backend/cmd/auth-svc/main.go
- backend/internal/auth/{types,repository}.go
- backend/internal/usage/{types,repository}.go
- backend/pkg/middleware/{llm_limits,ratelimit_tiered}.go
- backend/webui/src/components/auth/*
- backend/webui/src/app/(auth)/*

Made-with: Cursor
2026-02-28 01:33:49 +03:00

985 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, &regionsJSON, &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, &regionsJSON, &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]
}