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
This commit is contained in:
153
backend/internal/admin/migrations.go
Normal file
153
backend/internal/admin/migrations.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func RunAdminMigrations(ctx context.Context, db *sql.DB) error {
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
avatar_url TEXT,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
tier VARCHAR(50) NOT NULL DEFAULT 'free',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(500) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
excerpt TEXT,
|
||||
cover_image TEXT,
|
||||
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
tags JSONB DEFAULT '[]',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS platform_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_name VARCHAR(255) NOT NULL DEFAULT 'GooSeek',
|
||||
site_url VARCHAR(500) NOT NULL DEFAULT 'https://gooseek.ru',
|
||||
logo_url TEXT,
|
||||
favicon_url TEXT,
|
||||
description TEXT,
|
||||
support_email VARCHAR(255),
|
||||
features JSONB NOT NULL DEFAULT '{}',
|
||||
llm_settings JSONB NOT NULL DEFAULT '{}',
|
||||
search_settings JSONB NOT NULL DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS discover_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
name_ru VARCHAR(100) NOT NULL,
|
||||
icon VARCHAR(10) NOT NULL DEFAULT '📰',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#6B7280',
|
||||
keywords JSONB NOT NULL DEFAULT '[]',
|
||||
regions JSONB NOT NULL DEFAULT '["world"]',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_discover_categories_name ON discover_categories(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_discover_categories_sort ON discover_categories(sort_order)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS discover_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL UNIQUE,
|
||||
logo_url TEXT,
|
||||
categories JSONB NOT NULL DEFAULT '[]',
|
||||
trust_score DECIMAL(3,2) NOT NULL DEFAULT 0.5,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_discover_sources_url ON discover_sources(url)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_discover_sources_trust ON discover_sources(trust_score DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS connectors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
error_msg TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_connectors_type ON connectors(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_connectors_status ON connectors(status)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS user_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
original_name VARCHAR(500) NOT NULL,
|
||||
file_type VARCHAR(100) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
bucket VARCHAR(100) NOT NULL DEFAULT 'user-files',
|
||||
storage_key TEXT NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_files_user ON user_files(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_files_type ON user_files(file_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_files_bucket ON user_files(bucket)`,
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
if _, err := db.ExecContext(ctx, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
984
backend/internal/admin/repositories.go
Normal file
984
backend/internal/admin/repositories.go
Normal file
@@ -0,0 +1,984 @@
|
||||
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]
|
||||
}
|
||||
256
backend/internal/admin/types.go
Normal file
256
backend/internal/admin/types.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleUser UserRole = "user"
|
||||
RoleAdmin UserRole = "admin"
|
||||
)
|
||||
|
||||
type UserTier string
|
||||
|
||||
const (
|
||||
TierFree UserTier = "free"
|
||||
TierPro UserTier = "pro"
|
||||
TierBusiness UserTier = "business"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role UserRole `json:"role"`
|
||||
Tier UserTier `json:"tier"`
|
||||
IsActive bool `json:"isActive"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserCreateRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role UserRole `json:"role"`
|
||||
Tier UserTier `json:"tier"`
|
||||
}
|
||||
|
||||
type UserUpdateRequest struct {
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
Role *UserRole `json:"role,omitempty"`
|
||||
Tier *UserTier `json:"tier,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
}
|
||||
|
||||
type UserListResponse struct {
|
||||
Users []User `json:"users"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
AuthorID string `json:"authorId"`
|
||||
AuthorName string `json:"authorName,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
ViewCount int `json:"viewCount"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PostStatus string
|
||||
|
||||
const (
|
||||
PostStatusDraft PostStatus = "draft"
|
||||
PostStatusPublished PostStatus = "published"
|
||||
PostStatusArchived PostStatus = "archived"
|
||||
)
|
||||
|
||||
type PostCreateRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PostUpdateRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Excerpt *string `json:"excerpt,omitempty"`
|
||||
CoverImage *string `json:"coverImage,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type PostListResponse struct {
|
||||
Posts []Post `json:"posts"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
||||
type PlatformSettings struct {
|
||||
ID string `json:"id"`
|
||||
SiteName string `json:"siteName"`
|
||||
SiteURL string `json:"siteUrl"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
FaviconURL string `json:"faviconUrl,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SupportEmail string `json:"supportEmail,omitempty"`
|
||||
Features FeatureFlags `json:"features"`
|
||||
LLMSettings LLMSettings `json:"llmSettings"`
|
||||
SearchSettings SearchSettings `json:"searchSettings"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FeatureFlags struct {
|
||||
EnableRegistration bool `json:"enableRegistration"`
|
||||
EnableDiscover bool `json:"enableDiscover"`
|
||||
EnableFinance bool `json:"enableFinance"`
|
||||
EnableLearning bool `json:"enableLearning"`
|
||||
EnableTravel bool `json:"enableTravel"`
|
||||
EnableMedicine bool `json:"enableMedicine"`
|
||||
EnableFileUploads bool `json:"enableFileUploads"`
|
||||
MaintenanceMode bool `json:"maintenanceMode"`
|
||||
}
|
||||
|
||||
type LLMSettings struct {
|
||||
DefaultProvider string `json:"defaultProvider"`
|
||||
DefaultModel string `json:"defaultModel"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
APIKeys map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
type SearchSettings struct {
|
||||
DefaultEngine string `json:"defaultEngine"`
|
||||
SafeSearch bool `json:"safeSearch"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
EnabledCategories []string `json:"enabledCategories"`
|
||||
}
|
||||
|
||||
type DiscoverCategory struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameRu string `json:"nameRu"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Regions []string `json:"regions"`
|
||||
IsActive bool `json:"isActive"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DiscoverCategoryCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
NameRu string `json:"nameRu"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Regions []string `json:"regions"`
|
||||
}
|
||||
|
||||
type DiscoverCategoryUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
NameRu *string `json:"nameRu,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Keywords *[]string `json:"keywords,omitempty"`
|
||||
Regions *[]string `json:"regions,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
SortOrder *int `json:"sortOrder,omitempty"`
|
||||
}
|
||||
|
||||
type DiscoverSource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
Categories []string `json:"categories"`
|
||||
TrustScore float64 `json:"trustScore"`
|
||||
IsActive bool `json:"isActive"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DiscoverSourceCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
Categories []string `json:"categories"`
|
||||
TrustScore float64 `json:"trustScore"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalUsers int `json:"totalUsers"`
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
TotalPosts int `json:"totalPosts"`
|
||||
PublishedPosts int `json:"publishedPosts"`
|
||||
TotalSearches int `json:"totalSearches"`
|
||||
TodaySearches int `json:"todaySearches"`
|
||||
StorageUsedMB int `json:"storageUsedMb"`
|
||||
StorageLimitMB int `json:"storageLimitMb"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Connector struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
IsActive bool `json:"isActive"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ConnectorType string
|
||||
|
||||
const (
|
||||
ConnectorTypeOpenAI ConnectorType = "openai"
|
||||
ConnectorTypeAnthropic ConnectorType = "anthropic"
|
||||
ConnectorTypeGemini ConnectorType = "gemini"
|
||||
ConnectorTypeTimeweb ConnectorType = "timeweb"
|
||||
ConnectorTypeCustomLLM ConnectorType = "custom_llm"
|
||||
ConnectorTypeS3 ConnectorType = "s3"
|
||||
ConnectorTypeMinio ConnectorType = "minio"
|
||||
)
|
||||
Reference in New Issue
Block a user