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:
97
backend/internal/db/article_summary_repo.go
Normal file
97
backend/internal/db/article_summary_repo.go
Normal 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()
|
||||
}
|
||||
204
backend/internal/db/collection_repo.go
Normal file
204
backend/internal/db/collection_repo.go
Normal 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
|
||||
}
|
||||
322
backend/internal/db/computer_artifact_repo.go
Normal file
322
backend/internal/db/computer_artifact_repo.go
Normal 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
|
||||
}
|
||||
306
backend/internal/db/computer_memory_repo.go
Normal file
306
backend/internal/db/computer_memory_repo.go
Normal 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
|
||||
}
|
||||
411
backend/internal/db/computer_task_repo.go
Normal file
411
backend/internal/db/computer_task_repo.go
Normal 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
|
||||
}
|
||||
177
backend/internal/db/digest_repo.go
Normal file
177
backend/internal/db/digest_repo.go
Normal 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()
|
||||
}
|
||||
149
backend/internal/db/file_repo.go
Normal file
149
backend/internal/db/file_repo.go
Normal 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
|
||||
}
|
||||
170
backend/internal/db/memory_repo.go
Normal file
170
backend/internal/db/memory_repo.go
Normal 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
|
||||
}
|
||||
219
backend/internal/db/page_repo.go
Normal file
219
backend/internal/db/page_repo.go
Normal 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,
|
||||
§ionsJSON, &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,
|
||||
§ionsJSON, &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,
|
||||
§ionsJSON, &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
|
||||
}
|
||||
134
backend/internal/db/postgres.go
Normal file
134
backend/internal/db/postgres.go
Normal 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
|
||||
}
|
||||
163
backend/internal/db/space_repo.go
Normal file
163
backend/internal/db/space_repo.go
Normal 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
|
||||
}
|
||||
270
backend/internal/db/thread_repo.go
Normal file
270
backend/internal/db/thread_repo.go
Normal 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
|
||||
}
|
||||
323
backend/internal/db/user_interests_repo.go
Normal file
323
backend/internal/db/user_interests_repo.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user