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
985 lines
30 KiB
Go
985 lines
30 KiB
Go
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]
|
||
}
|