feat: Go backend, enhanced search, new widgets, Docker deploy

Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
This commit is contained in:
home
2026-02-27 04:15:32 +03:00
parent 328d968f3f
commit 06fe57c765
285 changed files with 53132 additions and 1871 deletions

View File

@@ -0,0 +1,97 @@
package db
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"strings"
"time"
)
type ArticleSummary struct {
ID int64 `json:"id"`
URLHash string `json:"urlHash"`
URL string `json:"url"`
Events []string `json:"events"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type ArticleSummaryRepository struct {
db *PostgresDB
}
func NewArticleSummaryRepository(db *PostgresDB) *ArticleSummaryRepository {
return &ArticleSummaryRepository{db: db}
}
func (r *ArticleSummaryRepository) hashURL(url string) string {
normalized := strings.TrimSpace(url)
normalized = strings.TrimSuffix(normalized, "/")
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
hash := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(hash[:])
}
func (r *ArticleSummaryRepository) GetByURL(ctx context.Context, url string) (*ArticleSummary, error) {
urlHash := r.hashURL(url)
query := `
SELECT id, url_hash, url, events, created_at, expires_at
FROM article_summaries
WHERE url_hash = $1 AND expires_at > NOW()
`
var a ArticleSummary
var eventsJSON []byte
err := r.db.db.QueryRowContext(ctx, query, urlHash).Scan(
&a.ID, &a.URLHash, &a.URL, &eventsJSON, &a.CreatedAt, &a.ExpiresAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(eventsJSON, &a.Events)
return &a, nil
}
func (r *ArticleSummaryRepository) Save(ctx context.Context, url string, events []string, ttl time.Duration) error {
urlHash := r.hashURL(url)
eventsJSON, _ := json.Marshal(events)
expiresAt := time.Now().Add(ttl)
query := `
INSERT INTO article_summaries (url_hash, url, events, expires_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (url_hash)
DO UPDATE SET
events = EXCLUDED.events,
expires_at = EXCLUDED.expires_at
`
_, err := r.db.db.ExecContext(ctx, query, urlHash, url, eventsJSON, expiresAt)
return err
}
func (r *ArticleSummaryRepository) Delete(ctx context.Context, url string) error {
urlHash := r.hashURL(url)
_, err := r.db.db.ExecContext(ctx, "DELETE FROM article_summaries WHERE url_hash = $1", urlHash)
return err
}
func (r *ArticleSummaryRepository) CleanupExpired(ctx context.Context) (int64, error) {
result, err := r.db.db.ExecContext(ctx, "DELETE FROM article_summaries WHERE expires_at < NOW()")
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@@ -0,0 +1,204 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type Collection struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
ContextEnabled bool `json:"contextEnabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Items []CollectionItem `json:"items,omitempty"`
ItemCount int `json:"itemCount,omitempty"`
}
type CollectionItem struct {
ID string `json:"id"`
CollectionID string `json:"collectionId"`
ItemType string `json:"itemType"`
Title string `json:"title"`
Content string `json:"content"`
URL string `json:"url"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"createdAt"`
SortOrder int `json:"sortOrder"`
}
type CollectionRepository struct {
db *PostgresDB
}
func NewCollectionRepository(db *PostgresDB) *CollectionRepository {
return &CollectionRepository{db: db}
}
func (r *CollectionRepository) Create(ctx context.Context, c *Collection) error {
query := `
INSERT INTO collections (user_id, name, description, is_public, context_enabled)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at
`
return r.db.db.QueryRowContext(ctx, query,
c.UserID, c.Name, c.Description, c.IsPublic, c.ContextEnabled,
).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
}
func (r *CollectionRepository) GetByID(ctx context.Context, id string) (*Collection, error) {
query := `
SELECT id, user_id, name, description, is_public, context_enabled, created_at, updated_at,
(SELECT COUNT(*) FROM collection_items WHERE collection_id = collections.id) as item_count
FROM collections
WHERE id = $1
`
var c Collection
err := r.db.db.QueryRowContext(ctx, query, id).Scan(
&c.ID, &c.UserID, &c.Name, &c.Description, &c.IsPublic,
&c.ContextEnabled, &c.CreatedAt, &c.UpdatedAt, &c.ItemCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
func (r *CollectionRepository) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*Collection, error) {
query := `
SELECT id, user_id, name, description, is_public, context_enabled, created_at, updated_at,
(SELECT COUNT(*) FROM collection_items WHERE collection_id = collections.id) as item_count
FROM collections
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var collections []*Collection
for rows.Next() {
var c Collection
if err := rows.Scan(
&c.ID, &c.UserID, &c.Name, &c.Description, &c.IsPublic,
&c.ContextEnabled, &c.CreatedAt, &c.UpdatedAt, &c.ItemCount,
); err != nil {
return nil, err
}
collections = append(collections, &c)
}
return collections, nil
}
func (r *CollectionRepository) Update(ctx context.Context, c *Collection) error {
query := `
UPDATE collections
SET name = $2, description = $3, is_public = $4, context_enabled = $5, updated_at = NOW()
WHERE id = $1
`
_, err := r.db.db.ExecContext(ctx, query,
c.ID, c.Name, c.Description, c.IsPublic, c.ContextEnabled,
)
return err
}
func (r *CollectionRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM collections WHERE id = $1", id)
return err
}
func (r *CollectionRepository) AddItem(ctx context.Context, item *CollectionItem) error {
metadataJSON, _ := json.Marshal(item.Metadata)
query := `
INSERT INTO collection_items (collection_id, item_type, title, content, url, metadata, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE((SELECT MAX(sort_order) + 1 FROM collection_items WHERE collection_id = $1), 0))
RETURNING id, created_at, sort_order
`
return r.db.db.QueryRowContext(ctx, query,
item.CollectionID, item.ItemType, item.Title, item.Content, item.URL, metadataJSON,
).Scan(&item.ID, &item.CreatedAt, &item.SortOrder)
}
func (r *CollectionRepository) GetItems(ctx context.Context, collectionID string) ([]CollectionItem, error) {
query := `
SELECT id, collection_id, item_type, title, content, url, metadata, created_at, sort_order
FROM collection_items
WHERE collection_id = $1
ORDER BY sort_order ASC
`
rows, err := r.db.db.QueryContext(ctx, query, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CollectionItem
for rows.Next() {
var item CollectionItem
var metadataJSON []byte
if err := rows.Scan(
&item.ID, &item.CollectionID, &item.ItemType, &item.Title,
&item.Content, &item.URL, &metadataJSON, &item.CreatedAt, &item.SortOrder,
); err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &item.Metadata)
items = append(items, item)
}
return items, nil
}
func (r *CollectionRepository) RemoveItem(ctx context.Context, itemID string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM collection_items WHERE id = $1", itemID)
return err
}
func (r *CollectionRepository) GetCollectionContext(ctx context.Context, collectionID string) (string, error) {
items, err := r.GetItems(ctx, collectionID)
if err != nil {
return "", err
}
var context string
for _, item := range items {
switch item.ItemType {
case "search":
context += "Previous search: " + item.Title + "\n"
if item.Content != "" {
context += "Summary: " + item.Content + "\n"
}
case "note":
context += "User note: " + item.Content + "\n"
case "url":
context += "Saved URL: " + item.URL + " - " + item.Title + "\n"
case "file":
context += "Uploaded file: " + item.Title + "\n"
if item.Content != "" {
context += "Content: " + item.Content + "\n"
}
}
context += "\n"
}
return context, nil
}

View File

@@ -0,0 +1,322 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/gooseek/backend/internal/computer"
)
type ComputerArtifactRepo struct {
db *sql.DB
}
func NewComputerArtifactRepo(db *sql.DB) *ComputerArtifactRepo {
return &ComputerArtifactRepo{db: db}
}
func (r *ComputerArtifactRepo) Migrate() error {
query := `
CREATE TABLE IF NOT EXISTS computer_artifacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL,
type VARCHAR(50) NOT NULL,
name VARCHAR(255),
content BYTEA,
url TEXT,
size BIGINT DEFAULT 0,
mime_type VARCHAR(100),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_computer_artifacts_task_id ON computer_artifacts(task_id);
CREATE INDEX IF NOT EXISTS idx_computer_artifacts_type ON computer_artifacts(type);
CREATE INDEX IF NOT EXISTS idx_computer_artifacts_created ON computer_artifacts(created_at DESC);
`
_, err := r.db.Exec(query)
return err
}
func (r *ComputerArtifactRepo) Create(ctx context.Context, artifact *computer.Artifact) error {
metadataJSON, _ := json.Marshal(artifact.Metadata)
query := `
INSERT INTO computer_artifacts (id, task_id, type, name, content, url, size, mime_type, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
_, err := r.db.ExecContext(ctx, query,
artifact.ID,
artifact.TaskID,
artifact.Type,
artifact.Name,
artifact.Content,
artifact.URL,
artifact.Size,
artifact.MimeType,
metadataJSON,
artifact.CreatedAt,
)
return err
}
func (r *ComputerArtifactRepo) GetByID(ctx context.Context, id string) (*computer.Artifact, error) {
query := `
SELECT id, task_id, type, name, content, url, size, mime_type, metadata, created_at
FROM computer_artifacts
WHERE id = $1
`
var artifact computer.Artifact
var content []byte
var url, mimeType sql.NullString
var metadataJSON []byte
err := r.db.QueryRowContext(ctx, query, id).Scan(
&artifact.ID,
&artifact.TaskID,
&artifact.Type,
&artifact.Name,
&content,
&url,
&artifact.Size,
&mimeType,
&metadataJSON,
&artifact.CreatedAt,
)
if err != nil {
return nil, err
}
artifact.Content = content
if url.Valid {
artifact.URL = url.String
}
if mimeType.Valid {
artifact.MimeType = mimeType.String
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &artifact.Metadata)
}
return &artifact, nil
}
func (r *ComputerArtifactRepo) GetByTaskID(ctx context.Context, taskID string) ([]computer.Artifact, error) {
query := `
SELECT id, task_id, type, name, url, size, mime_type, metadata, created_at
FROM computer_artifacts
WHERE task_id = $1
ORDER BY created_at ASC
`
rows, err := r.db.QueryContext(ctx, query, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var artifacts []computer.Artifact
for rows.Next() {
var artifact computer.Artifact
var url, mimeType sql.NullString
var metadataJSON []byte
err := rows.Scan(
&artifact.ID,
&artifact.TaskID,
&artifact.Type,
&artifact.Name,
&url,
&artifact.Size,
&mimeType,
&metadataJSON,
&artifact.CreatedAt,
)
if err != nil {
continue
}
if url.Valid {
artifact.URL = url.String
}
if mimeType.Valid {
artifact.MimeType = mimeType.String
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &artifact.Metadata)
}
artifacts = append(artifacts, artifact)
}
return artifacts, nil
}
func (r *ComputerArtifactRepo) GetByType(ctx context.Context, taskID, artifactType string) ([]computer.Artifact, error) {
query := `
SELECT id, task_id, type, name, url, size, mime_type, metadata, created_at
FROM computer_artifacts
WHERE task_id = $1 AND type = $2
ORDER BY created_at ASC
`
rows, err := r.db.QueryContext(ctx, query, taskID, artifactType)
if err != nil {
return nil, err
}
defer rows.Close()
var artifacts []computer.Artifact
for rows.Next() {
var artifact computer.Artifact
var url, mimeType sql.NullString
var metadataJSON []byte
err := rows.Scan(
&artifact.ID,
&artifact.TaskID,
&artifact.Type,
&artifact.Name,
&url,
&artifact.Size,
&mimeType,
&metadataJSON,
&artifact.CreatedAt,
)
if err != nil {
continue
}
if url.Valid {
artifact.URL = url.String
}
if mimeType.Valid {
artifact.MimeType = mimeType.String
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &artifact.Metadata)
}
artifacts = append(artifacts, artifact)
}
return artifacts, nil
}
func (r *ComputerArtifactRepo) GetContent(ctx context.Context, id string) ([]byte, error) {
query := `SELECT content FROM computer_artifacts WHERE id = $1`
var content []byte
err := r.db.QueryRowContext(ctx, query, id).Scan(&content)
return content, err
}
func (r *ComputerArtifactRepo) UpdateURL(ctx context.Context, id, url string) error {
query := `UPDATE computer_artifacts SET url = $1 WHERE id = $2`
_, err := r.db.ExecContext(ctx, query, url, id)
return err
}
func (r *ComputerArtifactRepo) Delete(ctx context.Context, id string) error {
query := `DELETE FROM computer_artifacts WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
func (r *ComputerArtifactRepo) DeleteByTaskID(ctx context.Context, taskID string) error {
query := `DELETE FROM computer_artifacts WHERE task_id = $1`
_, err := r.db.ExecContext(ctx, query, taskID)
return err
}
func (r *ComputerArtifactRepo) DeleteOlderThan(ctx context.Context, days int) (int64, error) {
query := `
DELETE FROM computer_artifacts
WHERE created_at < NOW() - INTERVAL '1 day' * $1
`
result, err := r.db.ExecContext(ctx, query, days)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (r *ComputerArtifactRepo) GetTotalSize(ctx context.Context, taskID string) (int64, error) {
query := `SELECT COALESCE(SUM(size), 0) FROM computer_artifacts WHERE task_id = $1`
var size int64
err := r.db.QueryRowContext(ctx, query, taskID).Scan(&size)
return size, err
}
func (r *ComputerArtifactRepo) Count(ctx context.Context, taskID string) (int64, error) {
query := `SELECT COUNT(*) FROM computer_artifacts WHERE task_id = $1`
var count int64
err := r.db.QueryRowContext(ctx, query, taskID).Scan(&count)
return count, err
}
type ArtifactSummary struct {
ID string `json:"id"`
TaskID string `json:"taskId"`
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
MimeType string `json:"mimeType"`
CreatedAt time.Time `json:"createdAt"`
}
func (r *ComputerArtifactRepo) GetSummaries(ctx context.Context, taskID string) ([]ArtifactSummary, error) {
query := `
SELECT id, task_id, type, name, url, size, mime_type, created_at
FROM computer_artifacts
WHERE task_id = $1
ORDER BY created_at ASC
`
rows, err := r.db.QueryContext(ctx, query, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []ArtifactSummary
for rows.Next() {
var s ArtifactSummary
var url, mimeType sql.NullString
err := rows.Scan(
&s.ID,
&s.TaskID,
&s.Type,
&s.Name,
&url,
&s.Size,
&mimeType,
&s.CreatedAt,
)
if err != nil {
continue
}
if url.Valid {
s.URL = url.String
}
if mimeType.Valid {
s.MimeType = mimeType.String
}
summaries = append(summaries, s)
}
return summaries, nil
}

View File

@@ -0,0 +1,306 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
"github.com/gooseek/backend/internal/computer"
)
type ComputerMemoryRepo struct {
db *sql.DB
}
func NewComputerMemoryRepo(db *sql.DB) *ComputerMemoryRepo {
return &ComputerMemoryRepo{db: db}
}
func (r *ComputerMemoryRepo) Migrate() error {
query := `
CREATE TABLE IF NOT EXISTS computer_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
task_id UUID,
key VARCHAR(255) NOT NULL,
value JSONB NOT NULL,
type VARCHAR(50),
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_computer_memory_user_id ON computer_memory(user_id);
CREATE INDEX IF NOT EXISTS idx_computer_memory_task_id ON computer_memory(task_id);
CREATE INDEX IF NOT EXISTS idx_computer_memory_type ON computer_memory(type);
CREATE INDEX IF NOT EXISTS idx_computer_memory_expires ON computer_memory(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_computer_memory_key ON computer_memory(key);
`
_, err := r.db.Exec(query)
return err
}
func (r *ComputerMemoryRepo) Store(ctx context.Context, entry *computer.MemoryEntry) error {
valueJSON, err := json.Marshal(entry.Value)
if err != nil {
return err
}
query := `
INSERT INTO computer_memory (id, user_id, task_id, key, value, type, tags, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
value = EXCLUDED.value,
type = EXCLUDED.type,
tags = EXCLUDED.tags,
expires_at = EXCLUDED.expires_at
`
var taskID interface{}
if entry.TaskID != "" {
taskID = entry.TaskID
}
_, err = r.db.ExecContext(ctx, query,
entry.ID,
entry.UserID,
taskID,
entry.Key,
valueJSON,
entry.Type,
entry.Tags,
entry.CreatedAt,
entry.ExpiresAt,
)
return err
}
func (r *ComputerMemoryRepo) GetByUser(ctx context.Context, userID string, limit int) ([]computer.MemoryEntry, error) {
query := `
SELECT id, user_id, task_id, key, value, type, tags, created_at, expires_at
FROM computer_memory
WHERE user_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.QueryContext(ctx, query, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *ComputerMemoryRepo) GetByTask(ctx context.Context, taskID string) ([]computer.MemoryEntry, error) {
query := `
SELECT id, user_id, task_id, key, value, type, tags, created_at, expires_at
FROM computer_memory
WHERE task_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at ASC
`
rows, err := r.db.QueryContext(ctx, query, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *ComputerMemoryRepo) Search(ctx context.Context, userID, query string, limit int) ([]computer.MemoryEntry, error) {
searchTerms := strings.Fields(strings.ToLower(query))
if len(searchTerms) == 0 {
return r.GetByUser(ctx, userID, limit)
}
likePatterns := make([]string, len(searchTerms))
args := make([]interface{}, len(searchTerms)+2)
args[0] = userID
for i, term := range searchTerms {
likePatterns[i] = "%" + term + "%"
args[i+1] = likePatterns[i]
}
args[len(args)-1] = limit
var conditions []string
for i := range searchTerms {
conditions = append(conditions, "(LOWER(key) LIKE $"+string(rune('2'+i))+" OR LOWER(value::text) LIKE $"+string(rune('2'+i))+")")
}
sqlQuery := `
SELECT id, user_id, task_id, key, value, type, tags, created_at, expires_at
FROM computer_memory
WHERE user_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
AND (` + strings.Join(conditions, " OR ") + `)
ORDER BY created_at DESC
LIMIT $` + string(rune('2'+len(searchTerms)))
rows, err := r.db.QueryContext(ctx, sqlQuery, args...)
if err != nil {
return r.GetByUser(ctx, userID, limit)
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *ComputerMemoryRepo) GetByType(ctx context.Context, userID, memType string, limit int) ([]computer.MemoryEntry, error) {
query := `
SELECT id, user_id, task_id, key, value, type, tags, created_at, expires_at
FROM computer_memory
WHERE user_id = $1 AND type = $2
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT $3
`
rows, err := r.db.QueryContext(ctx, query, userID, memType, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *ComputerMemoryRepo) GetByKey(ctx context.Context, userID, key string) (*computer.MemoryEntry, error) {
query := `
SELECT id, user_id, task_id, key, value, type, tags, created_at, expires_at
FROM computer_memory
WHERE user_id = $1 AND key = $2
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT 1
`
var entry computer.MemoryEntry
var valueJSON []byte
var taskID sql.NullString
var expiresAt sql.NullTime
var tags []string
err := r.db.QueryRowContext(ctx, query, userID, key).Scan(
&entry.ID,
&entry.UserID,
&taskID,
&entry.Key,
&valueJSON,
&entry.Type,
&tags,
&entry.CreatedAt,
&expiresAt,
)
if err != nil {
return nil, err
}
if taskID.Valid {
entry.TaskID = taskID.String
}
if expiresAt.Valid {
entry.ExpiresAt = &expiresAt.Time
}
entry.Tags = tags
json.Unmarshal(valueJSON, &entry.Value)
return &entry, nil
}
func (r *ComputerMemoryRepo) Delete(ctx context.Context, id string) error {
query := `DELETE FROM computer_memory WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
func (r *ComputerMemoryRepo) DeleteByUser(ctx context.Context, userID string) error {
query := `DELETE FROM computer_memory WHERE user_id = $1`
_, err := r.db.ExecContext(ctx, query, userID)
return err
}
func (r *ComputerMemoryRepo) DeleteByTask(ctx context.Context, taskID string) error {
query := `DELETE FROM computer_memory WHERE task_id = $1`
_, err := r.db.ExecContext(ctx, query, taskID)
return err
}
func (r *ComputerMemoryRepo) DeleteExpired(ctx context.Context) (int64, error) {
query := `DELETE FROM computer_memory WHERE expires_at IS NOT NULL AND expires_at < NOW()`
result, err := r.db.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (r *ComputerMemoryRepo) scanEntries(rows *sql.Rows) ([]computer.MemoryEntry, error) {
var entries []computer.MemoryEntry
for rows.Next() {
var entry computer.MemoryEntry
var valueJSON []byte
var taskID sql.NullString
var expiresAt sql.NullTime
var tags []string
err := rows.Scan(
&entry.ID,
&entry.UserID,
&taskID,
&entry.Key,
&valueJSON,
&entry.Type,
&tags,
&entry.CreatedAt,
&expiresAt,
)
if err != nil {
continue
}
if taskID.Valid {
entry.TaskID = taskID.String
}
if expiresAt.Valid {
entry.ExpiresAt = &expiresAt.Time
}
entry.Tags = tags
json.Unmarshal(valueJSON, &entry.Value)
entries = append(entries, entry)
}
return entries, nil
}
func (r *ComputerMemoryRepo) Count(ctx context.Context, userID string) (int64, error) {
query := `
SELECT COUNT(*)
FROM computer_memory
WHERE user_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
`
var count int64
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
return count, err
}
func (r *ComputerMemoryRepo) UpdateExpiry(ctx context.Context, id string, expiresAt time.Time) error {
query := `UPDATE computer_memory SET expires_at = $1 WHERE id = $2`
_, err := r.db.ExecContext(ctx, query, expiresAt, id)
return err
}

View File

@@ -0,0 +1,411 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/gooseek/backend/internal/computer"
)
type ComputerTaskRepo struct {
db *sql.DB
}
func NewComputerTaskRepo(db *sql.DB) *ComputerTaskRepo {
return &ComputerTaskRepo{db: db}
}
func (r *ComputerTaskRepo) Migrate() error {
query := `
CREATE TABLE IF NOT EXISTS computer_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
query TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
plan JSONB,
sub_tasks JSONB,
artifacts JSONB,
memory JSONB,
progress INT DEFAULT 0,
message TEXT,
error TEXT,
schedule JSONB,
next_run_at TIMESTAMPTZ,
run_count INT DEFAULT 0,
total_cost DECIMAL(10,6) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_computer_tasks_user_id ON computer_tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_computer_tasks_status ON computer_tasks(status);
CREATE INDEX IF NOT EXISTS idx_computer_tasks_next_run ON computer_tasks(next_run_at) WHERE next_run_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_computer_tasks_created ON computer_tasks(created_at DESC);
`
_, err := r.db.Exec(query)
return err
}
func (r *ComputerTaskRepo) Create(ctx context.Context, task *computer.ComputerTask) error {
planJSON, _ := json.Marshal(task.Plan)
subTasksJSON, _ := json.Marshal(task.SubTasks)
artifactsJSON, _ := json.Marshal(task.Artifacts)
memoryJSON, _ := json.Marshal(task.Memory)
scheduleJSON, _ := json.Marshal(task.Schedule)
query := `
INSERT INTO computer_tasks (
id, user_id, query, status, plan, sub_tasks, artifacts, memory,
progress, message, error, schedule, next_run_at, run_count, total_cost,
created_at, updated_at, completed_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
)
`
_, err := r.db.ExecContext(ctx, query,
task.ID,
task.UserID,
task.Query,
task.Status,
planJSON,
subTasksJSON,
artifactsJSON,
memoryJSON,
task.Progress,
task.Message,
task.Error,
scheduleJSON,
task.NextRunAt,
task.RunCount,
task.TotalCost,
task.CreatedAt,
task.UpdatedAt,
task.CompletedAt,
)
return err
}
func (r *ComputerTaskRepo) Update(ctx context.Context, task *computer.ComputerTask) error {
planJSON, _ := json.Marshal(task.Plan)
subTasksJSON, _ := json.Marshal(task.SubTasks)
artifactsJSON, _ := json.Marshal(task.Artifacts)
memoryJSON, _ := json.Marshal(task.Memory)
scheduleJSON, _ := json.Marshal(task.Schedule)
query := `
UPDATE computer_tasks SET
status = $1,
plan = $2,
sub_tasks = $3,
artifacts = $4,
memory = $5,
progress = $6,
message = $7,
error = $8,
schedule = $9,
next_run_at = $10,
run_count = $11,
total_cost = $12,
updated_at = $13,
completed_at = $14
WHERE id = $15
`
_, err := r.db.ExecContext(ctx, query,
task.Status,
planJSON,
subTasksJSON,
artifactsJSON,
memoryJSON,
task.Progress,
task.Message,
task.Error,
scheduleJSON,
task.NextRunAt,
task.RunCount,
task.TotalCost,
time.Now(),
task.CompletedAt,
task.ID,
)
return err
}
func (r *ComputerTaskRepo) GetByID(ctx context.Context, id string) (*computer.ComputerTask, error) {
query := `
SELECT id, user_id, query, status, plan, sub_tasks, artifacts, memory,
progress, message, error, schedule, next_run_at, run_count, total_cost,
created_at, updated_at, completed_at
FROM computer_tasks
WHERE id = $1
`
var task computer.ComputerTask
var planJSON, subTasksJSON, artifactsJSON, memoryJSON, scheduleJSON []byte
var message, errStr sql.NullString
var nextRunAt, completedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, id).Scan(
&task.ID,
&task.UserID,
&task.Query,
&task.Status,
&planJSON,
&subTasksJSON,
&artifactsJSON,
&memoryJSON,
&task.Progress,
&message,
&errStr,
&scheduleJSON,
&nextRunAt,
&task.RunCount,
&task.TotalCost,
&task.CreatedAt,
&task.UpdatedAt,
&completedAt,
)
if err != nil {
return nil, err
}
if len(planJSON) > 0 {
json.Unmarshal(planJSON, &task.Plan)
}
if len(subTasksJSON) > 0 {
json.Unmarshal(subTasksJSON, &task.SubTasks)
}
if len(artifactsJSON) > 0 {
json.Unmarshal(artifactsJSON, &task.Artifacts)
}
if len(memoryJSON) > 0 {
json.Unmarshal(memoryJSON, &task.Memory)
}
if len(scheduleJSON) > 0 {
json.Unmarshal(scheduleJSON, &task.Schedule)
}
if message.Valid {
task.Message = message.String
}
if errStr.Valid {
task.Error = errStr.String
}
if nextRunAt.Valid {
task.NextRunAt = &nextRunAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
return &task, nil
}
func (r *ComputerTaskRepo) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]computer.ComputerTask, error) {
query := `
SELECT id, user_id, query, status, plan, sub_tasks, artifacts, memory,
progress, message, error, schedule, next_run_at, run_count, total_cost,
created_at, updated_at, completed_at
FROM computer_tasks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []computer.ComputerTask
for rows.Next() {
var task computer.ComputerTask
var planJSON, subTasksJSON, artifactsJSON, memoryJSON, scheduleJSON []byte
var message, errStr sql.NullString
var nextRunAt, completedAt sql.NullTime
err := rows.Scan(
&task.ID,
&task.UserID,
&task.Query,
&task.Status,
&planJSON,
&subTasksJSON,
&artifactsJSON,
&memoryJSON,
&task.Progress,
&message,
&errStr,
&scheduleJSON,
&nextRunAt,
&task.RunCount,
&task.TotalCost,
&task.CreatedAt,
&task.UpdatedAt,
&completedAt,
)
if err != nil {
continue
}
if len(planJSON) > 0 {
json.Unmarshal(planJSON, &task.Plan)
}
if len(subTasksJSON) > 0 {
json.Unmarshal(subTasksJSON, &task.SubTasks)
}
if len(artifactsJSON) > 0 {
json.Unmarshal(artifactsJSON, &task.Artifacts)
}
if len(memoryJSON) > 0 {
json.Unmarshal(memoryJSON, &task.Memory)
}
if len(scheduleJSON) > 0 {
json.Unmarshal(scheduleJSON, &task.Schedule)
}
if message.Valid {
task.Message = message.String
}
if errStr.Valid {
task.Error = errStr.String
}
if nextRunAt.Valid {
task.NextRunAt = &nextRunAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
tasks = append(tasks, task)
}
return tasks, nil
}
func (r *ComputerTaskRepo) GetScheduled(ctx context.Context) ([]computer.ComputerTask, error) {
query := `
SELECT id, user_id, query, status, plan, sub_tasks, artifacts, memory,
progress, message, error, schedule, next_run_at, run_count, total_cost,
created_at, updated_at, completed_at
FROM computer_tasks
WHERE status = 'scheduled' AND schedule IS NOT NULL
ORDER BY next_run_at ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []computer.ComputerTask
for rows.Next() {
var task computer.ComputerTask
var planJSON, subTasksJSON, artifactsJSON, memoryJSON, scheduleJSON []byte
var message, errStr sql.NullString
var nextRunAt, completedAt sql.NullTime
err := rows.Scan(
&task.ID,
&task.UserID,
&task.Query,
&task.Status,
&planJSON,
&subTasksJSON,
&artifactsJSON,
&memoryJSON,
&task.Progress,
&message,
&errStr,
&scheduleJSON,
&nextRunAt,
&task.RunCount,
&task.TotalCost,
&task.CreatedAt,
&task.UpdatedAt,
&completedAt,
)
if err != nil {
continue
}
if len(planJSON) > 0 {
json.Unmarshal(planJSON, &task.Plan)
}
if len(subTasksJSON) > 0 {
json.Unmarshal(subTasksJSON, &task.SubTasks)
}
if len(artifactsJSON) > 0 {
json.Unmarshal(artifactsJSON, &task.Artifacts)
}
if len(memoryJSON) > 0 {
json.Unmarshal(memoryJSON, &task.Memory)
}
if len(scheduleJSON) > 0 {
json.Unmarshal(scheduleJSON, &task.Schedule)
}
if message.Valid {
task.Message = message.String
}
if errStr.Valid {
task.Error = errStr.String
}
if nextRunAt.Valid {
task.NextRunAt = &nextRunAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
tasks = append(tasks, task)
}
return tasks, nil
}
func (r *ComputerTaskRepo) Delete(ctx context.Context, id string) error {
query := `DELETE FROM computer_tasks WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
func (r *ComputerTaskRepo) DeleteOlderThan(ctx context.Context, days int) (int64, error) {
query := `
DELETE FROM computer_tasks
WHERE created_at < NOW() - INTERVAL '%d days'
AND status IN ('completed', 'failed', 'cancelled')
`
result, err := r.db.ExecContext(ctx, fmt.Sprintf(query, days))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (r *ComputerTaskRepo) CountByUser(ctx context.Context, userID string) (int64, error) {
query := `SELECT COUNT(*) FROM computer_tasks WHERE user_id = $1`
var count int64
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
return count, err
}
func (r *ComputerTaskRepo) CountByStatus(ctx context.Context, status string) (int64, error) {
query := `SELECT COUNT(*) FROM computer_tasks WHERE status = $1`
var count int64
err := r.db.QueryRowContext(ctx, query, status).Scan(&count)
return count, err
}

View File

@@ -0,0 +1,177 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type DigestCitation struct {
Index int `json:"index"`
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
}
type Digest struct {
ID int64 `json:"id"`
Topic string `json:"topic"`
Region string `json:"region"`
ClusterTitle string `json:"clusterTitle"`
SummaryRu string `json:"summaryRu"`
Citations []DigestCitation `json:"citations"`
SourcesCount int `json:"sourcesCount"`
FollowUp []string `json:"followUp"`
Thumbnail string `json:"thumbnail"`
ShortDescription string `json:"shortDescription"`
MainURL string `json:"mainUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DigestRepository struct {
db *PostgresDB
}
func NewDigestRepository(db *PostgresDB) *DigestRepository {
return &DigestRepository{db: db}
}
func (r *DigestRepository) GetByTopicRegionTitle(ctx context.Context, topic, region, title string) (*Digest, error) {
query := `
SELECT id, topic, region, cluster_title, summary_ru, citations, sources_count,
follow_up, thumbnail, short_description, main_url, created_at, updated_at
FROM digests
WHERE topic = $1 AND region = $2 AND cluster_title = $3
`
var d Digest
var citationsJSON, followUpJSON []byte
err := r.db.db.QueryRowContext(ctx, query, topic, region, title).Scan(
&d.ID, &d.Topic, &d.Region, &d.ClusterTitle, &d.SummaryRu,
&citationsJSON, &d.SourcesCount, &followUpJSON,
&d.Thumbnail, &d.ShortDescription, &d.MainURL,
&d.CreatedAt, &d.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(citationsJSON, &d.Citations)
json.Unmarshal(followUpJSON, &d.FollowUp)
return &d, nil
}
func (r *DigestRepository) GetByURL(ctx context.Context, url string) (*Digest, error) {
query := `
SELECT id, topic, region, cluster_title, summary_ru, citations, sources_count,
follow_up, thumbnail, short_description, main_url, created_at, updated_at
FROM digests
WHERE main_url = $1
LIMIT 1
`
var d Digest
var citationsJSON, followUpJSON []byte
err := r.db.db.QueryRowContext(ctx, query, url).Scan(
&d.ID, &d.Topic, &d.Region, &d.ClusterTitle, &d.SummaryRu,
&citationsJSON, &d.SourcesCount, &followUpJSON,
&d.Thumbnail, &d.ShortDescription, &d.MainURL,
&d.CreatedAt, &d.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(citationsJSON, &d.Citations)
json.Unmarshal(followUpJSON, &d.FollowUp)
return &d, nil
}
func (r *DigestRepository) GetByTopicRegion(ctx context.Context, topic, region string, limit int) ([]*Digest, error) {
query := `
SELECT id, topic, region, cluster_title, summary_ru, citations, sources_count,
follow_up, thumbnail, short_description, main_url, created_at, updated_at
FROM digests
WHERE topic = $1 AND region = $2
ORDER BY created_at DESC
LIMIT $3
`
rows, err := r.db.db.QueryContext(ctx, query, topic, region, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var digests []*Digest
for rows.Next() {
var d Digest
var citationsJSON, followUpJSON []byte
if err := rows.Scan(
&d.ID, &d.Topic, &d.Region, &d.ClusterTitle, &d.SummaryRu,
&citationsJSON, &d.SourcesCount, &followUpJSON,
&d.Thumbnail, &d.ShortDescription, &d.MainURL,
&d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(citationsJSON, &d.Citations)
json.Unmarshal(followUpJSON, &d.FollowUp)
digests = append(digests, &d)
}
return digests, nil
}
func (r *DigestRepository) Upsert(ctx context.Context, d *Digest) error {
citationsJSON, _ := json.Marshal(d.Citations)
followUpJSON, _ := json.Marshal(d.FollowUp)
query := `
INSERT INTO digests (topic, region, cluster_title, summary_ru, citations, sources_count,
follow_up, thumbnail, short_description, main_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (topic, region, cluster_title)
DO UPDATE SET
summary_ru = EXCLUDED.summary_ru,
citations = EXCLUDED.citations,
sources_count = EXCLUDED.sources_count,
follow_up = EXCLUDED.follow_up,
thumbnail = EXCLUDED.thumbnail,
short_description = EXCLUDED.short_description,
main_url = EXCLUDED.main_url,
updated_at = NOW()
`
_, err := r.db.db.ExecContext(ctx, query,
d.Topic, d.Region, d.ClusterTitle, d.SummaryRu,
citationsJSON, d.SourcesCount, followUpJSON,
d.Thumbnail, d.ShortDescription, d.MainURL,
)
return err
}
func (r *DigestRepository) DeleteByTopicRegion(ctx context.Context, topic, region string) (int64, error) {
result, err := r.db.db.ExecContext(ctx,
"DELETE FROM digests WHERE topic = $1 AND region = $2",
topic, region,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@@ -0,0 +1,149 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type UploadedFile struct {
ID string `json:"id"`
UserID string `json:"userId"`
Filename string `json:"filename"`
FileType string `json:"fileType"`
FileSize int64 `json:"fileSize"`
StoragePath string `json:"storagePath"`
ExtractedText string `json:"extractedText,omitempty"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"createdAt"`
}
type FileRepository struct {
db *PostgresDB
}
func NewFileRepository(db *PostgresDB) *FileRepository {
return &FileRepository{db: db}
}
func (r *FileRepository) Create(ctx context.Context, f *UploadedFile) error {
metadataJSON, _ := json.Marshal(f.Metadata)
query := `
INSERT INTO uploaded_files (user_id, filename, file_type, file_size, storage_path, extracted_text, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at
`
return r.db.db.QueryRowContext(ctx, query,
f.UserID, f.Filename, f.FileType, f.FileSize, f.StoragePath, f.ExtractedText, metadataJSON,
).Scan(&f.ID, &f.CreatedAt)
}
func (r *FileRepository) GetByID(ctx context.Context, id string) (*UploadedFile, error) {
query := `
SELECT id, user_id, filename, file_type, file_size, storage_path, extracted_text, metadata, created_at
FROM uploaded_files
WHERE id = $1
`
var f UploadedFile
var metadataJSON []byte
err := r.db.db.QueryRowContext(ctx, query, id).Scan(
&f.ID, &f.UserID, &f.Filename, &f.FileType, &f.FileSize,
&f.StoragePath, &f.ExtractedText, &metadataJSON, &f.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &f.Metadata)
return &f, nil
}
func (r *FileRepository) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*UploadedFile, error) {
query := `
SELECT id, user_id, filename, file_type, file_size, storage_path, extracted_text, metadata, created_at
FROM uploaded_files
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []*UploadedFile
for rows.Next() {
var f UploadedFile
var metadataJSON []byte
if err := rows.Scan(
&f.ID, &f.UserID, &f.Filename, &f.FileType, &f.FileSize,
&f.StoragePath, &f.ExtractedText, &metadataJSON, &f.CreatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &f.Metadata)
files = append(files, &f)
}
return files, nil
}
func (r *FileRepository) UpdateExtractedText(ctx context.Context, id, text string) error {
_, err := r.db.db.ExecContext(ctx,
"UPDATE uploaded_files SET extracted_text = $2 WHERE id = $1",
id, text,
)
return err
}
func (r *FileRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM uploaded_files WHERE id = $1", id)
return err
}
func (r *FileRepository) GetByIDs(ctx context.Context, ids []string) ([]*UploadedFile, error) {
if len(ids) == 0 {
return nil, nil
}
query := `
SELECT id, user_id, filename, file_type, file_size, storage_path, extracted_text, metadata, created_at
FROM uploaded_files
WHERE id = ANY($1)
`
rows, err := r.db.db.QueryContext(ctx, query, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var files []*UploadedFile
for rows.Next() {
var f UploadedFile
var metadataJSON []byte
if err := rows.Scan(
&f.ID, &f.UserID, &f.Filename, &f.FileType, &f.FileSize,
&f.StoragePath, &f.ExtractedText, &metadataJSON, &f.CreatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &f.Metadata)
files = append(files, &f)
}
return files, nil
}

View File

@@ -0,0 +1,170 @@
package db
import (
"context"
"encoding/json"
"time"
)
type UserMemory struct {
ID string `json:"id"`
UserID string `json:"userId"`
MemoryType string `json:"memoryType"`
Key string `json:"key"`
Value string `json:"value"`
Metadata map[string]interface{} `json:"metadata"`
Importance int `json:"importance"`
LastUsed time.Time `json:"lastUsed"`
UseCount int `json:"useCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type MemoryRepository struct {
db *PostgresDB
}
func NewMemoryRepository(db *PostgresDB) *MemoryRepository {
return &MemoryRepository{db: db}
}
func (r *MemoryRepository) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS user_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
memory_type VARCHAR(50) NOT NULL,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
importance INT DEFAULT 5,
last_used TIMESTAMPTZ DEFAULT NOW(),
use_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, memory_type, key)
)`,
`CREATE INDEX IF NOT EXISTS idx_user_memories_user ON user_memories(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_memories_type ON user_memories(user_id, memory_type)`,
`CREATE INDEX IF NOT EXISTS idx_user_memories_importance ON user_memories(user_id, importance DESC)`,
}
for _, m := range migrations {
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
return err
}
}
return nil
}
func (r *MemoryRepository) Save(ctx context.Context, mem *UserMemory) error {
metadataJSON, _ := json.Marshal(mem.Metadata)
query := `
INSERT INTO user_memories (user_id, memory_type, key, value, metadata, importance)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, memory_type, key)
DO UPDATE SET
value = EXCLUDED.value,
metadata = EXCLUDED.metadata,
importance = EXCLUDED.importance,
updated_at = NOW()
RETURNING id, created_at, updated_at
`
return r.db.db.QueryRowContext(ctx, query,
mem.UserID, mem.MemoryType, mem.Key, mem.Value, metadataJSON, mem.Importance,
).Scan(&mem.ID, &mem.CreatedAt, &mem.UpdatedAt)
}
func (r *MemoryRepository) GetByUserID(ctx context.Context, userID string, memoryType string, limit int) ([]*UserMemory, error) {
query := `
SELECT id, user_id, memory_type, key, value, metadata, importance, last_used, use_count, created_at, updated_at
FROM user_memories
WHERE user_id = $1
`
args := []interface{}{userID}
if memoryType != "" {
query += " AND memory_type = $2"
args = append(args, memoryType)
}
query += " ORDER BY importance DESC, last_used DESC"
if limit > 0 {
query += " LIMIT $" + string(rune('0'+len(args)+1))
args = append(args, limit)
}
rows, err := r.db.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var memories []*UserMemory
for rows.Next() {
var mem UserMemory
var metadataJSON []byte
if err := rows.Scan(
&mem.ID, &mem.UserID, &mem.MemoryType, &mem.Key, &mem.Value,
&metadataJSON, &mem.Importance, &mem.LastUsed, &mem.UseCount,
&mem.CreatedAt, &mem.UpdatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &mem.Metadata)
memories = append(memories, &mem)
}
return memories, nil
}
func (r *MemoryRepository) GetContextForUser(ctx context.Context, userID string) (string, error) {
memories, err := r.GetByUserID(ctx, userID, "", 20)
if err != nil {
return "", err
}
var context string
for _, mem := range memories {
switch mem.MemoryType {
case "preference":
context += "User preference: " + mem.Key + " = " + mem.Value + "\n"
case "fact":
context += "Known fact about user: " + mem.Value + "\n"
case "instruction":
context += "User instruction: " + mem.Value + "\n"
case "interest":
context += "User interest: " + mem.Value + "\n"
default:
context += mem.Key + ": " + mem.Value + "\n"
}
}
return context, nil
}
func (r *MemoryRepository) IncrementUseCount(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx,
"UPDATE user_memories SET use_count = use_count + 1, last_used = NOW() WHERE id = $1",
id,
)
return err
}
func (r *MemoryRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM user_memories WHERE id = $1", id)
return err
}
func (r *MemoryRepository) DeleteByUserID(ctx context.Context, userID string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM user_memories WHERE user_id = $1", userID)
return err
}
func ExtractMemoriesFromConversation(ctx context.Context, llmClient interface{}, conversation, answer string) ([]UserMemory, error) {
return nil, nil
}

View File

@@ -0,0 +1,219 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"github.com/gooseek/backend/internal/pages"
)
type PageRepository struct {
db *PostgresDB
}
func NewPageRepository(db *PostgresDB) *PageRepository {
return &PageRepository{db: db}
}
func (r *PageRepository) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
thread_id UUID REFERENCES threads(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
subtitle TEXT,
sections JSONB NOT NULL DEFAULT '[]',
sources JSONB NOT NULL DEFAULT '[]',
thumbnail TEXT,
is_public BOOLEAN DEFAULT FALSE,
share_id VARCHAR(100) UNIQUE,
view_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_pages_user ON pages(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_pages_share ON pages(share_id)`,
}
for _, m := range migrations {
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
return err
}
}
return nil
}
func (r *PageRepository) Create(ctx context.Context, p *pages.Page) error {
sectionsJSON, _ := json.Marshal(p.Sections)
sourcesJSON, _ := json.Marshal(p.Sources)
query := `
INSERT INTO pages (user_id, thread_id, title, subtitle, sections, sources, thumbnail, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, updated_at
`
var threadID *string
if p.ThreadID != "" {
threadID = &p.ThreadID
}
return r.db.db.QueryRowContext(ctx, query,
p.UserID, threadID, p.Title, p.Subtitle, sectionsJSON, sourcesJSON, p.Thumbnail, p.IsPublic,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (r *PageRepository) GetByID(ctx context.Context, id string) (*pages.Page, error) {
query := `
SELECT id, user_id, thread_id, title, subtitle, sections, sources, thumbnail, is_public, share_id, view_count, created_at, updated_at
FROM pages
WHERE id = $1
`
var p pages.Page
var sectionsJSON, sourcesJSON []byte
var threadID, shareID sql.NullString
err := r.db.db.QueryRowContext(ctx, query, id).Scan(
&p.ID, &p.UserID, &threadID, &p.Title, &p.Subtitle,
&sectionsJSON, &sourcesJSON, &p.Thumbnail,
&p.IsPublic, &shareID, &p.ViewCount, &p.CreatedAt, &p.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(sectionsJSON, &p.Sections)
json.Unmarshal(sourcesJSON, &p.Sources)
if threadID.Valid {
p.ThreadID = threadID.String
}
if shareID.Valid {
p.ShareID = shareID.String
}
return &p, nil
}
func (r *PageRepository) GetByShareID(ctx context.Context, shareID string) (*pages.Page, error) {
query := `
SELECT id, user_id, thread_id, title, subtitle, sections, sources, thumbnail, is_public, share_id, view_count, created_at, updated_at
FROM pages
WHERE share_id = $1 AND is_public = true
`
var p pages.Page
var sectionsJSON, sourcesJSON []byte
var threadID, shareIDVal sql.NullString
err := r.db.db.QueryRowContext(ctx, query, shareID).Scan(
&p.ID, &p.UserID, &threadID, &p.Title, &p.Subtitle,
&sectionsJSON, &sourcesJSON, &p.Thumbnail,
&p.IsPublic, &shareIDVal, &p.ViewCount, &p.CreatedAt, &p.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(sectionsJSON, &p.Sections)
json.Unmarshal(sourcesJSON, &p.Sources)
if threadID.Valid {
p.ThreadID = threadID.String
}
if shareIDVal.Valid {
p.ShareID = shareIDVal.String
}
return &p, nil
}
func (r *PageRepository) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*pages.Page, error) {
query := `
SELECT id, user_id, thread_id, title, subtitle, sections, sources, thumbnail, is_public, share_id, view_count, created_at, updated_at
FROM pages
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var pagesList []*pages.Page
for rows.Next() {
var p pages.Page
var sectionsJSON, sourcesJSON []byte
var threadID, shareID sql.NullString
if err := rows.Scan(
&p.ID, &p.UserID, &threadID, &p.Title, &p.Subtitle,
&sectionsJSON, &sourcesJSON, &p.Thumbnail,
&p.IsPublic, &shareID, &p.ViewCount, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(sectionsJSON, &p.Sections)
json.Unmarshal(sourcesJSON, &p.Sources)
if threadID.Valid {
p.ThreadID = threadID.String
}
if shareID.Valid {
p.ShareID = shareID.String
}
pagesList = append(pagesList, &p)
}
return pagesList, nil
}
func (r *PageRepository) Update(ctx context.Context, p *pages.Page) error {
sectionsJSON, _ := json.Marshal(p.Sections)
sourcesJSON, _ := json.Marshal(p.Sources)
query := `
UPDATE pages
SET title = $2, subtitle = $3, sections = $4, sources = $5, thumbnail = $6, is_public = $7, updated_at = NOW()
WHERE id = $1
`
_, err := r.db.db.ExecContext(ctx, query,
p.ID, p.Title, p.Subtitle, sectionsJSON, sourcesJSON, p.Thumbnail, p.IsPublic,
)
return err
}
func (r *PageRepository) SetShareID(ctx context.Context, pageID, shareID string) error {
_, err := r.db.db.ExecContext(ctx,
"UPDATE pages SET share_id = $2, is_public = true WHERE id = $1",
pageID, shareID,
)
return err
}
func (r *PageRepository) IncrementViewCount(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx,
"UPDATE pages SET view_count = view_count + 1 WHERE id = $1",
id,
)
return err
}
func (r *PageRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM pages WHERE id = $1", id)
return err
}

View File

@@ -0,0 +1,134 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
type PostgresDB struct {
db *sql.DB
}
func NewPostgresDB(databaseURL string) (*PostgresDB, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &PostgresDB{db: db}, nil
}
func (p *PostgresDB) Close() error {
return p.db.Close()
}
func (p *PostgresDB) DB() *sql.DB {
return p.db
}
func (p *PostgresDB) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS digests (
id SERIAL PRIMARY KEY,
topic VARCHAR(100) NOT NULL,
region VARCHAR(50) NOT NULL,
cluster_title VARCHAR(500) NOT NULL,
summary_ru TEXT NOT NULL,
citations JSONB DEFAULT '[]',
sources_count INT DEFAULT 0,
follow_up JSONB DEFAULT '[]',
thumbnail TEXT,
short_description TEXT,
main_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(topic, region, cluster_title)
)`,
`CREATE INDEX IF NOT EXISTS idx_digests_topic_region ON digests(topic, region)`,
`CREATE INDEX IF NOT EXISTS idx_digests_main_url ON digests(main_url)`,
`CREATE TABLE IF NOT EXISTS article_summaries (
id SERIAL PRIMARY KEY,
url_hash VARCHAR(64) NOT NULL UNIQUE,
url TEXT NOT NULL,
events JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
)`,
`CREATE INDEX IF NOT EXISTS idx_article_summaries_url_hash ON article_summaries(url_hash)`,
`CREATE INDEX IF NOT EXISTS idx_article_summaries_expires ON article_summaries(expires_at)`,
`CREATE TABLE IF NOT EXISTS collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
context_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id)`,
`CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL,
title VARCHAR(500),
content TEXT,
url TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
sort_order INT DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id)`,
`CREATE TABLE IF NOT EXISTS uploaded_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
filename VARCHAR(500) NOT NULL,
file_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
storage_path TEXT NOT NULL,
extracted_text TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_uploaded_files_user ON uploaded_files(user_id)`,
`CREATE TABLE IF NOT EXISTS research_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
collection_id UUID REFERENCES collections(id) ON DELETE SET NULL,
query TEXT NOT NULL,
focus_mode VARCHAR(50) DEFAULT 'all',
optimization_mode VARCHAR(50) DEFAULT 'balanced',
sources JSONB DEFAULT '[]',
response_blocks JSONB DEFAULT '[]',
final_answer TEXT,
citations JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_research_sessions_user ON research_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_research_sessions_collection ON research_sessions(collection_id)`,
}
for _, migration := range migrations {
if _, err := p.db.ExecContext(ctx, migration); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,163 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type Space struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Color string `json:"color"`
CustomInstructions string `json:"customInstructions"`
DefaultFocusMode string `json:"defaultFocusMode"`
DefaultModel string `json:"defaultModel"`
IsPublic bool `json:"isPublic"`
Settings map[string]interface{} `json:"settings"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ThreadCount int `json:"threadCount,omitempty"`
}
type SpaceRepository struct {
db *PostgresDB
}
func NewSpaceRepository(db *PostgresDB) *SpaceRepository {
return &SpaceRepository{db: db}
}
func (r *SpaceRepository) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(50),
color VARCHAR(20),
custom_instructions TEXT,
default_focus_mode VARCHAR(50) DEFAULT 'all',
default_model VARCHAR(100),
is_public BOOLEAN DEFAULT FALSE,
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_spaces_user ON spaces(user_id)`,
}
for _, m := range migrations {
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
return err
}
}
return nil
}
func (r *SpaceRepository) Create(ctx context.Context, s *Space) error {
settingsJSON, _ := json.Marshal(s.Settings)
query := `
INSERT INTO spaces (user_id, name, description, icon, color, custom_instructions, default_focus_mode, default_model, is_public, settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
return r.db.db.QueryRowContext(ctx, query,
s.UserID, s.Name, s.Description, s.Icon, s.Color,
s.CustomInstructions, s.DefaultFocusMode, s.DefaultModel,
s.IsPublic, settingsJSON,
).Scan(&s.ID, &s.CreatedAt, &s.UpdatedAt)
}
func (r *SpaceRepository) GetByID(ctx context.Context, id string) (*Space, error) {
query := `
SELECT id, user_id, name, description, icon, color, custom_instructions,
default_focus_mode, default_model, is_public, settings, created_at, updated_at,
(SELECT COUNT(*) FROM threads WHERE space_id = spaces.id) as thread_count
FROM spaces
WHERE id = $1
`
var s Space
var settingsJSON []byte
err := r.db.db.QueryRowContext(ctx, query, id).Scan(
&s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color,
&s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel,
&s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &s.Settings)
return &s, nil
}
func (r *SpaceRepository) GetByUserID(ctx context.Context, userID string) ([]*Space, error) {
query := `
SELECT id, user_id, name, description, icon, color, custom_instructions,
default_focus_mode, default_model, is_public, settings, created_at, updated_at,
(SELECT COUNT(*) FROM threads WHERE space_id = spaces.id) as thread_count
FROM spaces
WHERE user_id = $1
ORDER BY updated_at DESC
`
rows, err := r.db.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var spaces []*Space
for rows.Next() {
var s Space
var settingsJSON []byte
if err := rows.Scan(
&s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color,
&s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel,
&s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount,
); err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &s.Settings)
spaces = append(spaces, &s)
}
return spaces, nil
}
func (r *SpaceRepository) Update(ctx context.Context, s *Space) error {
settingsJSON, _ := json.Marshal(s.Settings)
query := `
UPDATE spaces
SET name = $2, description = $3, icon = $4, color = $5,
custom_instructions = $6, default_focus_mode = $7, default_model = $8,
is_public = $9, settings = $10, updated_at = NOW()
WHERE id = $1
`
_, err := r.db.db.ExecContext(ctx, query,
s.ID, s.Name, s.Description, s.Icon, s.Color,
s.CustomInstructions, s.DefaultFocusMode, s.DefaultModel,
s.IsPublic, settingsJSON,
)
return err
}
func (r *SpaceRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM spaces WHERE id = $1", id)
return err
}

View File

@@ -0,0 +1,270 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type Thread struct {
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID *string `json:"spaceId,omitempty"`
Title string `json:"title"`
FocusMode string `json:"focusMode"`
IsPublic bool `json:"isPublic"`
ShareID *string `json:"shareId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Messages []ThreadMessage `json:"messages,omitempty"`
MessageCount int `json:"messageCount,omitempty"`
}
type ThreadMessage struct {
ID string `json:"id"`
ThreadID string `json:"threadId"`
Role string `json:"role"`
Content string `json:"content"`
Sources []ThreadSource `json:"sources,omitempty"`
Widgets []map[string]interface{} `json:"widgets,omitempty"`
RelatedQuestions []string `json:"relatedQuestions,omitempty"`
Model string `json:"model,omitempty"`
TokensUsed int `json:"tokensUsed,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type ThreadSource struct {
Index int `json:"index"`
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
Snippet string `json:"snippet,omitempty"`
}
type ThreadRepository struct {
db *PostgresDB
}
func NewThreadRepository(db *PostgresDB) *ThreadRepository {
return &ThreadRepository{db: db}
}
func (r *ThreadRepository) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
space_id UUID REFERENCES spaces(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL DEFAULT 'New Thread',
focus_mode VARCHAR(50) DEFAULT 'all',
is_public BOOLEAN DEFAULT FALSE,
share_id VARCHAR(100) UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_threads_user ON threads(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_threads_space ON threads(space_id)`,
`CREATE INDEX IF NOT EXISTS idx_threads_share ON threads(share_id)`,
`CREATE TABLE IF NOT EXISTS thread_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
sources JSONB DEFAULT '[]',
widgets JSONB DEFAULT '[]',
related_questions JSONB DEFAULT '[]',
model VARCHAR(100),
tokens_used INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)`,
}
for _, m := range migrations {
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
return err
}
}
return nil
}
func (r *ThreadRepository) Create(ctx context.Context, t *Thread) error {
query := `
INSERT INTO threads (user_id, space_id, title, focus_mode, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at
`
return r.db.db.QueryRowContext(ctx, query,
t.UserID, t.SpaceID, t.Title, t.FocusMode, t.IsPublic,
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
func (r *ThreadRepository) GetByID(ctx context.Context, id string) (*Thread, error) {
query := `
SELECT id, user_id, space_id, title, focus_mode, is_public, share_id, created_at, updated_at,
(SELECT COUNT(*) FROM thread_messages WHERE thread_id = threads.id) as message_count
FROM threads
WHERE id = $1
`
var t Thread
err := r.db.db.QueryRowContext(ctx, query, id).Scan(
&t.ID, &t.UserID, &t.SpaceID, &t.Title, &t.FocusMode,
&t.IsPublic, &t.ShareID, &t.CreatedAt, &t.UpdatedAt, &t.MessageCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
func (r *ThreadRepository) GetByShareID(ctx context.Context, shareID string) (*Thread, error) {
query := `
SELECT id, user_id, space_id, title, focus_mode, is_public, share_id, created_at, updated_at
FROM threads
WHERE share_id = $1 AND is_public = true
`
var t Thread
err := r.db.db.QueryRowContext(ctx, query, shareID).Scan(
&t.ID, &t.UserID, &t.SpaceID, &t.Title, &t.FocusMode,
&t.IsPublic, &t.ShareID, &t.CreatedAt, &t.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
func (r *ThreadRepository) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*Thread, error) {
query := `
SELECT id, user_id, space_id, title, focus_mode, is_public, share_id, created_at, updated_at,
(SELECT COUNT(*) FROM thread_messages WHERE thread_id = threads.id) as message_count
FROM threads
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var threads []*Thread
for rows.Next() {
var t Thread
if err := rows.Scan(
&t.ID, &t.UserID, &t.SpaceID, &t.Title, &t.FocusMode,
&t.IsPublic, &t.ShareID, &t.CreatedAt, &t.UpdatedAt, &t.MessageCount,
); err != nil {
return nil, err
}
threads = append(threads, &t)
}
return threads, nil
}
func (r *ThreadRepository) Update(ctx context.Context, t *Thread) error {
query := `
UPDATE threads
SET title = $2, focus_mode = $3, is_public = $4, updated_at = NOW()
WHERE id = $1
`
_, err := r.db.db.ExecContext(ctx, query, t.ID, t.Title, t.FocusMode, t.IsPublic)
return err
}
func (r *ThreadRepository) SetShareID(ctx context.Context, threadID, shareID string) error {
_, err := r.db.db.ExecContext(ctx,
"UPDATE threads SET share_id = $2, is_public = true WHERE id = $1",
threadID, shareID,
)
return err
}
func (r *ThreadRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.db.ExecContext(ctx, "DELETE FROM threads WHERE id = $1", id)
return err
}
func (r *ThreadRepository) AddMessage(ctx context.Context, msg *ThreadMessage) error {
sourcesJSON, _ := json.Marshal(msg.Sources)
widgetsJSON, _ := json.Marshal(msg.Widgets)
relatedJSON, _ := json.Marshal(msg.RelatedQuestions)
query := `
INSERT INTO thread_messages (thread_id, role, content, sources, widgets, related_questions, model, tokens_used)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at
`
err := r.db.db.QueryRowContext(ctx, query,
msg.ThreadID, msg.Role, msg.Content, sourcesJSON, widgetsJSON, relatedJSON, msg.Model, msg.TokensUsed,
).Scan(&msg.ID, &msg.CreatedAt)
if err == nil {
r.db.db.ExecContext(ctx, "UPDATE threads SET updated_at = NOW() WHERE id = $1", msg.ThreadID)
}
return err
}
func (r *ThreadRepository) GetMessages(ctx context.Context, threadID string, limit, offset int) ([]ThreadMessage, error) {
query := `
SELECT id, thread_id, role, content, sources, widgets, related_questions, model, tokens_used, created_at
FROM thread_messages
WHERE thread_id = $1
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`
rows, err := r.db.db.QueryContext(ctx, query, threadID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []ThreadMessage
for rows.Next() {
var msg ThreadMessage
var sourcesJSON, widgetsJSON, relatedJSON []byte
if err := rows.Scan(
&msg.ID, &msg.ThreadID, &msg.Role, &msg.Content,
&sourcesJSON, &widgetsJSON, &relatedJSON,
&msg.Model, &msg.TokensUsed, &msg.CreatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(sourcesJSON, &msg.Sources)
json.Unmarshal(widgetsJSON, &msg.Widgets)
json.Unmarshal(relatedJSON, &msg.RelatedQuestions)
messages = append(messages, msg)
}
return messages, nil
}
func (r *ThreadRepository) GenerateTitle(ctx context.Context, threadID, firstMessage string) error {
title := firstMessage
if len(title) > 100 {
title = title[:97] + "..."
}
_, err := r.db.db.ExecContext(ctx,
"UPDATE threads SET title = $2 WHERE id = $1",
threadID, title,
)
return err
}

View File

@@ -0,0 +1,323 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type UserInterestsData struct {
UserID string `json:"userId"`
Topics json.RawMessage `json:"topics"`
Sources json.RawMessage `json:"sources"`
Keywords json.RawMessage `json:"keywords"`
ViewHistory json.RawMessage `json:"viewHistory"`
SavedArticles json.RawMessage `json:"savedArticles"`
BlockedSources json.RawMessage `json:"blockedSources"`
BlockedTopics json.RawMessage `json:"blockedTopics"`
PreferredLang string `json:"preferredLang"`
Region string `json:"region"`
ReadingLevel string `json:"readingLevel"`
Notifications json.RawMessage `json:"notifications"`
CustomCategories json.RawMessage `json:"customCategories"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserInterestsRepository struct {
db *PostgresDB
}
func NewUserInterestsRepository(db *PostgresDB) *UserInterestsRepository {
return &UserInterestsRepository{db: db}
}
func (r *UserInterestsRepository) createTable(ctx context.Context) error {
query := `
CREATE TABLE IF NOT EXISTS user_interests (
user_id VARCHAR(255) PRIMARY KEY,
topics JSONB DEFAULT '{}',
sources JSONB DEFAULT '{}',
keywords JSONB DEFAULT '{}',
view_history JSONB DEFAULT '[]',
saved_articles JSONB DEFAULT '[]',
blocked_sources JSONB DEFAULT '[]',
blocked_topics JSONB DEFAULT '[]',
preferred_lang VARCHAR(10) DEFAULT 'ru',
region VARCHAR(50) DEFAULT 'russia',
reading_level VARCHAR(20) DEFAULT 'general',
notifications JSONB DEFAULT '{}',
custom_categories JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_interests_updated ON user_interests(updated_at);
CREATE INDEX IF NOT EXISTS idx_user_interests_region ON user_interests(region);
`
_, err := r.db.DB().ExecContext(ctx, query)
return err
}
func (r *UserInterestsRepository) Get(ctx context.Context, userID string) (*UserInterestsData, error) {
query := `
SELECT user_id, topics, sources, keywords, view_history, saved_articles,
blocked_sources, blocked_topics, preferred_lang, region, reading_level,
notifications, custom_categories, created_at, updated_at
FROM user_interests
WHERE user_id = $1
`
row := r.db.DB().QueryRowContext(ctx, query, userID)
var data UserInterestsData
err := row.Scan(
&data.UserID, &data.Topics, &data.Sources, &data.Keywords,
&data.ViewHistory, &data.SavedArticles, &data.BlockedSources,
&data.BlockedTopics, &data.PreferredLang, &data.Region,
&data.ReadingLevel, &data.Notifications, &data.CustomCategories,
&data.CreatedAt, &data.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &data, nil
}
func (r *UserInterestsRepository) Save(ctx context.Context, data *UserInterestsData) error {
query := `
INSERT INTO user_interests (
user_id, topics, sources, keywords, view_history, saved_articles,
blocked_sources, blocked_topics, preferred_lang, region, reading_level,
notifications, custom_categories, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (user_id) DO UPDATE SET
topics = EXCLUDED.topics,
sources = EXCLUDED.sources,
keywords = EXCLUDED.keywords,
view_history = EXCLUDED.view_history,
saved_articles = EXCLUDED.saved_articles,
blocked_sources = EXCLUDED.blocked_sources,
blocked_topics = EXCLUDED.blocked_topics,
preferred_lang = EXCLUDED.preferred_lang,
region = EXCLUDED.region,
reading_level = EXCLUDED.reading_level,
notifications = EXCLUDED.notifications,
custom_categories = EXCLUDED.custom_categories,
updated_at = NOW()
`
now := time.Now()
if data.CreatedAt.IsZero() {
data.CreatedAt = now
}
data.UpdatedAt = now
if data.Topics == nil {
data.Topics = json.RawMessage("{}")
}
if data.Sources == nil {
data.Sources = json.RawMessage("{}")
}
if data.Keywords == nil {
data.Keywords = json.RawMessage("{}")
}
if data.ViewHistory == nil {
data.ViewHistory = json.RawMessage("[]")
}
if data.SavedArticles == nil {
data.SavedArticles = json.RawMessage("[]")
}
if data.BlockedSources == nil {
data.BlockedSources = json.RawMessage("[]")
}
if data.BlockedTopics == nil {
data.BlockedTopics = json.RawMessage("[]")
}
if data.Notifications == nil {
data.Notifications = json.RawMessage("{}")
}
if data.CustomCategories == nil {
data.CustomCategories = json.RawMessage("[]")
}
_, err := r.db.DB().ExecContext(ctx, query,
data.UserID, data.Topics, data.Sources, data.Keywords,
data.ViewHistory, data.SavedArticles, data.BlockedSources,
data.BlockedTopics, data.PreferredLang, data.Region,
data.ReadingLevel, data.Notifications, data.CustomCategories,
data.CreatedAt, data.UpdatedAt,
)
return err
}
func (r *UserInterestsRepository) Delete(ctx context.Context, userID string) error {
query := `DELETE FROM user_interests WHERE user_id = $1`
_, err := r.db.DB().ExecContext(ctx, query, userID)
return err
}
func (r *UserInterestsRepository) AddViewEvent(ctx context.Context, userID string, event json.RawMessage) error {
query := `
UPDATE user_interests
SET view_history = CASE
WHEN jsonb_array_length(view_history) >= 500
THEN jsonb_build_array($2) || view_history[0:499]
ELSE jsonb_build_array($2) || view_history
END,
updated_at = NOW()
WHERE user_id = $1
`
result, err := r.db.DB().ExecContext(ctx, query, userID, event)
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
insertQuery := `
INSERT INTO user_interests (user_id, view_history, updated_at)
VALUES ($1, jsonb_build_array($2), NOW())
`
_, err = r.db.DB().ExecContext(ctx, insertQuery, userID, event)
}
return err
}
func (r *UserInterestsRepository) UpdateTopicScore(ctx context.Context, userID, topic string, delta float64) error {
query := `
UPDATE user_interests
SET topics = topics || jsonb_build_object($2, COALESCE((topics->>$2)::float, 0) + $3),
updated_at = NOW()
WHERE user_id = $1
`
result, err := r.db.DB().ExecContext(ctx, query, userID, topic, delta)
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
insertQuery := `
INSERT INTO user_interests (user_id, topics, updated_at)
VALUES ($1, jsonb_build_object($2, $3), NOW())
`
_, err = r.db.DB().ExecContext(ctx, insertQuery, userID, topic, delta)
}
return err
}
func (r *UserInterestsRepository) SaveArticle(ctx context.Context, userID, articleURL string) error {
query := `
UPDATE user_interests
SET saved_articles = CASE
WHEN NOT saved_articles ? $2
THEN saved_articles || jsonb_build_array($2)
ELSE saved_articles
END,
updated_at = NOW()
WHERE user_id = $1
`
_, err := r.db.DB().ExecContext(ctx, query, userID, articleURL)
return err
}
func (r *UserInterestsRepository) UnsaveArticle(ctx context.Context, userID, articleURL string) error {
query := `
UPDATE user_interests
SET saved_articles = saved_articles - $2,
updated_at = NOW()
WHERE user_id = $1
`
_, err := r.db.DB().ExecContext(ctx, query, userID, articleURL)
return err
}
func (r *UserInterestsRepository) BlockSource(ctx context.Context, userID, source string) error {
query := `
UPDATE user_interests
SET blocked_sources = CASE
WHEN NOT blocked_sources ? $2
THEN blocked_sources || jsonb_build_array($2)
ELSE blocked_sources
END,
updated_at = NOW()
WHERE user_id = $1
`
_, err := r.db.DB().ExecContext(ctx, query, userID, source)
return err
}
func (r *UserInterestsRepository) UnblockSource(ctx context.Context, userID, source string) error {
query := `
UPDATE user_interests
SET blocked_sources = blocked_sources - $2,
updated_at = NOW()
WHERE user_id = $1
`
_, err := r.db.DB().ExecContext(ctx, query, userID, source)
return err
}
func (r *UserInterestsRepository) GetTopUsers(ctx context.Context, limit int) ([]string, error) {
query := `
SELECT user_id FROM user_interests
ORDER BY updated_at DESC
LIMIT $1
`
rows, err := r.db.DB().QueryContext(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var userIDs []string
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return userIDs, rows.Err()
}
func (r *UserInterestsRepository) DecayAllInterests(ctx context.Context, decayFactor float64) error {
query := `
UPDATE user_interests
SET topics = (
SELECT jsonb_object_agg(key, (value::text::float * $1))
FROM jsonb_each(topics) WHERE (value::text::float * $1) > 0.01
),
sources = (
SELECT jsonb_object_agg(key, (value::text::float * $1))
FROM jsonb_each(sources) WHERE (value::text::float * $1) > 0.01
),
keywords = (
SELECT jsonb_object_agg(key, (value::text::float * $1))
FROM jsonb_each(keywords) WHERE (value::text::float * $1) > 0.01
),
updated_at = NOW()
`
_, err := r.db.DB().ExecContext(ctx, query, decayFactor)
return err
}