webapi_buddyfeed.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. package state
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "log/slog"
  8. "strings"
  9. "time"
  10. )
  11. // BuddyFeed represents a user's feed configuration.
  12. type BuddyFeed struct {
  13. ID int64 `json:"id"`
  14. ScreenName string `json:"screenName"`
  15. FeedType string `json:"feedType"`
  16. Title string `json:"title"`
  17. Description string `json:"description"`
  18. Link string `json:"link"`
  19. PublishedAt time.Time `json:"publishedAt"`
  20. CreatedAt time.Time `json:"createdAt"`
  21. UpdatedAt time.Time `json:"updatedAt"`
  22. IsActive bool `json:"isActive"`
  23. }
  24. // BuddyFeedItem represents an individual feed entry.
  25. type BuddyFeedItem struct {
  26. ID int64 `json:"id"`
  27. FeedID int64 `json:"feedId"`
  28. Title string `json:"title"`
  29. Description string `json:"description"`
  30. Link string `json:"link"`
  31. GUID string `json:"guid"`
  32. Author string `json:"author"`
  33. Categories []string `json:"categories"`
  34. PublishedAt time.Time `json:"publishedAt"`
  35. CreatedAt time.Time `json:"createdAt"`
  36. }
  37. // BuddyFeedSubscription represents a feed subscription.
  38. type BuddyFeedSubscription struct {
  39. ID int64 `json:"id"`
  40. SubscriberScreenName string `json:"subscriberScreenName"`
  41. FeedID int64 `json:"feedId"`
  42. SubscribedAt time.Time `json:"subscribedAt"`
  43. LastCheckedAt *time.Time `json:"lastCheckedAt"`
  44. }
  45. // BuddyFeedManager manages buddy feed operations.
  46. type BuddyFeedManager struct {
  47. db *sql.DB
  48. logger *slog.Logger
  49. }
  50. // NewBuddyFeedManager creates a new buddy feed manager.
  51. func NewBuddyFeedManager(db *sql.DB, logger *slog.Logger) *BuddyFeedManager {
  52. return &BuddyFeedManager{
  53. db: db,
  54. logger: logger,
  55. }
  56. }
  57. // CreateFeed creates a new buddy feed.
  58. func (m *BuddyFeedManager) CreateFeed(ctx context.Context, feed BuddyFeed) (*BuddyFeed, error) {
  59. now := time.Now()
  60. query := `
  61. INSERT INTO buddy_feeds (
  62. screen_name, feed_type, title, description, link,
  63. published_at, created_at, updated_at, is_active
  64. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  65. RETURNING id
  66. `
  67. var id int64
  68. err := m.db.QueryRowContext(ctx, query,
  69. feed.ScreenName, feed.FeedType, feed.Title, feed.Description, feed.Link,
  70. feed.PublishedAt.Unix(), now.Unix(), now.Unix(), feed.IsActive,
  71. ).Scan(&id)
  72. if err != nil {
  73. return nil, fmt.Errorf("failed to create feed: %w", err)
  74. }
  75. feed.ID = id
  76. feed.CreatedAt = now
  77. feed.UpdatedAt = now
  78. return &feed, nil
  79. }
  80. // GetUserFeed retrieves the feed configuration for a specific user.
  81. func (m *BuddyFeedManager) GetUserFeed(ctx context.Context, screenName string) (*BuddyFeed, error) {
  82. var feed BuddyFeed
  83. query := `
  84. SELECT id, screen_name, feed_type, title, description, link,
  85. published_at, created_at, updated_at, is_active
  86. FROM buddy_feeds
  87. WHERE screen_name = ? AND is_active = 1
  88. ORDER BY published_at DESC
  89. LIMIT 1
  90. `
  91. err := m.db.QueryRowContext(ctx, query, screenName).Scan(
  92. &feed.ID, &feed.ScreenName, &feed.FeedType, &feed.Title,
  93. &feed.Description, &feed.Link, &feed.PublishedAt,
  94. &feed.CreatedAt, &feed.UpdatedAt, &feed.IsActive,
  95. )
  96. if err == sql.ErrNoRows {
  97. return nil, nil // No feed found
  98. }
  99. if err != nil {
  100. return nil, fmt.Errorf("failed to get user feed: %w", err)
  101. }
  102. return &feed, nil
  103. }
  104. // GetBuddyListFeedItems retrieves aggregated feed items for a user's buddy list.
  105. func (m *BuddyFeedManager) GetBuddyListFeedItems(ctx context.Context, buddies []IdentScreenName, limit int) ([]BuddyFeedItem, error) {
  106. if limit <= 0 {
  107. limit = 100 // Default limit
  108. }
  109. if len(buddies) == 0 {
  110. return []BuddyFeedItem{}, nil
  111. }
  112. // Build placeholders for IN clause
  113. placeholders := make([]string, len(buddies))
  114. args := make([]interface{}, len(buddies)+1)
  115. for i, buddy := range buddies {
  116. placeholders[i] = "?"
  117. args[i] = buddy.String()
  118. }
  119. args[len(buddies)] = limit
  120. // Query to get feed items from all buddies, sorted by published date
  121. query := fmt.Sprintf(`
  122. SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
  123. i.author, i.categories, i.published_at, i.created_at
  124. FROM buddy_feed_items i
  125. JOIN buddy_feeds f ON i.feed_id = f.id
  126. WHERE f.screen_name IN (%s) AND f.is_active = 1
  127. ORDER BY i.published_at DESC
  128. LIMIT ?
  129. `, strings.Join(placeholders, ","))
  130. rows, err := m.db.QueryContext(ctx, query, args...)
  131. if err != nil {
  132. return nil, fmt.Errorf("failed to query buddy list feed items: %w", err)
  133. }
  134. defer rows.Close()
  135. return m.scanFeedItems(rows)
  136. }
  137. // GetUserFeedItems retrieves feed items for a specific user.
  138. func (m *BuddyFeedManager) GetUserFeedItems(ctx context.Context, screenName string, limit int) ([]BuddyFeedItem, error) {
  139. query := `
  140. SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
  141. i.author, i.categories, i.published_at, i.created_at
  142. FROM buddy_feed_items i
  143. JOIN buddy_feeds f ON i.feed_id = f.id
  144. WHERE f.screen_name = ? AND f.is_active = 1
  145. ORDER BY i.published_at DESC
  146. LIMIT ?
  147. `
  148. rows, err := m.db.QueryContext(ctx, query, screenName, limit)
  149. if err != nil {
  150. return nil, fmt.Errorf("failed to query feed items: %w", err)
  151. }
  152. defer rows.Close()
  153. var items []BuddyFeedItem
  154. for rows.Next() {
  155. var item BuddyFeedItem
  156. var categoriesJSON sql.NullString
  157. var publishedAt, createdAt int64
  158. err := rows.Scan(
  159. &item.ID, &item.FeedID, &item.Title, &item.Description,
  160. &item.Link, &item.GUID, &item.Author, &categoriesJSON,
  161. &publishedAt, &createdAt,
  162. )
  163. if err != nil {
  164. return nil, fmt.Errorf("failed to scan feed item: %w", err)
  165. }
  166. item.PublishedAt = time.Unix(publishedAt, 0)
  167. item.CreatedAt = time.Unix(createdAt, 0)
  168. if categoriesJSON.Valid {
  169. json.Unmarshal([]byte(categoriesJSON.String), &item.Categories)
  170. }
  171. items = append(items, item)
  172. }
  173. return items, nil
  174. }
  175. // GetFeedItems retrieves items for a specific feed.
  176. func (m *BuddyFeedManager) GetFeedItems(ctx context.Context, feedID int64, limit int) ([]BuddyFeedItem, error) {
  177. query := `
  178. SELECT id, feed_id, title, description, link, guid,
  179. author, categories, published_at, created_at
  180. FROM buddy_feed_items
  181. WHERE feed_id = ?
  182. ORDER BY published_at DESC
  183. LIMIT ?
  184. `
  185. rows, err := m.db.QueryContext(ctx, query, feedID, limit)
  186. if err != nil {
  187. return nil, fmt.Errorf("failed to query feed items: %w", err)
  188. }
  189. defer rows.Close()
  190. return m.scanFeedItems(rows)
  191. }
  192. // scanFeedItems is a helper to scan feed items from database rows.
  193. func (m *BuddyFeedManager) scanFeedItems(rows *sql.Rows) ([]BuddyFeedItem, error) {
  194. var items []BuddyFeedItem
  195. for rows.Next() {
  196. var item BuddyFeedItem
  197. var categoriesJSON sql.NullString
  198. var publishedAt, createdAt int64
  199. err := rows.Scan(
  200. &item.ID, &item.FeedID, &item.Title, &item.Description,
  201. &item.Link, &item.GUID, &item.Author, &categoriesJSON,
  202. &publishedAt, &createdAt,
  203. )
  204. if err != nil {
  205. return nil, fmt.Errorf("failed to scan feed item: %w", err)
  206. }
  207. item.PublishedAt = time.Unix(publishedAt, 0)
  208. item.CreatedAt = time.Unix(createdAt, 0)
  209. if categoriesJSON.Valid {
  210. json.Unmarshal([]byte(categoriesJSON.String), &item.Categories)
  211. }
  212. items = append(items, item)
  213. }
  214. return items, nil
  215. }
  216. // AddFeedItem adds a new item to a feed.
  217. func (m *BuddyFeedManager) AddFeedItem(ctx context.Context, feedID int64, item BuddyFeedItem) (*BuddyFeedItem, error) {
  218. categoriesJSON, _ := json.Marshal(item.Categories)
  219. now := time.Now()
  220. query := `
  221. INSERT INTO buddy_feed_items (
  222. feed_id, title, description, link, guid,
  223. author, categories, published_at, created_at
  224. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  225. RETURNING id
  226. `
  227. var id int64
  228. err := m.db.QueryRowContext(ctx, query,
  229. feedID, item.Title, item.Description, item.Link, item.GUID,
  230. item.Author, string(categoriesJSON), item.PublishedAt.Unix(), now.Unix(),
  231. ).Scan(&id)
  232. if err != nil {
  233. return nil, fmt.Errorf("failed to add feed item: %w", err)
  234. }
  235. item.ID = id
  236. item.FeedID = feedID
  237. item.CreatedAt = now
  238. // Update feed's updated_at timestamp
  239. updateQuery := `UPDATE buddy_feeds SET updated_at = ? WHERE id = ?`
  240. m.db.ExecContext(ctx, updateQuery, now.Unix(), feedID)
  241. return &item, nil
  242. }
  243. // GetOrCreateFeedForUser gets an existing feed or creates a new one for a user.
  244. func (m *BuddyFeedManager) GetOrCreateFeedForUser(ctx context.Context, screenName string, feedType string) (int64, error) {
  245. var feedID int64
  246. query := `SELECT id FROM buddy_feeds WHERE screen_name = ? AND is_active = 1 LIMIT 1`
  247. err := m.db.QueryRowContext(ctx, query, screenName).Scan(&feedID)
  248. if err == nil {
  249. return feedID, nil
  250. }
  251. if err != sql.ErrNoRows {
  252. return 0, fmt.Errorf("failed to query feed: %w", err)
  253. }
  254. // Create new feed
  255. if feedType == "" {
  256. feedType = "status"
  257. }
  258. feed := BuddyFeed{
  259. ScreenName: screenName,
  260. FeedType: feedType,
  261. Title: fmt.Sprintf("%s's Feed", screenName),
  262. Description: fmt.Sprintf("Updates from %s", screenName),
  263. Link: fmt.Sprintf("/buddyfeed/getUser?u=%s", screenName),
  264. PublishedAt: time.Now(),
  265. IsActive: true,
  266. }
  267. createdFeed, err := m.CreateFeed(ctx, feed)
  268. if err != nil {
  269. return 0, fmt.Errorf("failed to create feed: %w", err)
  270. }
  271. return createdFeed.ID, nil
  272. }