webapi_buddyfeed.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. if err := json.Unmarshal([]byte(categoriesJSON.String), &item.Categories); err != nil {
  170. return nil, fmt.Errorf("failed to unmarshal feed item categories: %w", err)
  171. }
  172. }
  173. items = append(items, item)
  174. }
  175. return items, nil
  176. }
  177. // GetFeedItems retrieves items for a specific feed.
  178. func (m *BuddyFeedManager) GetFeedItems(ctx context.Context, feedID int64, limit int) ([]BuddyFeedItem, error) {
  179. query := `
  180. SELECT id, feed_id, title, description, link, guid,
  181. author, categories, published_at, created_at
  182. FROM buddy_feed_items
  183. WHERE feed_id = ?
  184. ORDER BY published_at DESC
  185. LIMIT ?
  186. `
  187. rows, err := m.db.QueryContext(ctx, query, feedID, limit)
  188. if err != nil {
  189. return nil, fmt.Errorf("failed to query feed items: %w", err)
  190. }
  191. defer rows.Close()
  192. return m.scanFeedItems(rows)
  193. }
  194. // scanFeedItems is a helper to scan feed items from database rows.
  195. func (m *BuddyFeedManager) scanFeedItems(rows *sql.Rows) ([]BuddyFeedItem, error) {
  196. var items []BuddyFeedItem
  197. for rows.Next() {
  198. var item BuddyFeedItem
  199. var categoriesJSON sql.NullString
  200. var publishedAt, createdAt int64
  201. err := rows.Scan(
  202. &item.ID, &item.FeedID, &item.Title, &item.Description,
  203. &item.Link, &item.GUID, &item.Author, &categoriesJSON,
  204. &publishedAt, &createdAt,
  205. )
  206. if err != nil {
  207. return nil, fmt.Errorf("failed to scan feed item: %w", err)
  208. }
  209. item.PublishedAt = time.Unix(publishedAt, 0)
  210. item.CreatedAt = time.Unix(createdAt, 0)
  211. if categoriesJSON.Valid {
  212. if err := json.Unmarshal([]byte(categoriesJSON.String), &item.Categories); err != nil {
  213. return nil, fmt.Errorf("failed to unmarshal feed item categories: %w", err)
  214. }
  215. }
  216. items = append(items, item)
  217. }
  218. return items, nil
  219. }
  220. // AddFeedItem adds a new item to a feed.
  221. func (m *BuddyFeedManager) AddFeedItem(ctx context.Context, feedID int64, item BuddyFeedItem) (*BuddyFeedItem, error) {
  222. categoriesJSON, _ := json.Marshal(item.Categories)
  223. now := time.Now()
  224. query := `
  225. INSERT INTO buddy_feed_items (
  226. feed_id, title, description, link, guid,
  227. author, categories, published_at, created_at
  228. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  229. RETURNING id
  230. `
  231. var id int64
  232. err := m.db.QueryRowContext(ctx, query,
  233. feedID, item.Title, item.Description, item.Link, item.GUID,
  234. item.Author, string(categoriesJSON), item.PublishedAt.Unix(), now.Unix(),
  235. ).Scan(&id)
  236. if err != nil {
  237. return nil, fmt.Errorf("failed to add feed item: %w", err)
  238. }
  239. item.ID = id
  240. item.FeedID = feedID
  241. item.CreatedAt = now
  242. // Update feed's updated_at timestamp
  243. updateQuery := `UPDATE buddy_feeds SET updated_at = ? WHERE id = ?`
  244. if _, err := m.db.ExecContext(ctx, updateQuery, now.Unix(), feedID); err != nil {
  245. return nil, fmt.Errorf("failed to update feed timestamp: %w", err)
  246. }
  247. return &item, nil
  248. }
  249. // GetOrCreateFeedForUser gets an existing feed or creates a new one for a user.
  250. func (m *BuddyFeedManager) GetOrCreateFeedForUser(ctx context.Context, screenName string, feedType string) (int64, error) {
  251. var feedID int64
  252. query := `SELECT id FROM buddy_feeds WHERE screen_name = ? AND is_active = 1 LIMIT 1`
  253. err := m.db.QueryRowContext(ctx, query, screenName).Scan(&feedID)
  254. if err == nil {
  255. return feedID, nil
  256. }
  257. if err != sql.ErrNoRows {
  258. return 0, fmt.Errorf("failed to query feed: %w", err)
  259. }
  260. // Create new feed
  261. if feedType == "" {
  262. feedType = "status"
  263. }
  264. feed := BuddyFeed{
  265. ScreenName: screenName,
  266. FeedType: feedType,
  267. Title: fmt.Sprintf("%s's Feed", screenName),
  268. Description: fmt.Sprintf("Updates from %s", screenName),
  269. Link: fmt.Sprintf("/buddyfeed/getUser?u=%s", screenName),
  270. PublishedAt: time.Now(),
  271. IsActive: true,
  272. }
  273. createdFeed, err := m.CreateFeed(ctx, feed)
  274. if err != nil {
  275. return 0, fmt.Errorf("failed to create feed: %w", err)
  276. }
  277. return createdFeed.ID, nil
  278. }