package postgres import ( "context" "database/sql" "encoding/json" "fmt" "strings" "switchboard-core/models" "switchboard-core/store" "github.com/lib/pq" ) type NoteStore struct{} func NewNoteStore() *NoteStore { return &NoteStore{} } func (s *NoteStore) Create(ctx context.Context, n *models.Note) error { return DB.QueryRowContext(ctx, ` INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`, n.UserID, n.Title, n.Content, n.FolderPath, pq.Array(n.Tags), ToJSON(n.Metadata), models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID), models.NullString(n.TeamID), ).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt) } func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) { var n models.Note var sourceChannelID, sourceMessageID, teamID sql.NullString var metadataJSON []byte err := DB.QueryRowContext(ctx, ` SELECT id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at FROM notes WHERE id = $1`, id).Scan( &n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, pq.Array(&n.Tags), &metadataJSON, &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt, ) if err != nil { return nil, err } n.SourceChannelID = NullableStringPtr(sourceChannelID) n.SourceMessageID = NullableStringPtr(sourceMessageID) n.TeamID = NullableStringPtr(teamID) json.Unmarshal(metadataJSON, &n.Metadata) return &n, nil } func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { b := NewUpdate("notes") for k, v := range fields { if k == "metadata" { b.SetJSON(k, v) } else if k == "tags" { if tags, ok := v.([]string); ok { b.Set(k, pq.Array(tags)) } } else { b.Set(k, v) } } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *NoteStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = $1", id) return err } func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) { b := NewSelect( "id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at", "notes", ).Where("user_id = ?", userID) if opts.FolderPath != "" { b.Where("folder_path = ?", opts.FolderPath) } if opts.Tag != "" { b.Where("? = ANY(tags)", opts.Tag) } if opts.TeamID != "" { b.Where("team_id = ?", opts.TeamID) } // Count countQ, countArgs := b.CountBuild() var total int DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) if opts.Sort == "" { b.OrderBy("updated_at", "DESC") } b.Paginate(opts.ListOptions) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.Note for rows.Next() { var n models.Note var sourceChannelID, sourceMessageID, teamID sql.NullString var metadataJSON []byte err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, pq.Array(&n.Tags), &metadataJSON, &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt) if err != nil { return nil, 0, err } n.SourceChannelID = NullableStringPtr(sourceChannelID) n.SourceMessageID = NullableStringPtr(sourceMessageID) n.TeamID = NullableStringPtr(teamID) json.Unmarshal(metadataJSON, &n.Metadata) result = append(result, n) } return result, total, rows.Err() } func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) { tsQuery := strings.Join(strings.Fields(query), " & ") b := NewSelect( "id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at", "notes", ).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery) b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC") b.Paginate(opts) var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM notes WHERE user_id = $1 AND search_vector @@ to_tsquery('english', $2)", userID, tsQuery).Scan(&total) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.Note for rows.Next() { var n models.Note var sourceChannelID, sourceMessageID, teamID sql.NullString var metadataJSON []byte err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, pq.Array(&n.Tags), &metadataJSON, &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt) if err != nil { return nil, 0, err } n.SourceChannelID = NullableStringPtr(sourceChannelID) n.SourceMessageID = NullableStringPtr(sourceMessageID) n.TeamID = NullableStringPtr(teamID) json.Unmarshal(metadataJSON, &n.Metadata) result = append(result, n) } return result, total, rows.Err() } func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) { if len(ids) == 0 { return 0, nil } placeholders := make([]string, len(ids)) args := make([]interface{}, 0, len(ids)+1) args = append(args, userID) for i, id := range ids { placeholders[i] = fmt.Sprintf("$%d", i+2) args = append(args, id) } result, err := DB.ExecContext(ctx, fmt.Sprintf("DELETE FROM notes WHERE user_id = $1 AND id IN (%s)", strings.Join(placeholders, ",")), args...) if err != nil { return 0, err } n, _ := result.RowsAffected() return int(n), nil } func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) { if limit <= 0 || limit > 50 { limit = 10 } rows, err := DB.QueryContext(ctx, ` SELECT id, title, folder_path FROM notes WHERE user_id = $1 AND title ILIKE '%' || $2 || '%' ORDER BY updated_at DESC LIMIT $3`, userID, query, limit) if err != nil { return nil, err } defer rows.Close() var results []models.Note for rows.Next() { var n models.Note if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil { return nil, err } results = append(results, n) } return results, rows.Err() } // ── CS5c additions (v0.29.0) ────────────────────────────────────────── func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { _, err := DB.ExecContext(ctx, `UPDATE notes SET embedding = $1::vector WHERE id = $2`, vecStr, noteID) return err } func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, title, folder_path, tags, LEFT(content, 500), ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, ts_headline('english', content, plainto_tsquery('english', $2), 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline FROM notes WHERE user_id = $1 AND search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC LIMIT $3 `, userID, query, limit) if err != nil { return nil, err } defer rows.Close() results := make([]store.NoteSearchResult, 0) for rows.Next() { var r store.NoteSearchResult var dbTags pq.StringArray if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { continue } r.Tags = []string(dbTags) if r.Tags == nil { r.Tags = []string{} } results = append(results, r) } return results, rows.Err() } func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, title, folder_path, tags, LEFT(content, 500), 1 - (embedding <=> $2::vector) AS similarity FROM notes WHERE user_id = $1 AND embedding IS NOT NULL AND 1 - (embedding <=> $2::vector) > 0.3 ORDER BY embedding <=> $2::vector LIMIT $3 `, userID, vecStr, limit) if err != nil { return nil, err } defer rows.Close() results := make([]store.NoteSearchResult, 0) for rows.Next() { var r store.NoteSearchResult var dbTags pq.StringArray if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank); err != nil { continue } r.Tags = []string(dbTags) if r.Tags == nil { r.Tags = []string{} } results = append(results, r) } return results, rows.Err() } func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { rows, err := DB.QueryContext(ctx, ` SELECT DISTINCT folder_path, COUNT(*) AS count FROM notes WHERE user_id = $1 GROUP BY folder_path ORDER BY folder_path `, userID) if err != nil { return nil, err } defer rows.Close() results := make([]store.FolderInfo, 0) for rows.Next() { var f store.FolderInfo if err := rows.Scan(&f.Path, &f.Count); err != nil { continue } results = append(results, f) } return results, rows.Err() }