this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #27 from websages/sources

Add multi-client support with source metadata

authored by

Michael Stahnke and committed by
GitHub
7fcc4508 017841f1

+2953 -346
+1090
docs/plans/2026-02-13-multi-source-implementation.md
··· 1 + # Multi-Client Support Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add client metadata to links, quotes, and images so Tumble can track where posts originate and scope duplicate detection per client. 6 + 7 + **Architecture:** Add five nullable client columns to all three content tables via GORM struct changes. Modify the Store interface to accept a `ClientFilter` for queries and duplicate detection. Update API handlers to parse, pass through, and return client fields. 8 + 9 + **Tech Stack:** Go, GORM, SQLite/MySQL, `net/http`, `httptest` for testing 10 + 11 + --- 12 + 13 + ### Task 1: Add client fields to data models 14 + 15 + **Files:** 16 + - Modify: `internal/data/store.go` 17 + 18 + **Step 1: Add client fields to IRCLink struct** 19 + 20 + In `internal/data/store.go`, add five fields to the `IRCLink` struct (after the `ContentType` field, line 15): 21 + 22 + ```go 23 + type IRCLink struct { 24 + ID int `json:"ircLinkID" gorm:"column:ircLinkID;primaryKey"` 25 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 26 + User string `json:"user" gorm:"column:user;index"` 27 + Title string `json:"title" gorm:"column:title"` 28 + URL string `json:"url" gorm:"column:url"` 29 + Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 30 + ContentType string `json:"content_type" gorm:"column:content_type"` 31 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_client,priority:1"` 32 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_client,priority:2"` 33 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_client,priority:3"` 34 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 35 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 36 + } 37 + ``` 38 + 39 + **Step 2: Add client fields to Image struct** 40 + 41 + Same five fields added to `Image` (after `MD5Sum`, line 29): 42 + 43 + ```go 44 + type Image struct { 45 + ID int `json:"imageID" gorm:"column:imageID;primaryKey"` 46 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 47 + Title string `json:"title" gorm:"column:title"` 48 + Link string `json:"link" gorm:"column:link"` 49 + URL string `json:"url" gorm:"column:url"` 50 + MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 51 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_client,priority:1"` 52 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_client,priority:2"` 53 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_client,priority:3"` 54 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 55 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 56 + } 57 + ``` 58 + 59 + **Step 3: Add client fields to Quote struct** 60 + 61 + Same five fields added to `Quote` (after `Poster`, line 42): 62 + 63 + ```go 64 + type Quote struct { 65 + ID int `json:"quoteID" gorm:"column:quoteID;primaryKey"` 66 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 67 + Quote string `json:"quote" gorm:"column:quote"` 68 + Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 69 + Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 70 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_client,priority:1"` 71 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_client,priority:2"` 72 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_client,priority:3"` 73 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 74 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 75 + } 76 + ``` 77 + 78 + **Step 4: Add client fields to TimelineItem struct** 79 + 80 + Add client fields to `TimelineItem` (after `ContentType`, line 65): 81 + 82 + ```go 83 + type TimelineItem struct { 84 + Type string `json:"type"` 85 + ID int `json:"id"` 86 + Timestamp time.Time `json:"timestamp"` 87 + Title string `json:"title"` 88 + URL string `json:"url"` 89 + Content string `json:"content"` 90 + Author string `json:"author"` 91 + MD5Sum string `json:"md5sum"` 92 + ContentType string `json:"contentType" gorm:"column:content_type"` 93 + ClientType *string `json:"client_type,omitempty"` 94 + ClientNetwork *string `json:"client_network,omitempty"` 95 + ClientChannel *string `json:"client_channel,omitempty"` 96 + ClientUserID *string `json:"client_user_id,omitempty"` 97 + ClientUserName *string `json:"client_user_name,omitempty"` 98 + } 99 + ``` 100 + 101 + **Step 5: Add ClientFilter type** 102 + 103 + Add a new type after the `TimelineItem` struct: 104 + 105 + ```go 106 + // ClientFilter is used to filter queries by client metadata. 107 + // When all fields are nil, no client filtering is applied. 108 + type ClientFilter struct { 109 + ClientType *string 110 + ClientNetwork *string 111 + ClientChannel *string 112 + } 113 + ``` 114 + 115 + **Step 6: Run tests to verify nothing broke** 116 + 117 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 118 + Expected: All existing tests pass (struct additions are backward compatible) 119 + 120 + **Step 7: Commit** 121 + 122 + ```bash 123 + git add internal/data/store.go 124 + git commit -m "feat: add client metadata fields to data models" 125 + ``` 126 + 127 + --- 128 + 129 + ### Task 2: Update Store interface and GormStore for client-aware queries 130 + 131 + **Files:** 132 + - Modify: `internal/data/store.go` (interface) 133 + - Modify: `internal/data/gorm_store.go` (implementation) 134 + 135 + **Step 1: Write failing test for client-scoped InsertIRCLink** 136 + 137 + Create test in a new file `internal/data/client_filter_test.go`: 138 + 139 + ```go 140 + package data 141 + 142 + import ( 143 + "testing" 144 + ) 145 + 146 + func TestClientFilter_IsEmpty(t *testing.T) { 147 + tests := []struct { 148 + name string 149 + filter ClientFilter 150 + expected bool 151 + }{ 152 + {"all nil", ClientFilter{}, true}, 153 + {"type set", ClientFilter{ClientType: strPtr("irc")}, false}, 154 + {"network set", ClientFilter{ClientNetwork: strPtr("server")}, false}, 155 + {"channel set", ClientFilter{ClientChannel: strPtr("#chan")}, false}, 156 + {"all set", ClientFilter{ 157 + ClientType: strPtr("irc"), 158 + ClientNetwork: strPtr("server"), 159 + ClientChannel: strPtr("#chan"), 160 + }, false}, 161 + } 162 + 163 + for _, tt := range tests { 164 + t.Run(tt.name, func(t *testing.T) { 165 + result := tt.filter.IsEmpty() 166 + if result != tt.expected { 167 + t.Errorf("expected %v, got %v", tt.expected, result) 168 + } 169 + }) 170 + } 171 + } 172 + 173 + func strPtr(s string) *string { 174 + return &s 175 + } 176 + ``` 177 + 178 + **Step 2: Run test to verify it fails** 179 + 180 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/data/ -run TestClientFilter` 181 + Expected: FAIL -- `ClientFilter` has no `IsEmpty` method 182 + 183 + **Step 3: Implement IsEmpty on ClientFilter** 184 + 185 + In `internal/data/store.go`, add method after the `ClientFilter` struct: 186 + 187 + ```go 188 + // IsEmpty returns true if no client filter fields are set. 189 + func (f ClientFilter) IsEmpty() bool { 190 + return f.ClientType == nil && f.ClientNetwork == nil && f.ClientChannel == nil 191 + } 192 + ``` 193 + 194 + **Step 4: Run test to verify it passes** 195 + 196 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/data/ -run TestClientFilter` 197 + Expected: PASS 198 + 199 + **Step 5: Update Store interface signatures** 200 + 201 + In `internal/data/store.go`, change these method signatures: 202 + 203 + Old: 204 + ```go 205 + InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) 206 + GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) 207 + GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]IRCLink, error) 208 + GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]Quote, error) 209 + GetRecentImages(ctx context.Context, days int, offsetDays int) ([]Image, error) 210 + InsertQuote(ctx context.Context, quote, author, poster string) (int, error) 211 + SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) 212 + SearchQuotes(ctx context.Context, query string) ([]Quote, error) 213 + ``` 214 + 215 + New: 216 + ```go 217 + InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) 218 + GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) 219 + GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]IRCLink, error) 220 + GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Quote, error) 221 + GetRecentImages(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Image, error) 222 + InsertQuote(ctx context.Context, quote *Quote) (int, error) 223 + InsertImage(ctx context.Context, image *Image) (int, error) 224 + SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) 225 + SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) 226 + ``` 227 + 228 + **Step 6: Update GormStore.InsertIRCLink** 229 + 230 + In `internal/data/gorm_store.go`, change `InsertIRCLink` (line 204): 231 + 232 + Old: 233 + ```go 234 + func (s *GormStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 235 + link := IRCLink{ 236 + User: user, 237 + Title: title, 238 + URL: url, 239 + ContentType: contentType, 240 + Timestamp: time.Now(), 241 + Clicks: 0, 242 + } 243 + err := s.db.WithContext(ctx).Create(&link).Error 244 + return link.ID, err 245 + } 246 + ``` 247 + 248 + New: 249 + ```go 250 + func (s *GormStore) InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) { 251 + link.Timestamp = time.Now() 252 + link.Clicks = 0 253 + err := s.db.WithContext(ctx).Create(link).Error 254 + return link.ID, err 255 + } 256 + ``` 257 + 258 + **Step 7: Update GormStore.InsertQuote** 259 + 260 + Find the `InsertQuote` method in `gorm_store.go` and update similarly: 261 + 262 + Old: 263 + ```go 264 + func (s *GormStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 265 + q := Quote{ 266 + Quote: quote, 267 + Author: author, 268 + Poster: poster, 269 + Timestamp: time.Now(), 270 + } 271 + err := s.db.WithContext(ctx).Create(&q).Error 272 + return q.ID, err 273 + } 274 + ``` 275 + 276 + New: 277 + ```go 278 + func (s *GormStore) InsertQuote(ctx context.Context, quote *Quote) (int, error) { 279 + quote.Timestamp = time.Now() 280 + err := s.db.WithContext(ctx).Create(quote).Error 281 + return quote.ID, err 282 + } 283 + ``` 284 + 285 + **Step 8: Update GormStore.InsertImage** 286 + 287 + Old: 288 + ```go 289 + func (s *GormStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 290 + img := Image{ 291 + Title: title, 292 + Link: link, 293 + URL: url, 294 + Timestamp: time.Now(), 295 + } 296 + err := s.db.WithContext(ctx).Create(&img).Error 297 + return img.ID, err 298 + } 299 + ``` 300 + 301 + New: 302 + ```go 303 + func (s *GormStore) InsertImage(ctx context.Context, image *Image) (int, error) { 304 + image.Timestamp = time.Now() 305 + err := s.db.WithContext(ctx).Create(image).Error 306 + return image.ID, err 307 + } 308 + ``` 309 + 310 + **Step 9: Update GormStore.GetIRCLinksByURL for client-scoped duplicate detection** 311 + 312 + In `gorm_store.go` (line 191): 313 + 314 + Old: 315 + ```go 316 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) { 317 + var links []IRCLink 318 + err := s.db.WithContext(ctx). 319 + Where("url = ?", url). 320 + Order("timestamp DESC"). 321 + Find(&links).Error 322 + return links, err 323 + } 324 + ``` 325 + 326 + New: 327 + ```go 328 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) { 329 + var links []IRCLink 330 + query := s.db.WithContext(ctx).Where("url = ?", url) 331 + query = applyClientFilter(query, filter) 332 + err := query.Order("timestamp DESC").Find(&links).Error 333 + return links, err 334 + } 335 + ``` 336 + 337 + **Step 10: Update GormStore.GetRecentIRCLinks for client filtering** 338 + 339 + In `gorm_store.go` (line 32): 340 + 341 + Old: 342 + ```go 343 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 344 + var links []IRCLink 345 + now := time.Now() 346 + startDate := now.AddDate(0, 0, -startDays) 347 + endDate := now.AddDate(0, 0, -endDays) 348 + 349 + err := s.db.WithContext(ctx). 350 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 351 + Order("timestamp DESC"). 352 + Find(&links).Error 353 + return links, err 354 + } 355 + ``` 356 + 357 + New: 358 + ```go 359 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]IRCLink, error) { 360 + var links []IRCLink 361 + now := time.Now() 362 + startDate := now.AddDate(0, 0, -startDays) 363 + endDate := now.AddDate(0, 0, -endDays) 364 + 365 + query := s.db.WithContext(ctx). 366 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 367 + query = applyClientFilter(query, filter) 368 + err := query.Order("timestamp DESC").Find(&links).Error 369 + return links, err 370 + } 371 + ``` 372 + 373 + **Step 11: Apply same pattern to GetRecentQuotes, GetRecentImages, SearchIRCLinks, SearchQuotes** 374 + 375 + Add `filter ClientFilter` parameter and `applyClientFilter(query, filter)` call to each. Follow exact same pattern as Step 10. 376 + 377 + **Step 12: Add applyClientFilter helper** 378 + 379 + Add this function to `gorm_store.go`: 380 + 381 + ```go 382 + // applyClientFilter adds WHERE clauses for client metadata fields. 383 + // When filter is empty, no clauses are added (backward compatible). 384 + func applyClientFilter(query *gorm.DB, filter ClientFilter) *gorm.DB { 385 + if filter.ClientType != nil { 386 + query = query.Where("client_type = ?", *filter.ClientType) 387 + } 388 + if filter.ClientNetwork != nil { 389 + query = query.Where("client_network = ?", *filter.ClientNetwork) 390 + } 391 + if filter.ClientChannel != nil { 392 + query = query.Where("client_channel = ?", *filter.ClientChannel) 393 + } 394 + return query 395 + } 396 + ``` 397 + 398 + **Step 13: Update all other callers of changed methods** 399 + 400 + Search the codebase for all calls to `InsertIRCLink`, `InsertQuote`, `InsertImage`, `GetIRCLinksByURL`, `GetRecentIRCLinks`, `GetRecentQuotes`, `GetRecentImages`, `SearchIRCLinks`, `SearchQuotes`. Update each call site to pass the new parameters. For existing callers that don't have client context, pass `data.ClientFilter{}` (empty filter). 401 + 402 + Key callers to update: 403 + - `internal/handler/api_v1_links.go` -- `apiV1CreateLink`, `apiV1ListLinks` 404 + - `internal/handler/api_v1_quotes.go` -- `apiV1CreateQuote`, `apiV1ListQuotes` 405 + - `internal/handler/api_v1_search.go` -- `APIv1SearchHandler` 406 + - `internal/handler/irclink.go` -- legacy handler (if it exists) 407 + - `internal/handler/handlers.go` -- any frontend handlers calling these 408 + - `internal/scheduler/` -- any background jobs 409 + - All test mock implementations 410 + 411 + **Step 14: Update mock stores in test files** 412 + 413 + Update `mockAPIStore` in `internal/handler/api_v1_links_test.go` to match new signatures: 414 + 415 + ```go 416 + func (m *mockAPIStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.IRCLink, error) { 417 + if m.err != nil { 418 + return nil, m.err 419 + } 420 + return m.links, nil 421 + } 422 + 423 + func (m *mockAPIStore) GetIRCLinksByURL(ctx context.Context, url string, filter data.ClientFilter) ([]data.IRCLink, error) { 424 + if m.linksByURLFn != nil { 425 + return m.linksByURLFn(url) 426 + } 427 + if m.err != nil { 428 + return nil, m.err 429 + } 430 + return m.linksByURL, nil 431 + } 432 + 433 + func (m *mockAPIStore) InsertIRCLink(ctx context.Context, link *data.IRCLink) (int, error) { 434 + if m.insertLinkFn != nil { 435 + return m.insertLinkFn(link.User, link.Title, link.URL, link.ContentType) 436 + } 437 + if m.err != nil { 438 + return 0, m.err 439 + } 440 + return m.insertedLinkID, nil 441 + } 442 + ``` 443 + 444 + Do the same for `mockQuoteStore` in `api_v1_quotes_test.go` and any other mock stores. 445 + 446 + **Step 15: Run tests** 447 + 448 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 449 + Expected: All tests pass. Compilation succeeds with updated signatures. 450 + 451 + **Step 16: Commit** 452 + 453 + ```bash 454 + git add internal/data/store.go internal/data/gorm_store.go internal/data/client_filter_test.go 455 + git add internal/handler/ 456 + git commit -m "feat: update Store interface for client-aware queries 457 + 458 + Change Insert methods to accept struct pointers instead of 459 + individual parameters. Add ClientFilter to Get and Search 460 + methods. Add applyClientFilter helper for GORM queries." 461 + ``` 462 + 463 + --- 464 + 465 + ### Task 3: Update API handlers to accept and return client fields 466 + 467 + **Files:** 468 + - Modify: `internal/handler/api_v1_types.go` 469 + - Modify: `internal/handler/api_v1_links.go` 470 + - Modify: `internal/handler/api_v1_quotes.go` 471 + - Modify: `internal/handler/api_v1_search.go` 472 + 473 + **Step 1: Write failing test for client fields in link creation** 474 + 475 + Add to `internal/handler/api_v1_links_test.go`, in the `TestAPIv1_CreateLink` function's test table: 476 + 477 + ```go 478 + { 479 + name: "valid link with client fields", 480 + body: `{"url":"https://example.com","user":"testuser","client_type":"slack","client_network":"T12345","client_channel":"C67890","client_user_id":"U99999","client_user_name":"testuser"}`, 481 + store: &mockAPIStore{insertedLinkID: 42}, 482 + expectedStatus: http.StatusCreated, 483 + checkBody: func(t *testing.T, body []byte) { 484 + var resp APILinkCreateResponse 485 + if err := json.Unmarshal(body, &resp); err != nil { 486 + t.Fatalf("failed to unmarshal: %v", err) 487 + } 488 + if resp.ClientType == nil || *resp.ClientType != "slack" { 489 + t.Errorf("expected client_type 'slack', got %v", resp.ClientType) 490 + } 491 + if resp.ClientNetwork == nil || *resp.ClientNetwork != "T12345" { 492 + t.Errorf("expected client_network 'T12345', got %v", resp.ClientNetwork) 493 + } 494 + }, 495 + }, 496 + ``` 497 + 498 + **Step 2: Run test to verify it fails** 499 + 500 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/handler/ -run TestAPIv1_CreateLink/valid_link_with_client_fields` 501 + Expected: FAIL -- `APILinkCreateResponse` has no `ClientType` field 502 + 503 + **Step 3: Add client fields to API response types** 504 + 505 + In `internal/handler/api_v1_types.go`, add client fields to `APILinkResponse`: 506 + 507 + ```go 508 + type APILinkResponse struct { 509 + ID int `json:"id"` 510 + URL string `json:"url"` 511 + Title string `json:"title"` 512 + User string `json:"user"` 513 + Clicks int `json:"clicks"` 514 + CreatedAt time.Time `json:"created_at"` 515 + Tags []string `json:"tags,omitempty"` 516 + ClientType *string `json:"client_type,omitempty"` 517 + ClientNetwork *string `json:"client_network,omitempty"` 518 + ClientChannel *string `json:"client_channel,omitempty"` 519 + ClientUserID *string `json:"client_user_id,omitempty"` 520 + ClientUserName *string `json:"client_user_name,omitempty"` 521 + } 522 + ``` 523 + 524 + Add client fields to `APIQuoteResponse`: 525 + 526 + ```go 527 + type APIQuoteResponse struct { 528 + ID int `json:"id"` 529 + Quote string `json:"quote"` 530 + Author string `json:"author"` 531 + Poster string `json:"poster,omitempty"` 532 + CreatedAt time.Time `json:"created_at"` 533 + Tags []string `json:"tags,omitempty"` 534 + ClientType *string `json:"client_type,omitempty"` 535 + ClientNetwork *string `json:"client_network,omitempty"` 536 + ClientChannel *string `json:"client_channel,omitempty"` 537 + ClientUserID *string `json:"client_user_id,omitempty"` 538 + ClientUserName *string `json:"client_user_name,omitempty"` 539 + } 540 + ``` 541 + 542 + **Step 4: Add client fields to request types** 543 + 544 + In `internal/handler/api_v1_links.go`, update `APILinkCreateRequest`: 545 + 546 + ```go 547 + type APILinkCreateRequest struct { 548 + URL string `json:"url"` 549 + User string `json:"user"` 550 + Tags []string `json:"tags,omitempty"` 551 + ClientType *string `json:"client_type,omitempty"` 552 + ClientNetwork *string `json:"client_network,omitempty"` 553 + ClientChannel *string `json:"client_channel,omitempty"` 554 + ClientUserID *string `json:"client_user_id,omitempty"` 555 + ClientUserName *string `json:"client_user_name,omitempty"` 556 + } 557 + ``` 558 + 559 + In `internal/handler/api_v1_quotes.go`, update `APIQuoteCreateRequest`: 560 + 561 + ```go 562 + type APIQuoteCreateRequest struct { 563 + Quote string `json:"quote"` 564 + Author string `json:"author"` 565 + Poster string `json:"poster"` 566 + Tags []string `json:"tags,omitempty"` 567 + ClientType *string `json:"client_type,omitempty"` 568 + ClientNetwork *string `json:"client_network,omitempty"` 569 + ClientChannel *string `json:"client_channel,omitempty"` 570 + ClientUserID *string `json:"client_user_id,omitempty"` 571 + ClientUserName *string `json:"client_user_name,omitempty"` 572 + } 573 + ``` 574 + 575 + **Step 5: Update apiV1CreateLink to pass client fields through** 576 + 577 + In `internal/handler/api_v1_links.go`, update `apiV1CreateLink` to build an `IRCLink` struct and pass client fields: 578 + 579 + ```go 580 + // Build client filter for duplicate detection 581 + clientFilter := data.ClientFilter{ 582 + ClientType: req.ClientType, 583 + ClientNetwork: req.ClientNetwork, 584 + ClientChannel: req.ClientChannel, 585 + } 586 + 587 + // Check for duplicates (scoped by client when provided) 588 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, clientFilter) 589 + 590 + // Build the link struct with client metadata 591 + link := &data.IRCLink{ 592 + User: req.User, 593 + Title: req.URL, 594 + URL: req.URL, 595 + ContentType: "", 596 + ClientType: req.ClientType, 597 + ClientNetwork: req.ClientNetwork, 598 + ClientChannel: req.ClientChannel, 599 + ClientUserID: req.ClientUserID, 600 + ClientUserName: req.ClientUserName, 601 + } 602 + linkID, err := h.Store.InsertIRCLink(ctx, link) 603 + ``` 604 + 605 + Update the response builder to include client fields: 606 + 607 + ```go 608 + resp := APILinkCreateResponse{ 609 + APILinkResponse: APILinkResponse{ 610 + ID: linkID, 611 + URL: req.URL, 612 + Title: req.URL, 613 + User: req.User, 614 + Clicks: 0, 615 + CreatedAt: time.Now(), 616 + Tags: tagStrings, 617 + ClientType: req.ClientType, 618 + ClientNetwork: req.ClientNetwork, 619 + ClientChannel: req.ClientChannel, 620 + ClientUserID: req.ClientUserID, 621 + ClientUserName: req.ClientUserName, 622 + }, 623 + IsDuplicate: isDuplicate, 624 + PreviousSubmissions: previousSubmissions, 625 + } 626 + ``` 627 + 628 + **Step 6: Update apiV1CreateQuote similarly** 629 + 630 + Pass client fields through to `InsertQuote` and include in response. 631 + 632 + **Step 7: Update apiV1ListLinks to parse client query params and pass filter** 633 + 634 + In `internal/handler/api_v1_links.go`, in `apiV1ListLinks`: 635 + 636 + ```go 637 + // Parse client filter parameters 638 + var clientFilter data.ClientFilter 639 + if st := r.URL.Query().Get("client_type"); st != "" { 640 + clientFilter.ClientType = &st 641 + } 642 + if sn := r.URL.Query().Get("client_network"); sn != "" { 643 + clientFilter.ClientNetwork = &sn 644 + } 645 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 646 + clientFilter.ClientChannel = &sc 647 + } 648 + 649 + links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, clientFilter) 650 + ``` 651 + 652 + Update the response conversion loop to include client fields from each link: 653 + 654 + ```go 655 + data = append(data, APILinkResponse{ 656 + ID: link.ID, 657 + URL: link.URL, 658 + Title: link.Title, 659 + User: link.User, 660 + Clicks: link.Clicks, 661 + CreatedAt: link.Timestamp, 662 + Tags: h.getTagStrings(ctx, "link", link.ID), 663 + ClientType: link.ClientType, 664 + ClientNetwork: link.ClientNetwork, 665 + ClientChannel: link.ClientChannel, 666 + ClientUserID: link.ClientUserID, 667 + ClientUserName: link.ClientUserName, 668 + }) 669 + ``` 670 + 671 + **Step 8: Update apiV1ListQuotes the same way** 672 + 673 + Parse client query params, pass filter to `GetRecentQuotes`, include client fields in response. 674 + 675 + **Step 9: Update apiV1GetLink and apiV1GetQuote responses** 676 + 677 + Include client fields from the fetched link/quote in the response structs. 678 + 679 + **Step 10: Update APIv1SearchHandler** 680 + 681 + In `internal/handler/api_v1_search.go`, parse client query params and pass filter to `SearchIRCLinks` and `SearchQuotes`. Include client fields in the response conversion loops. 682 + 683 + **Step 11: Run tests** 684 + 685 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 686 + Expected: All tests pass including the new client fields test 687 + 688 + **Step 12: Commit** 689 + 690 + ```bash 691 + git add internal/handler/ 692 + git commit -m "feat: accept and return client fields in API endpoints 693 + 694 + POST links/quotes accepts optional client_type, client_network, 695 + client_channel, client_user_id, client_user_name fields. 696 + GET links/quotes/search supports client_type, client_network, 697 + client_channel query parameters for filtering. 698 + All responses include client fields with omitempty." 699 + ``` 700 + 701 + --- 702 + 703 + ### Task 4: Write comprehensive tests for client-scoped duplicate detection 704 + 705 + **Files:** 706 + - Modify: `internal/handler/api_v1_links_test.go` 707 + 708 + **Step 1: Write test cases for scoped duplicate detection** 709 + 710 + Add a new test function `TestAPIv1_CreateLink_ClientDuplicates`: 711 + 712 + ```go 713 + func TestAPIv1_CreateLink_ClientDuplicates(t *testing.T) { 714 + now := time.Now() 715 + 716 + tests := []struct { 717 + name string 718 + body string 719 + existingLinks []data.IRCLink 720 + expectedStatus int 721 + expectedDuplicate bool 722 + }{ 723 + { 724 + name: "same URL different client is not duplicate", 725 + body: `{"url":"https://example.com","user":"alice","client_type":"slack","client_network":"T111","client_channel":"C222"}`, 726 + existingLinks: nil, // client-scoped query returns nothing 727 + expectedStatus: http.StatusCreated, 728 + expectedDuplicate: false, 729 + }, 730 + { 731 + name: "same URL same client is duplicate", 732 + body: `{"url":"https://example.com","user":"bob","client_type":"slack","client_network":"T111","client_channel":"C222"}`, 733 + existingLinks: []data.IRCLink{ 734 + {ID: 10, User: "alice", URL: "https://example.com", Timestamp: now}, 735 + }, 736 + expectedStatus: http.StatusCreated, 737 + expectedDuplicate: true, 738 + }, 739 + { 740 + name: "no client fields uses global duplicate check", 741 + body: `{"url":"https://example.com","user":"charlie"}`, 742 + existingLinks: []data.IRCLink{ 743 + {ID: 10, User: "alice", URL: "https://example.com", Timestamp: now}, 744 + }, 745 + expectedStatus: http.StatusCreated, 746 + expectedDuplicate: true, 747 + }, 748 + } 749 + 750 + for _, tt := range tests { 751 + t.Run(tt.name, func(t *testing.T) { 752 + store := &mockAPIStore{ 753 + linksByURL: tt.existingLinks, 754 + insertedLinkID: 42, 755 + } 756 + handler := NewHandler(store, &config.Config{}) 757 + req := httptest.NewRequest(http.MethodPost, "/api/v1/links", strings.NewReader(tt.body)) 758 + req.RemoteAddr = "127.0.0.1:12345" 759 + w := httptest.NewRecorder() 760 + 761 + handler.APIv1LinksHandler(w, req) 762 + 763 + if w.Code != tt.expectedStatus { 764 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 765 + } 766 + 767 + var resp APILinkCreateResponse 768 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 769 + t.Fatalf("failed to unmarshal: %v", err) 770 + } 771 + if resp.IsDuplicate != tt.expectedDuplicate { 772 + t.Errorf("expected is_duplicate=%v, got %v", tt.expectedDuplicate, resp.IsDuplicate) 773 + } 774 + }) 775 + } 776 + } 777 + ``` 778 + 779 + **Step 2: Run tests** 780 + 781 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/handler/ -run TestAPIv1_CreateLink_ClientDuplicates` 782 + Expected: PASS 783 + 784 + **Step 3: Write test for client filtering on GET** 785 + 786 + Add `TestAPIv1_ListLinks_ClientFiltering`: 787 + 788 + ```go 789 + func TestAPIv1_ListLinks_ClientFiltering(t *testing.T) { 790 + slackType := "slack" 791 + slackNetwork := "T12345" 792 + slackChannel := "C67890" 793 + 794 + tests := []struct { 795 + name string 796 + queryParams string 797 + links []data.IRCLink 798 + expectedTotal int 799 + }{ 800 + { 801 + name: "no filter returns all", 802 + queryParams: "", 803 + links: []data.IRCLink{ 804 + {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now()}, 805 + {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), ClientType: &slackType}, 806 + }, 807 + expectedTotal: 2, 808 + }, 809 + { 810 + name: "filter by client_type", 811 + queryParams: "?client_type=slack", 812 + links: []data.IRCLink{ 813 + {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), ClientType: &slackType}, 814 + }, 815 + expectedTotal: 1, 816 + }, 817 + } 818 + 819 + for _, tt := range tests { 820 + t.Run(tt.name, func(t *testing.T) { 821 + store := &mockAPIStore{links: tt.links} 822 + handler := NewHandler(store, &config.Config{}) 823 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links"+tt.queryParams, nil) 824 + w := httptest.NewRecorder() 825 + 826 + handler.APIv1LinksHandler(w, req) 827 + 828 + var resp APILinksResponse 829 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 830 + t.Fatalf("failed to unmarshal: %v", err) 831 + } 832 + if resp.Meta.Total != tt.expectedTotal { 833 + t.Errorf("expected total %d, got %d", tt.expectedTotal, resp.Meta.Total) 834 + } 835 + }) 836 + } 837 + } 838 + ``` 839 + 840 + **Step 4: Write test for omitempty serialization** 841 + 842 + Add `TestAPIv1_LinkResponse_ClientOmitEmpty`: 843 + 844 + ```go 845 + func TestAPIv1_LinkResponse_ClientOmitEmpty(t *testing.T) { 846 + t.Run("null client fields omitted from JSON", func(t *testing.T) { 847 + store := &mockAPIStore{ 848 + links: []data.IRCLink{ 849 + {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now()}, 850 + }, 851 + } 852 + handler := NewHandler(store, &config.Config{}) 853 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links", nil) 854 + w := httptest.NewRecorder() 855 + 856 + handler.APIv1LinksHandler(w, req) 857 + 858 + body := w.Body.String() 859 + if strings.Contains(body, "client_type") { 860 + t.Error("expected client_type to be omitted when null") 861 + } 862 + }) 863 + 864 + t.Run("client fields present when set", func(t *testing.T) { 865 + slackType := "slack" 866 + store := &mockAPIStore{ 867 + links: []data.IRCLink{ 868 + {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now(), ClientType: &slackType}, 869 + }, 870 + } 871 + handler := NewHandler(store, &config.Config{}) 872 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links", nil) 873 + w := httptest.NewRecorder() 874 + 875 + handler.APIv1LinksHandler(w, req) 876 + 877 + body := w.Body.String() 878 + if !strings.Contains(body, `"client_type":"slack"`) { 879 + t.Errorf("expected client_type in response, got: %s", body) 880 + } 881 + }) 882 + } 883 + ``` 884 + 885 + **Step 5: Run all tests** 886 + 887 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 888 + Expected: All tests pass 889 + 890 + **Step 6: Commit** 891 + 892 + ```bash 893 + git add internal/handler/api_v1_links_test.go 894 + git commit -m "test: add tests for client-scoped duplicate detection and filtering" 895 + ``` 896 + 897 + --- 898 + 899 + ### Task 5: Write backfill SQL script 900 + 901 + **Files:** 902 + - Create: `sql/backfill_clients.sql` 903 + 904 + **Step 1: Create the backfill script** 905 + 906 + ```sql 907 + -- One-time backfill: set client metadata on all existing rows. 908 + -- All existing data originates from IRC, #soggies channel on jameswhite.org. 909 + -- Run this manually after deploying the client fields migration. 910 + -- 911 + -- Usage (SQLite): sqlite3 tumble.db < sql/backfill_clients.sql 912 + -- Usage (MySQL): mysql -u user -p tumble < sql/backfill_clients.sql 913 + 914 + UPDATE ircLink 915 + SET client_type = 'irc', 916 + client_network = 'jameswhite.org', 917 + client_channel = '#soggies' 918 + WHERE client_type IS NULL; 919 + 920 + UPDATE quote 921 + SET client_type = 'irc', 922 + client_network = 'jameswhite.org', 923 + client_channel = '#soggies' 924 + WHERE client_type IS NULL; 925 + 926 + UPDATE image 927 + SET client_type = 'irc', 928 + client_network = 'jameswhite.org', 929 + client_channel = '#soggies' 930 + WHERE client_type IS NULL; 931 + ``` 932 + 933 + **Step 2: Commit** 934 + 935 + ```bash 936 + git add sql/backfill_clients.sql 937 + git commit -m "feat: add one-time backfill script for client metadata" 938 + ``` 939 + 940 + --- 941 + 942 + ### Task 6: Update OpenAPI specification 943 + 944 + **Files:** 945 + - Modify: `internal/assets/openapi.json` 946 + 947 + **Step 1: Add client fields to LinkCreateRequest schema** 948 + 949 + Find the `LinkCreateRequest` schema in `openapi.json` and add: 950 + 951 + ```json 952 + "client_type": { 953 + "type": "string", 954 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 955 + "example": "slack" 956 + }, 957 + "client_network": { 958 + "type": "string", 959 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 960 + "example": "T12345" 961 + }, 962 + "client_channel": { 963 + "type": "string", 964 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 965 + "example": "C67890" 966 + }, 967 + "client_user_id": { 968 + "type": "string", 969 + "description": "Platform-specific user ID", 970 + "example": "U99999" 971 + }, 972 + "client_user_name": { 973 + "type": "string", 974 + "description": "Display/mention name at time of post", 975 + "example": "stahnma" 976 + } 977 + ``` 978 + 979 + **Step 2: Add same fields to QuoteCreateRequest schema** 980 + 981 + Same five fields. 982 + 983 + **Step 3: Add client fields to APILinkResponse and APIQuoteResponse schemas** 984 + 985 + Same five fields added to response schemas. 986 + 987 + **Step 4: Add client query parameters to GET /api/v1/links** 988 + 989 + Add three optional query parameters: 990 + 991 + ```json 992 + { 993 + "name": "client_type", 994 + "in": "query", 995 + "required": false, 996 + "schema": { "type": "string" }, 997 + "description": "Filter by client platform (e.g., irc, slack, discord)" 998 + }, 999 + { 1000 + "name": "client_network", 1001 + "in": "query", 1002 + "required": false, 1003 + "schema": { "type": "string" }, 1004 + "description": "Filter by client network (requires client_type)" 1005 + }, 1006 + { 1007 + "name": "client_channel", 1008 + "in": "query", 1009 + "required": false, 1010 + "schema": { "type": "string" }, 1011 + "description": "Filter by client channel (requires client_type and client_network)" 1012 + } 1013 + ``` 1014 + 1015 + **Step 5: Add same query parameters to GET /api/v1/quotes and GET /api/v1/search** 1016 + 1017 + **Step 6: Update 208 response description** 1018 + 1019 + Update the duplicate detection documentation for POST /api/v1/links to note that duplicate detection is scoped per client when client fields are provided. 1020 + 1021 + **Step 7: Run the app to verify docs render** 1022 + 1023 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make restart` 1024 + Visit: `http://localhost:8080/api/docs` and verify the new fields appear. 1025 + Then: `make kill` 1026 + 1027 + **Step 8: Commit** 1028 + 1029 + ```bash 1030 + git add internal/assets/openapi.json 1031 + git commit -m "docs: update OpenAPI spec with client metadata fields 1032 + 1033 + Add client_type, client_network, client_channel, client_user_id, 1034 + and client_user_name to request/response schemas. Add client 1035 + filter query parameters to GET endpoints." 1036 + ``` 1037 + 1038 + --- 1039 + 1040 + ### Task 7: Final integration verification 1041 + 1042 + **Step 1: Run full test suite** 1043 + 1044 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 1045 + Expected: All tests pass 1046 + 1047 + **Step 2: Run API integration tests** 1048 + 1049 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test-api` 1050 + Expected: All integration tests pass 1051 + 1052 + **Step 3: Manual smoke test** 1053 + 1054 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && make restart` 1055 + 1056 + Test creating a link with client fields: 1057 + ```bash 1058 + curl -s -X POST http://localhost:8080/api/v1/links \ 1059 + -H "Content-Type: application/json" \ 1060 + -d '{"url":"https://example.com/test","user":"testuser","client_type":"slack","client_network":"T12345","client_channel":"C67890","client_user_id":"U99999","client_user_name":"testuser"}' | jq . 1061 + ``` 1062 + 1063 + Verify client fields in response. Then test filtering: 1064 + ```bash 1065 + curl -s "http://localhost:8080/api/v1/links?client_type=slack" | jq . 1066 + ``` 1067 + 1068 + Test that creating same URL without client fields still works: 1069 + ```bash 1070 + curl -s -X POST http://localhost:8080/api/v1/links \ 1071 + -H "Content-Type: application/json" \ 1072 + -d '{"url":"https://example.com/test","user":"otheruser"}' | jq . 1073 + ``` 1074 + 1075 + Then: `make kill` 1076 + 1077 + **Step 4: Run go fmt** 1078 + 1079 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go fmt ./...` 1080 + 1081 + **Step 5: Check for trailing whitespace** 1082 + 1083 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && git diff --check` 1084 + 1085 + **Step 6: Final commit if any formatting fixes needed** 1086 + 1087 + ```bash 1088 + git add -A 1089 + git commit -m "chore: format and clean up" 1090 + ```
+208
docs/plans/2026-02-13-multi-source-support-design.md
··· 1 + # Multi-Client Support 2 + 3 + **Date:** 2026-02-13 4 + **Status:** Proposed 5 + 6 + ## Problem 7 + 8 + Tumble currently serves a single community with no tracking of where 9 + posts originate. As usage expands to multiple IRC channels, Slack 10 + teams, and potentially Discord servers, posts need metadata about 11 + their client. This enables per-client duplicate detection, per-client 12 + filtering, and eventually client-scoped UI views. 13 + 14 + ## Solution 15 + 16 + Add client metadata columns to all three content tables (links, 17 + quotes, images). Duplicate detection becomes scoped per client. 18 + API endpoints accept optional client fields on creation and support 19 + filtering by client on reads. No UI changes in this phase. 20 + 21 + ## Data Model 22 + 23 + Five nullable columns added to `ircLink`, `quote`, and `image`: 24 + 25 + | Column | Type | Purpose | 26 + |--------|------|---------| 27 + | `client_type` | VARCHAR(50) | Platform: "irc", "slack", "discord", "api", "web" | 28 + | `client_network` | VARCHAR(255) | IRC server, Slack team ID, Discord guild ID | 29 + | `client_channel` | VARCHAR(255) | IRC channel, Slack/Discord channel ID | 30 + | `client_user_id` | VARCHAR(255) | Platform-specific user ID (null for IRC) | 31 + | `client_user_name` | VARCHAR(255) | Display/mention name at time of post (null for IRC) | 32 + 33 + Composite index on `(client_type, client_network, client_channel)` 34 + on each table. 35 + 36 + The existing `user` column is unchanged. It continues to store the 37 + poster's name. The new `client_user_id` and `client_user_name` fields 38 + provide platform-specific identity alongside it. 39 + 40 + Platform mapping: 41 + 42 + | Field | IRC | Slack | Discord | 43 + |-------|-----|-------|---------| 44 + | `client_type` | "irc" | "slack" | "discord" | 45 + | `client_network` | server hostname | team ID | guild ID | 46 + | `client_channel` | channel name | channel ID | channel ID | 47 + | `client_user_id` | null | Slack user ID | Discord user ID | 48 + | `client_user_name` | null | mention name | display name | 49 + 50 + GORM model additions (same for all three structs): 51 + 52 + ```go 53 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type"` 54 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network"` 55 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel"` 56 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id"` 57 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name"` 58 + ``` 59 + 60 + Pointer types for correct null handling and `omitempty` serialization. 61 + 62 + ## DDL 63 + 64 + GORM AutoMigrate handles schema changes from the struct definitions. 65 + No manual DDL required. Columns are added automatically on next 66 + application startup. 67 + 68 + ## Data Backfill 69 + 70 + All existing rows predate multi-client support and originate from IRC. 71 + A standalone SQL script (run once, manually) backfills them: 72 + 73 + ```sql 74 + UPDATE ircLink SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL; 75 + UPDATE quote SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL; 76 + UPDATE image SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL; 77 + ``` 78 + 79 + No backfill code in the application. No dead code paths. 80 + 81 + ## Duplicate Detection 82 + 83 + Currently global: any POST of an existing URL returns 208. Changes to 84 + be scoped by `client_type + client_network + client_channel`. 85 + 86 + **Rules:** 87 + - POST with client fields: check duplicates within that client tuple 88 + only. Same URL from a different client is a new link (201). 89 + - POST with null/omitted client fields: fall back to global duplicate 90 + check (backward compatibility). 91 + - Same URL within same client: 208 Already Reported with previous 92 + submission info, as before. 93 + 94 + **Example:** 95 + 1. `https://example.com` from `slack / T12345 / C67890` -> 201 96 + 2. Same URL from `irc / jameswhite.org / #soggies` -> 201 97 + 3. Same URL from `slack / T12345 / C67890` again -> 208 98 + 99 + Images: same scoping applied to MD5 deduplication. 100 + 101 + Quotes: no duplicate detection currently, no change. 102 + 103 + ## API Changes 104 + 105 + ### POST Endpoints 106 + 107 + `POST /api/v1/links`, `POST /api/v1/quotes` accept new optional 108 + fields: 109 + 110 + ```json 111 + { 112 + "url": "https://example.com", 113 + "user": "stahnma", 114 + "client_type": "slack", 115 + "client_network": "T12345", 116 + "client_channel": "C67890", 117 + "client_user_id": "U99999", 118 + "client_user_name": "stahnma" 119 + } 120 + ``` 121 + 122 + All client fields are optional. Omitting them stores nulls. 123 + 124 + ### GET Endpoints 125 + 126 + `GET /api/v1/links`, `GET /api/v1/quotes`, `GET /api/v1/search` gain 127 + three optional query parameters: 128 + 129 + - `client_type` - filter by platform 130 + - `client_network` - filter by team/server (requires client_type) 131 + - `client_channel` - filter by channel (requires client_type + 132 + client_network) 133 + 134 + Cumulative filtering. Omitting all returns everything (current 135 + behavior). 136 + 137 + ### Response Payloads 138 + 139 + Client fields included with `omitempty`. Null client fields are 140 + omitted from responses: 141 + 142 + ```json 143 + { 144 + "id": 1234, 145 + "url": "https://example.com", 146 + "user": "stahnma", 147 + "title": "Example", 148 + "client_type": "slack", 149 + "client_network": "T12345", 150 + "client_channel": "C67890", 151 + "client_user_id": "U99999", 152 + "client_user_name": "stahnma" 153 + } 154 + ``` 155 + 156 + ## Store Interface Changes 157 + 158 + `GetLinkByURL` becomes client-scoped. New filter struct for query 159 + methods: 160 + 161 + ```go 162 + type ClientFilter struct { 163 + ClientType *string 164 + ClientNetwork *string 165 + ClientChannel *string 166 + } 167 + ``` 168 + 169 + Used by `GetLinks`, `GetQuotes`, `GetImages`, and the duplicate 170 + detection lookup. When all fields are nil, behaves identically to 171 + current implementation. 172 + 173 + `TimelineItem` gains client fields for future frontend use. 174 + 175 + ## Testing 176 + 177 + - Duplicate detection: same URL + same client = 208; same URL + 178 + different client = 201; null client = global fallback 179 + - Filtering: client params return correct subset; params combine to 180 + narrow results; no params returns everything 181 + - Backfill: existing rows have correct values after running script 182 + - Serialization: client fields omitted from JSON when null; present 183 + when populated 184 + - Round-trip: POST with client fields, GET returns them; POST 185 + without, GET omits them 186 + 187 + ## API Documentation 188 + 189 + Update `internal/assets/openapi.json` to reflect all API changes: 190 + 191 + - Add `client_type`, `client_network`, `client_channel`, 192 + `client_user_id`, and `client_user_name` to request schemas for 193 + `POST /api/v1/links` and `POST /api/v1/quotes` 194 + - Add `client_type`, `client_network`, `client_channel` as optional 195 + query parameters on `GET /api/v1/links`, `GET /api/v1/quotes`, and 196 + `GET /api/v1/search` 197 + - Add client fields to all response schemas (links, quotes, images) 198 + - Document the scoped duplicate detection behavior (208 is now 199 + per-client when client fields are provided) 200 + 201 + ## Out of Scope 202 + 203 + - UI changes 204 + - Authentication/authorization per client 205 + - User identity lookup table for name history tracking 206 + - Slack, Discord, or other client/bot implementations 207 + - Changes to the existing `source` request parameter (controls 208 + response format for irc/api/html -- separate concept)
+195 -2
internal/assets/openapi.json
··· 107 107 "type": "string" 108 108 }, 109 109 "description": "Tags associated with this link" 110 + }, 111 + "client_type": { 112 + "type": "string", 113 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 114 + "example": "slack" 115 + }, 116 + "client_network": { 117 + "type": "string", 118 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 119 + "example": "T12345" 120 + }, 121 + "client_channel": { 122 + "type": "string", 123 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 124 + "example": "C67890" 125 + }, 126 + "client_user_id": { 127 + "type": "string", 128 + "description": "Platform-specific user ID", 129 + "example": "U99999" 130 + }, 131 + "client_user_name": { 132 + "type": "string", 133 + "description": "Display/mention name at time of post", 134 + "example": "stahnma" 110 135 } 111 136 }, 112 137 "required": ["id", "url", "user", "created_at"], ··· 226 251 "type": "string" 227 252 }, 228 253 "description": "Tags associated with this quote" 254 + }, 255 + "client_type": { 256 + "type": "string", 257 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 258 + "example": "slack" 259 + }, 260 + "client_network": { 261 + "type": "string", 262 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 263 + "example": "T12345" 264 + }, 265 + "client_channel": { 266 + "type": "string", 267 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 268 + "example": "C67890" 269 + }, 270 + "client_user_id": { 271 + "type": "string", 272 + "description": "Platform-specific user ID", 273 + "example": "U99999" 274 + }, 275 + "client_user_name": { 276 + "type": "string", 277 + "description": "Display/mention name at time of post", 278 + "example": "stahnma" 229 279 } 230 280 }, 231 281 "required": ["id", "quote", "created_at"], ··· 468 518 "type": "string" 469 519 }, 470 520 "description": "Optional tags to add to the link (lowercased, no spaces)" 521 + }, 522 + "client_type": { 523 + "type": "string", 524 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 525 + "example": "slack" 526 + }, 527 + "client_network": { 528 + "type": "string", 529 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 530 + "example": "T12345" 531 + }, 532 + "client_channel": { 533 + "type": "string", 534 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 535 + "example": "C67890" 536 + }, 537 + "client_user_id": { 538 + "type": "string", 539 + "description": "Platform-specific user ID", 540 + "example": "U99999" 541 + }, 542 + "client_user_name": { 543 + "type": "string", 544 + "description": "Display/mention name at time of post", 545 + "example": "stahnma" 471 546 } 472 547 }, 473 548 "required": ["url", "user"], ··· 571 646 "type": "string" 572 647 }, 573 648 "description": "Optional tags to add to the quote (lowercased, no spaces)" 649 + }, 650 + "client_type": { 651 + "type": "string", 652 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 653 + "example": "slack" 654 + }, 655 + "client_network": { 656 + "type": "string", 657 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 658 + "example": "T12345" 659 + }, 660 + "client_channel": { 661 + "type": "string", 662 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 663 + "example": "C67890" 664 + }, 665 + "client_user_id": { 666 + "type": "string", 667 + "description": "Platform-specific user ID", 668 + "example": "U99999" 669 + }, 670 + "client_user_name": { 671 + "type": "string", 672 + "description": "Display/mention name at time of post", 673 + "example": "stahnma" 574 674 } 575 675 }, 576 676 "required": ["quote"], ··· 660 760 "schema": { 661 761 "type": "string" 662 762 } 763 + }, 764 + { 765 + "name": "client_type", 766 + "in": "query", 767 + "required": false, 768 + "schema": { "type": "string" }, 769 + "description": "Filter by client platform (e.g., irc, slack, discord)" 770 + }, 771 + { 772 + "name": "client_network", 773 + "in": "query", 774 + "required": false, 775 + "schema": { "type": "string" }, 776 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 777 + }, 778 + { 779 + "name": "client_channel", 780 + "in": "query", 781 + "required": false, 782 + "schema": { "type": "string" }, 783 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 663 784 } 664 785 ], 665 786 "responses": { ··· 679 800 } 680 801 } 681 802 }, 803 + "400": { 804 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 805 + "content": { 806 + "application/json": { 807 + "schema": { 808 + "$ref": "#/components/schemas/APIError" 809 + } 810 + } 811 + } 812 + }, 682 813 "500": { 683 814 "description": "Server error", 684 815 "content": { ··· 693 824 }, 694 825 "post": { 695 826 "summary": "Create Link", 696 - "description": "Submits a new link. Links are always created even if the URL was previously submitted. Duplicate information is included in the response.", 827 + "description": "Submits a new link. Links are always created even if the URL was previously submitted. Duplicate information is included in the response. When client fields (client_type, client_network, client_channel) are provided, duplicate detection is scoped per client: a URL is only considered a duplicate if it was previously submitted from the same client.", 697 828 "tags": ["Links"], 698 829 "requestBody": { 699 830 "required": true, ··· 707 838 }, 708 839 "responses": { 709 840 "201": { 710 - "description": "Link created successfully", 841 + "description": "Link created successfully. If duplicate detection finds a prior submission (scoped per client when client fields are provided), the response includes is_duplicate=true and previous_submissions.", 711 842 "content": { 712 843 "application/json": { 713 844 "schema": { ··· 882 1013 "default": 0, 883 1014 "minimum": 0 884 1015 } 1016 + }, 1017 + { 1018 + "name": "client_type", 1019 + "in": "query", 1020 + "required": false, 1021 + "schema": { "type": "string" }, 1022 + "description": "Filter by client platform (e.g., irc, slack, discord)" 1023 + }, 1024 + { 1025 + "name": "client_network", 1026 + "in": "query", 1027 + "required": false, 1028 + "schema": { "type": "string" }, 1029 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 1030 + }, 1031 + { 1032 + "name": "client_channel", 1033 + "in": "query", 1034 + "required": false, 1035 + "schema": { "type": "string" }, 1036 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 885 1037 } 886 1038 ], 887 1039 "responses": { ··· 897 1049 "schema": { 898 1050 "type": "string", 899 1051 "description": "Plain text representation of quotes" 1052 + } 1053 + } 1054 + } 1055 + }, 1056 + "400": { 1057 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 1058 + "content": { 1059 + "application/json": { 1060 + "schema": { 1061 + "$ref": "#/components/schemas/APIError" 900 1062 } 901 1063 } 902 1064 } ··· 1688 1850 "default": 0, 1689 1851 "minimum": 0 1690 1852 } 1853 + }, 1854 + { 1855 + "name": "client_type", 1856 + "in": "query", 1857 + "required": false, 1858 + "schema": { "type": "string" }, 1859 + "description": "Filter by client platform (e.g., irc, slack, discord)" 1860 + }, 1861 + { 1862 + "name": "client_network", 1863 + "in": "query", 1864 + "required": false, 1865 + "schema": { "type": "string" }, 1866 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 1867 + }, 1868 + { 1869 + "name": "client_channel", 1870 + "in": "query", 1871 + "required": false, 1872 + "schema": { "type": "string" }, 1873 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 1691 1874 } 1692 1875 ], 1693 1876 "responses": { ··· 1703 1886 "schema": { 1704 1887 "type": "string", 1705 1888 "description": "Plain text representation" 1889 + } 1890 + } 1891 + } 1892 + }, 1893 + "400": { 1894 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 1895 + "content": { 1896 + "application/json": { 1897 + "schema": { 1898 + "$ref": "#/components/schemas/APIError" 1706 1899 } 1707 1900 } 1708 1901 }
+117
internal/data/client_filter_test.go
··· 1 + package data 2 + 3 + import "testing" 4 + 5 + func TestClientFilter_IsEmpty(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + filter ClientFilter 9 + expected bool 10 + }{ 11 + { 12 + name: "all nil fields", 13 + filter: ClientFilter{}, 14 + expected: true, 15 + }, 16 + { 17 + name: "only ClientType set", 18 + filter: ClientFilter{ClientType: strPtr("irc")}, 19 + expected: false, 20 + }, 21 + { 22 + name: "only ClientNetwork set", 23 + filter: ClientFilter{ClientNetwork: strPtr("libera")}, 24 + expected: false, 25 + }, 26 + { 27 + name: "only ClientChannel set", 28 + filter: ClientFilter{ClientChannel: strPtr("#general")}, 29 + expected: false, 30 + }, 31 + { 32 + name: "all fields set", 33 + filter: ClientFilter{ClientType: strPtr("irc"), ClientNetwork: strPtr("libera"), ClientChannel: strPtr("#general")}, 34 + expected: false, 35 + }, 36 + { 37 + name: "two fields set", 38 + filter: ClientFilter{ClientType: strPtr("slack"), ClientNetwork: strPtr("workspace1")}, 39 + expected: false, 40 + }, 41 + } 42 + 43 + for _, tt := range tests { 44 + t.Run(tt.name, func(t *testing.T) { 45 + got := tt.filter.IsEmpty() 46 + if got != tt.expected { 47 + t.Errorf("ClientFilter.IsEmpty() = %v, want %v", got, tt.expected) 48 + } 49 + }) 50 + } 51 + } 52 + 53 + func TestClientFilter_Validate(t *testing.T) { 54 + tests := []struct { 55 + name string 56 + filter ClientFilter 57 + wantError bool 58 + }{ 59 + { 60 + name: "empty filter is valid", 61 + filter: ClientFilter{}, 62 + }, 63 + { 64 + name: "type only is valid", 65 + filter: ClientFilter{ClientType: strPtr("irc")}, 66 + }, 67 + { 68 + name: "type and network is valid", 69 + filter: ClientFilter{ClientType: strPtr("irc"), ClientNetwork: strPtr("libera")}, 70 + }, 71 + { 72 + name: "all three is valid", 73 + filter: ClientFilter{ClientType: strPtr("irc"), ClientNetwork: strPtr("libera"), ClientChannel: strPtr("#general")}, 74 + }, 75 + { 76 + name: "network without type is invalid", 77 + filter: ClientFilter{ClientNetwork: strPtr("libera")}, 78 + wantError: true, 79 + }, 80 + { 81 + name: "channel without type is invalid", 82 + filter: ClientFilter{ClientChannel: strPtr("#general")}, 83 + wantError: true, 84 + }, 85 + { 86 + name: "channel without network is invalid", 87 + filter: ClientFilter{ClientType: strPtr("irc"), ClientChannel: strPtr("#general")}, 88 + wantError: true, 89 + }, 90 + { 91 + name: "channel and network without type is invalid", 92 + filter: ClientFilter{ClientNetwork: strPtr("libera"), ClientChannel: strPtr("#general")}, 93 + wantError: true, 94 + }, 95 + { 96 + name: "invalid client_type is rejected", 97 + filter: ClientFilter{ClientType: strPtr("foobar")}, 98 + wantError: true, 99 + }, 100 + } 101 + 102 + for _, tt := range tests { 103 + t.Run(tt.name, func(t *testing.T) { 104 + err := tt.filter.Validate() 105 + if tt.wantError && err == nil { 106 + t.Error("expected error, got nil") 107 + } 108 + if !tt.wantError && err != nil { 109 + t.Errorf("expected no error, got %v", err) 110 + } 111 + }) 112 + } 113 + } 114 + 115 + func strPtr(s string) *string { 116 + return &s 117 + }
+78 -53
internal/data/gorm_store.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strings" 6 7 "time" 7 8 8 9 "gorm.io/gorm" 9 10 "gorm.io/gorm/clause" 10 11 ) 11 12 13 + // escapeLike escapes SQL LIKE pattern wildcards (%, _) in user input 14 + // so they are matched as literal characters. 15 + func escapeLike(s string) string { 16 + s = strings.ReplaceAll(s, `\`, `\\`) 17 + s = strings.ReplaceAll(s, `%`, `\%`) 18 + s = strings.ReplaceAll(s, `_`, `\_`) 19 + return s 20 + } 21 + 12 22 type GormStore struct { 13 23 db *gorm.DB 14 24 } ··· 29 39 return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}, &LinkPreview{}, &Tag{}, &ArchiveLookup{}) 30 40 } 31 41 32 - func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 42 + func applyClientFilter(query *gorm.DB, filter ClientFilter) *gorm.DB { 43 + if filter.ClientType != nil { 44 + query = query.Where("client_type = ?", *filter.ClientType) 45 + } 46 + if filter.ClientNetwork != nil { 47 + query = query.Where("client_network = ?", *filter.ClientNetwork) 48 + } 49 + if filter.ClientChannel != nil { 50 + query = query.Where("client_channel = ?", *filter.ClientChannel) 51 + } 52 + return query 53 + } 54 + 55 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]IRCLink, error) { 33 56 var links []IRCLink 34 57 // timestamp >= NOW() - startDays AND timestamp <= NOW() - endDays 35 58 // Note: startDays is "further back" (larger number), endDays is "closer" (smaller number) ··· 37 60 startDate := now.AddDate(0, 0, -startDays) 38 61 endDate := now.AddDate(0, 0, -endDays) 39 62 40 - err := s.db.WithContext(ctx). 41 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 42 - Order("timestamp DESC"). 63 + query := s.db.WithContext(ctx). 64 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 65 + query = applyClientFilter(query, filter) 66 + err := query.Order("timestamp DESC"). 43 67 Find(&links).Error 44 68 return links, err 45 69 } 46 70 47 - func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) { 71 + func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]Image, error) { 48 72 var images []Image 49 73 now := time.Now() 50 74 startDate := now.AddDate(0, 0, -startDays) 51 75 endDate := now.AddDate(0, 0, -endDays) 52 76 53 - err := s.db.WithContext(ctx). 54 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 55 - Order("timestamp DESC"). 77 + query := s.db.WithContext(ctx). 78 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 79 + query = applyClientFilter(query, filter) 80 + err := query.Order("timestamp DESC"). 56 81 Find(&images).Error 57 82 return images, err 58 83 } 59 84 60 - func (s *GormStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 61 - img := Image{ 62 - Title: title, 63 - Link: link, 64 - URL: url, 65 - Timestamp: time.Now(), 66 - } 67 - err := s.db.WithContext(ctx).Create(&img).Error 68 - return img.ID, err 85 + func (s *GormStore) InsertImage(ctx context.Context, image *Image) (int, error) { 86 + image.Timestamp = time.Now() 87 + err := s.db.WithContext(ctx).Create(image).Error 88 + return image.ID, err 69 89 } 70 90 71 91 func (s *GormStore) GetTodayImageByLink(ctx context.Context, link string) (*Image, error) { ··· 96 116 Delete(&Image{}).Error 97 117 } 98 118 99 - func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) { 119 + func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]Quote, error) { 100 120 var quotes []Quote 101 121 now := time.Now() 102 122 startDate := now.AddDate(0, 0, -startDays) 103 123 endDate := now.AddDate(0, 0, -endDays) 104 124 105 - err := s.db.WithContext(ctx). 106 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 107 - Order("timestamp DESC"). 125 + query := s.db.WithContext(ctx). 126 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 127 + query = applyClientFilter(query, filter) 128 + err := query.Order("timestamp DESC"). 108 129 Find(&quotes).Error 109 130 return quotes, err 110 131 } 111 132 112 - func (s *GormStore) SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) { 133 + func (s *GormStore) SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) { 113 134 var links []IRCLink 114 135 // Simple LIKE search for cross-db compatibility 115 - term := "%" + query + "%" 136 + term := "%" + escapeLike(query) + "%" 116 137 // Exclude links with cached error previews using tiered TTLs: 117 138 // - Recent links (< 10 days old): error cache expires after 24h 118 139 // - Old links (>= 10 days old): error cache expires after 60 days ··· 126 147 recentCutoff := time.Now().Add(-24 * time.Hour) 127 148 oldCutoff := time.Now().Add(-60 * 24 * time.Hour) 128 149 linkAgeCutoff := time.Now().Add(-10 * 24 * time.Hour) 129 - err := s.db.WithContext(ctx). 150 + q := s.db.WithContext(ctx). 130 151 Where(`(title LIKE ? OR url LIKE ? OR ircLinkID IN (SELECT resource_id FROM tags WHERE resource_type = 'link' AND tag LIKE ?)) 131 152 AND url NOT IN ( 132 153 SELECT lp.url FROM link_previews lp ··· 136 157 OR 137 158 (NOT EXISTS (SELECT 1 FROM ircLink il WHERE il.url = lp.url AND il.timestamp > ?) AND lp.updated_at > ?) 138 159 ) 139 - )`, term, term, term, linkAgeCutoff, recentCutoff, linkAgeCutoff, oldCutoff). 140 - Order("clicks DESC"). 160 + )`, term, term, term, linkAgeCutoff, recentCutoff, linkAgeCutoff, oldCutoff) 161 + q = applyClientFilter(q, filter) 162 + err := q.Order("clicks DESC"). 141 163 Limit(50). 142 164 Find(&links).Error 143 165 return links, err 144 166 } 145 167 146 - func (s *GormStore) SearchQuotes(ctx context.Context, query string) ([]Quote, error) { 168 + func (s *GormStore) SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) { 147 169 var quotes []Quote 148 170 // Simple LIKE search for cross-db compatibility 149 - term := "%" + query + "%" 150 - err := s.db.WithContext(ctx). 151 - Where("quote LIKE ? OR author LIKE ? OR quoteID IN (SELECT resource_id FROM tags WHERE resource_type = 'quote' AND tag LIKE ?)", term, term, term). 152 - Order("timestamp DESC"). 171 + term := "%" + escapeLike(query) + "%" 172 + q := s.db.WithContext(ctx). 173 + Where("quote LIKE ? OR author LIKE ? OR quoteID IN (SELECT resource_id FROM tags WHERE resource_type = 'quote' AND tag LIKE ?)", term, term, term) 174 + q = applyClientFilter(q, filter) 175 + err := q.Order("timestamp DESC"). 153 176 Limit(50). 154 177 Find(&quotes).Error 155 178 return quotes, err ··· 188 211 return link.URL, err 189 212 } 190 213 191 - func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) { 214 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) { 192 215 var links []IRCLink 193 - err := s.db.WithContext(ctx). 194 - Where("url = ?", url). 195 - Order("timestamp DESC"). 216 + query := s.db.WithContext(ctx). 217 + Where("url = ?", url) 218 + query = applyClientFilter(query, filter) 219 + err := query.Order("timestamp DESC"). 196 220 Find(&links).Error 197 221 return links, err 198 222 } ··· 201 225 return s.db.WithContext(ctx).Model(&IRCLink{}).Where("ircLinkID = ?", id).UpdateColumn("clicks", gorm.Expr("clicks + ?", 1)).Error 202 226 } 203 227 204 - func (s *GormStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 205 - link := IRCLink{ 206 - User: user, 207 - Title: title, 208 - URL: url, 209 - ContentType: contentType, 210 - Timestamp: time.Now(), 211 - Clicks: 0, 212 - } 213 - err := s.db.WithContext(ctx).Create(&link).Error 228 + func (s *GormStore) InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) { 229 + link.Timestamp = time.Now() 230 + link.Clicks = 0 231 + err := s.db.WithContext(ctx).Create(link).Error 214 232 return link.ID, err 215 233 } 216 234 ··· 225 243 return nil 226 244 } 227 245 228 - func (s *GormStore) InsertQuote(ctx context.Context, quoteText, author, poster string) (int, error) { 229 - quote := Quote{ 230 - Quote: quoteText, 231 - Author: author, 232 - Poster: poster, 233 - Timestamp: time.Now(), 234 - } 235 - err := s.db.WithContext(ctx).Create(&quote).Error 246 + func (s *GormStore) InsertQuote(ctx context.Context, quote *Quote) (int, error) { 247 + quote.Timestamp = time.Now() 248 + err := s.db.WithContext(ctx).Create(quote).Error 236 249 return quote.ID, err 237 250 } 238 251 ··· 272 285 return fmt.Errorf("quote not found") 273 286 } 274 287 return nil 288 + } 289 + 290 + func (s *GormStore) CountIRCLinks(ctx context.Context) (int64, error) { 291 + var count int64 292 + err := s.db.WithContext(ctx).Model(&IRCLink{}).Count(&count).Error 293 + return count, err 294 + } 295 + 296 + func (s *GormStore) CountQuotes(ctx context.Context) (int64, error) { 297 + var count int64 298 + err := s.db.WithContext(ctx).Model(&Quote{}).Count(&count).Error 299 + return count, err 275 300 } 276 301 277 302 func (s *GormStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) {
+1 -1
internal/data/hot_test.go
··· 80 80 } 81 81 82 82 for _, f := range fixtures { 83 - _, err := store.InsertIRCLink(ctx, "tester", f.Title, "http://example.com/"+f.Title, "text") 83 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "tester", Title: f.Title, URL: "http://example.com/" + f.Title, ContentType: "text"}) 84 84 if err != nil { 85 85 t.Fatalf("Failed to insert link: %v", err) 86 86 }
+3 -3
internal/data/image_test.go
··· 20 20 } 21 21 22 22 // Insert an image 23 - id, err := store.InsertImage(context.Background(), "Daily Kitten", "cat AAS", "https://cataas.com/cat/abc123") 23 + id, err := store.InsertImage(context.Background(), &Image{Title: "Daily Kitten", Link: "cat AAS", URL: "https://cataas.com/cat/abc123"}) 24 24 if err != nil { 25 25 t.Fatalf("InsertImage failed: %v", err) 26 26 } ··· 30 30 } 31 31 32 32 // Verify it's in the database via GetRecentImages 33 - images, err := store.GetRecentImages(context.Background(), 1, 0) 33 + images, err := store.GetRecentImages(context.Background(), 1, 0, ClientFilter{}) 34 34 if err != nil { 35 35 t.Fatalf("GetRecentImages failed: %v", err) 36 36 } ··· 72 72 } 73 73 74 74 // Insert an image 75 - _, err = store.InsertImage(context.Background(), "Daily Kitten", "cat AAS", "https://cataas.com/cat/abc123") 75 + _, err = store.InsertImage(context.Background(), &Image{Title: "Daily Kitten", Link: "cat AAS", URL: "https://cataas.com/cat/abc123"}) 76 76 if err != nil { 77 77 t.Fatalf("InsertImage failed: %v", err) 78 78 }
+29 -29
internal/data/search_mysql_test.go
··· 50 50 store := newMySQLTestStore(t) 51 51 ctx := context.Background() 52 52 53 - _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go-mysql", "text/html") 53 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Golang Tutorial", URL: "http://example.com/go-mysql", ContentType: "text/html"}) 54 54 if err != nil { 55 55 t.Fatalf("InsertIRCLink failed: %v", err) 56 56 } 57 - _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust-mysql", "text/html") 57 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Rust Guide", URL: "http://example.com/rust-mysql", ContentType: "text/html"}) 58 58 if err != nil { 59 59 t.Fatalf("InsertIRCLink failed: %v", err) 60 60 } 61 61 62 - links, err := store.SearchIRCLinks(ctx, "Golang") 62 + links, err := store.SearchIRCLinks(ctx, "Golang", ClientFilter{}) 63 63 if err != nil { 64 64 t.Fatalf("SearchIRCLinks failed: %v", err) 65 65 } ··· 75 75 store := newMySQLTestStore(t) 76 76 ctx := context.Background() 77 77 78 - _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-mysql-path", "text/html") 78 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Some Page", URL: "http://example.com/unique-mysql-path", ContentType: "text/html"}) 79 79 if err != nil { 80 80 t.Fatalf("InsertIRCLink failed: %v", err) 81 81 } 82 82 83 - links, err := store.SearchIRCLinks(ctx, "unique-mysql-path") 83 + links, err := store.SearchIRCLinks(ctx, "unique-mysql-path", ClientFilter{}) 84 84 if err != nil { 85 85 t.Fatalf("SearchIRCLinks failed: %v", err) 86 86 } ··· 96 96 store := newMySQLTestStore(t) 97 97 ctx := context.Background() 98 98 99 - id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged-mysql", "text/html") 99 + id, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Tagged Link", URL: "http://example.com/tagged-mysql", ContentType: "text/html"}) 100 100 if err != nil { 101 101 t.Fatalf("InsertIRCLink failed: %v", err) 102 102 } ··· 111 111 t.Fatalf("CreateTag failed: %v", err) 112 112 } 113 113 114 - links, err := store.SearchIRCLinks(ctx, "special-mysql-topic") 114 + links, err := store.SearchIRCLinks(ctx, "special-mysql-topic", ClientFilter{}) 115 115 if err != nil { 116 116 t.Fatalf("SearchIRCLinks failed: %v", err) 117 117 } ··· 127 127 store := newMySQLTestStore(t) 128 128 ctx := context.Background() 129 129 130 - _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a-mysql", "text/html") 130 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Something", URL: "http://example.com/a-mysql", ContentType: "text/html"}) 131 131 if err != nil { 132 132 t.Fatalf("InsertIRCLink failed: %v", err) 133 133 } 134 134 135 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql") 135 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql", ClientFilter{}) 136 136 if err != nil { 137 137 t.Fatalf("SearchIRCLinks failed: %v", err) 138 138 } ··· 146 146 ctx := context.Background() 147 147 db := store.db 148 148 149 - _, err := store.InsertIRCLink(ctx, "alice", "SearchM Low", "http://example.com/searchm-low", "text/html") 149 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "SearchM Low", URL: "http://example.com/searchm-low", ContentType: "text/html"}) 150 150 if err != nil { 151 151 t.Fatalf("InsertIRCLink failed: %v", err) 152 152 } 153 - _, err = store.InsertIRCLink(ctx, "bob", "SearchM High", "http://example.com/searchm-high", "text/html") 153 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "SearchM High", URL: "http://example.com/searchm-high", ContentType: "text/html"}) 154 154 if err != nil { 155 155 t.Fatalf("InsertIRCLink failed: %v", err) 156 156 } ··· 158 158 db.Model(&IRCLink{}).Where("title = ?", "SearchM Low").Update("clicks", 5) 159 159 db.Model(&IRCLink{}).Where("title = ?", "SearchM High").Update("clicks", 50) 160 160 161 - links, err := store.SearchIRCLinks(ctx, "SearchM") 161 + links, err := store.SearchIRCLinks(ctx, "SearchM", ClientFilter{}) 162 162 if err != nil { 163 163 t.Fatalf("SearchIRCLinks failed: %v", err) 164 164 } ··· 177 177 store := newMySQLTestStore(t) 178 178 ctx := context.Background() 179 179 180 - _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good-mysql", "text/html") 180 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Good Link", URL: "http://example.com/good-mysql", ContentType: "text/html"}) 181 181 if err != nil { 182 182 t.Fatalf("InsertIRCLink failed: %v", err) 183 183 } 184 - _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad-mysql", "text/html") 184 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Bad Link", URL: "http://example.com/bad-mysql", ContentType: "text/html"}) 185 185 if err != nil { 186 186 t.Fatalf("InsertIRCLink failed: %v", err) 187 187 } ··· 191 191 t.Fatalf("InsertLinkPreview failed: %v", err) 192 192 } 193 193 194 - links, err := store.SearchIRCLinks(ctx, "Link") 194 + links, err := store.SearchIRCLinks(ctx, "Link", ClientFilter{}) 195 195 if err != nil { 196 196 t.Fatalf("SearchIRCLinks failed: %v", err) 197 197 } ··· 208 208 ctx := context.Background() 209 209 db := store.db 210 210 211 - _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover-mysql", "text/html") 211 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Recoverable Link", URL: "http://example.com/recover-mysql", ContentType: "text/html"}) 212 212 if err != nil { 213 213 t.Fatalf("InsertIRCLink failed: %v", err) 214 214 } ··· 220 220 twoDaysAgo := time.Now().Add(-48 * time.Hour) 221 221 db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover-mysql").Update("updated_at", twoDaysAgo) 222 222 223 - links, err := store.SearchIRCLinks(ctx, "Recoverable") 223 + links, err := store.SearchIRCLinks(ctx, "Recoverable", ClientFilter{}) 224 224 if err != nil { 225 225 t.Fatalf("SearchIRCLinks failed: %v", err) 226 226 } ··· 238 238 store := newMySQLTestStore(t) 239 239 ctx := context.Background() 240 240 241 - _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 241 + _, err := store.InsertQuote(ctx, &Quote{Quote: "To be or not to be", Author: "Shakespeare", Poster: "alice"}) 242 242 if err != nil { 243 243 t.Fatalf("InsertQuote failed: %v", err) 244 244 } 245 - _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 245 + _, err = store.InsertQuote(ctx, &Quote{Quote: "I think therefore I am", Author: "Descartes", Poster: "bob"}) 246 246 if err != nil { 247 247 t.Fatalf("InsertQuote failed: %v", err) 248 248 } 249 249 250 - quotes, err := store.SearchQuotes(ctx, "not to be") 250 + quotes, err := store.SearchQuotes(ctx, "not to be", ClientFilter{}) 251 251 if err != nil { 252 252 t.Fatalf("SearchQuotes failed: %v", err) 253 253 } ··· 263 263 store := newMySQLTestStore(t) 264 264 ctx := context.Background() 265 265 266 - _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 266 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Some quote", Author: "UniqueAuthor42", Poster: "alice"}) 267 267 if err != nil { 268 268 t.Fatalf("InsertQuote failed: %v", err) 269 269 } 270 270 271 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 271 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", ClientFilter{}) 272 272 if err != nil { 273 273 t.Fatalf("SearchQuotes failed: %v", err) 274 274 } ··· 284 284 store := newMySQLTestStore(t) 285 285 ctx := context.Background() 286 286 287 - id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 287 + id, err := store.InsertQuote(ctx, &Quote{Quote: "A tagged quote", Author: "someone", Poster: "alice"}) 288 288 if err != nil { 289 289 t.Fatalf("InsertQuote failed: %v", err) 290 290 } ··· 299 299 t.Fatalf("CreateTag failed: %v", err) 300 300 } 301 301 302 - quotes, err := store.SearchQuotes(ctx, "philosophy") 302 + quotes, err := store.SearchQuotes(ctx, "philosophy", ClientFilter{}) 303 303 if err != nil { 304 304 t.Fatalf("SearchQuotes failed: %v", err) 305 305 } ··· 315 315 store := newMySQLTestStore(t) 316 316 ctx := context.Background() 317 317 318 - _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 318 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Hello world", Author: "author1", Poster: "poster1"}) 319 319 if err != nil { 320 320 t.Fatalf("InsertQuote failed: %v", err) 321 321 } 322 322 323 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql") 323 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql", ClientFilter{}) 324 324 if err != nil { 325 325 t.Fatalf("SearchQuotes failed: %v", err) 326 326 } ··· 334 334 ctx := context.Background() 335 335 db := store.db 336 336 337 - _, err := store.InsertQuote(ctx, "SearchM older quote", "auth1", "poster") 337 + _, err := store.InsertQuote(ctx, &Quote{Quote: "SearchM older quote", Author: "auth1", Poster: "poster"}) 338 338 if err != nil { 339 339 t.Fatalf("InsertQuote failed: %v", err) 340 340 } 341 - _, err = store.InsertQuote(ctx, "SearchM newer quote", "auth2", "poster") 341 + _, err = store.InsertQuote(ctx, &Quote{Quote: "SearchM newer quote", Author: "auth2", Poster: "poster"}) 342 342 if err != nil { 343 343 t.Fatalf("InsertQuote failed: %v", err) 344 344 } ··· 347 347 db.Model(&Quote{}).Where("quote = ?", "SearchM older quote").Update("timestamp", now.Add(-48*time.Hour)) 348 348 db.Model(&Quote{}).Where("quote = ?", "SearchM newer quote").Update("timestamp", now.Add(-1*time.Hour)) 349 349 350 - quotes, err := store.SearchQuotes(ctx, "SearchM") 350 + quotes, err := store.SearchQuotes(ctx, "SearchM", ClientFilter{}) 351 351 if err != nil { 352 352 t.Fatalf("SearchQuotes failed: %v", err) 353 353 }
+29 -29
internal/data/search_test.go
··· 12 12 store := newTestStore(t) 13 13 ctx := context.Background() 14 14 15 - _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go", "text/html") 15 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Golang Tutorial", URL: "http://example.com/go", ContentType: "text/html"}) 16 16 if err != nil { 17 17 t.Fatalf("InsertIRCLink failed: %v", err) 18 18 } 19 - _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust", "text/html") 19 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Rust Guide", URL: "http://example.com/rust", ContentType: "text/html"}) 20 20 if err != nil { 21 21 t.Fatalf("InsertIRCLink failed: %v", err) 22 22 } 23 23 24 - links, err := store.SearchIRCLinks(ctx, "Golang") 24 + links, err := store.SearchIRCLinks(ctx, "Golang", ClientFilter{}) 25 25 if err != nil { 26 26 t.Fatalf("SearchIRCLinks failed: %v", err) 27 27 } ··· 37 37 store := newTestStore(t) 38 38 ctx := context.Background() 39 39 40 - _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-path", "text/html") 40 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Some Page", URL: "http://example.com/unique-path", ContentType: "text/html"}) 41 41 if err != nil { 42 42 t.Fatalf("InsertIRCLink failed: %v", err) 43 43 } 44 44 45 - links, err := store.SearchIRCLinks(ctx, "unique-path") 45 + links, err := store.SearchIRCLinks(ctx, "unique-path", ClientFilter{}) 46 46 if err != nil { 47 47 t.Fatalf("SearchIRCLinks failed: %v", err) 48 48 } ··· 58 58 store := newTestStore(t) 59 59 ctx := context.Background() 60 60 61 - id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged", "text/html") 61 + id, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Tagged Link", URL: "http://example.com/tagged", ContentType: "text/html"}) 62 62 if err != nil { 63 63 t.Fatalf("InsertIRCLink failed: %v", err) 64 64 } ··· 73 73 t.Fatalf("CreateTag failed: %v", err) 74 74 } 75 75 76 - links, err := store.SearchIRCLinks(ctx, "special-topic") 76 + links, err := store.SearchIRCLinks(ctx, "special-topic", ClientFilter{}) 77 77 if err != nil { 78 78 t.Fatalf("SearchIRCLinks failed: %v", err) 79 79 } ··· 89 89 store := newTestStore(t) 90 90 ctx := context.Background() 91 91 92 - _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a", "text/html") 92 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Something", URL: "http://example.com/a", ContentType: "text/html"}) 93 93 if err != nil { 94 94 t.Fatalf("InsertIRCLink failed: %v", err) 95 95 } 96 96 97 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy") 97 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy", ClientFilter{}) 98 98 if err != nil { 99 99 t.Fatalf("SearchIRCLinks failed: %v", err) 100 100 } ··· 108 108 ctx := context.Background() 109 109 db := store.db 110 110 111 - _, err := store.InsertIRCLink(ctx, "alice", "Search Low", "http://example.com/search-low", "text/html") 111 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Search Low", URL: "http://example.com/search-low", ContentType: "text/html"}) 112 112 if err != nil { 113 113 t.Fatalf("InsertIRCLink failed: %v", err) 114 114 } 115 - _, err = store.InsertIRCLink(ctx, "bob", "Search High", "http://example.com/search-high", "text/html") 115 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Search High", URL: "http://example.com/search-high", ContentType: "text/html"}) 116 116 if err != nil { 117 117 t.Fatalf("InsertIRCLink failed: %v", err) 118 118 } ··· 121 121 db.Model(&IRCLink{}).Where("title = ?", "Search Low").Update("clicks", 5) 122 122 db.Model(&IRCLink{}).Where("title = ?", "Search High").Update("clicks", 50) 123 123 124 - links, err := store.SearchIRCLinks(ctx, "Search") 124 + links, err := store.SearchIRCLinks(ctx, "Search", ClientFilter{}) 125 125 if err != nil { 126 126 t.Fatalf("SearchIRCLinks failed: %v", err) 127 127 } ··· 141 141 ctx := context.Background() 142 142 143 143 // Insert two links — one will have an error preview, one won't 144 - _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good", "text/html") 144 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Good Link", URL: "http://example.com/good", ContentType: "text/html"}) 145 145 if err != nil { 146 146 t.Fatalf("InsertIRCLink failed: %v", err) 147 147 } 148 - _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad", "text/html") 148 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Bad Link", URL: "http://example.com/bad", ContentType: "text/html"}) 149 149 if err != nil { 150 150 t.Fatalf("InsertIRCLink failed: %v", err) 151 151 } ··· 156 156 t.Fatalf("InsertLinkPreview failed: %v", err) 157 157 } 158 158 159 - links, err := store.SearchIRCLinks(ctx, "Link") 159 + links, err := store.SearchIRCLinks(ctx, "Link", ClientFilter{}) 160 160 if err != nil { 161 161 t.Fatalf("SearchIRCLinks failed: %v", err) 162 162 } ··· 174 174 db := store.db 175 175 176 176 // Insert link that is "recent" (< 10 days old) — its error cache TTL is 24h 177 - _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover", "text/html") 177 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Recoverable Link", URL: "http://example.com/recover", ContentType: "text/html"}) 178 178 if err != nil { 179 179 t.Fatalf("InsertIRCLink failed: %v", err) 180 180 } ··· 188 188 twoDaysAgo := time.Now().Add(-48 * time.Hour) 189 189 db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover").Update("updated_at", twoDaysAgo) 190 190 191 - links, err := store.SearchIRCLinks(ctx, "Recoverable") 191 + links, err := store.SearchIRCLinks(ctx, "Recoverable", ClientFilter{}) 192 192 if err != nil { 193 193 t.Fatalf("SearchIRCLinks failed: %v", err) 194 194 } ··· 206 206 store := newTestStore(t) 207 207 ctx := context.Background() 208 208 209 - _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 209 + _, err := store.InsertQuote(ctx, &Quote{Quote: "To be or not to be", Author: "Shakespeare", Poster: "alice"}) 210 210 if err != nil { 211 211 t.Fatalf("InsertQuote failed: %v", err) 212 212 } 213 - _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 213 + _, err = store.InsertQuote(ctx, &Quote{Quote: "I think therefore I am", Author: "Descartes", Poster: "bob"}) 214 214 if err != nil { 215 215 t.Fatalf("InsertQuote failed: %v", err) 216 216 } 217 217 218 - quotes, err := store.SearchQuotes(ctx, "not to be") 218 + quotes, err := store.SearchQuotes(ctx, "not to be", ClientFilter{}) 219 219 if err != nil { 220 220 t.Fatalf("SearchQuotes failed: %v", err) 221 221 } ··· 231 231 store := newTestStore(t) 232 232 ctx := context.Background() 233 233 234 - _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 234 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Some quote", Author: "UniqueAuthor42", Poster: "alice"}) 235 235 if err != nil { 236 236 t.Fatalf("InsertQuote failed: %v", err) 237 237 } 238 238 239 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 239 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", ClientFilter{}) 240 240 if err != nil { 241 241 t.Fatalf("SearchQuotes failed: %v", err) 242 242 } ··· 252 252 store := newTestStore(t) 253 253 ctx := context.Background() 254 254 255 - id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 255 + id, err := store.InsertQuote(ctx, &Quote{Quote: "A tagged quote", Author: "someone", Poster: "alice"}) 256 256 if err != nil { 257 257 t.Fatalf("InsertQuote failed: %v", err) 258 258 } ··· 267 267 t.Fatalf("CreateTag failed: %v", err) 268 268 } 269 269 270 - quotes, err := store.SearchQuotes(ctx, "philosophy") 270 + quotes, err := store.SearchQuotes(ctx, "philosophy", ClientFilter{}) 271 271 if err != nil { 272 272 t.Fatalf("SearchQuotes failed: %v", err) 273 273 } ··· 283 283 store := newTestStore(t) 284 284 ctx := context.Background() 285 285 286 - _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 286 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Hello world", Author: "author1", Poster: "poster1"}) 287 287 if err != nil { 288 288 t.Fatalf("InsertQuote failed: %v", err) 289 289 } 290 290 291 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy") 291 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy", ClientFilter{}) 292 292 if err != nil { 293 293 t.Fatalf("SearchQuotes failed: %v", err) 294 294 } ··· 302 302 ctx := context.Background() 303 303 db := store.db 304 304 305 - _, err := store.InsertQuote(ctx, "Search older quote", "auth1", "poster") 305 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Search older quote", Author: "auth1", Poster: "poster"}) 306 306 if err != nil { 307 307 t.Fatalf("InsertQuote failed: %v", err) 308 308 } 309 - _, err = store.InsertQuote(ctx, "Search newer quote", "auth2", "poster") 309 + _, err = store.InsertQuote(ctx, &Quote{Quote: "Search newer quote", Author: "auth2", Poster: "poster"}) 310 310 if err != nil { 311 311 t.Fatalf("InsertQuote failed: %v", err) 312 312 } ··· 316 316 db.Model(&Quote{}).Where("quote = ?", "Search older quote").Update("timestamp", now.Add(-48*time.Hour)) 317 317 db.Model(&Quote{}).Where("quote = ?", "Search newer quote").Update("timestamp", now.Add(-1*time.Hour)) 318 318 319 - quotes, err := store.SearchQuotes(ctx, "Search") 319 + quotes, err := store.SearchQuotes(ctx, "Search", ClientFilter{}) 320 320 if err != nil { 321 321 t.Fatalf("SearchQuotes failed: %v", err) 322 322 }
+93 -36
internal/data/store.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "time" 6 7 ) 7 8 8 9 type IRCLink struct { 9 - ID int `json:"ircLinkID" gorm:"column:ircLinkID;primaryKey"` 10 - Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 11 - User string `json:"user" gorm:"column:user;index"` 12 - Title string `json:"title" gorm:"column:title"` 13 - URL string `json:"url" gorm:"column:url"` 14 - Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 15 - ContentType string `json:"content_type" gorm:"column:content_type"` 10 + ID int `json:"ircLinkID" gorm:"column:ircLinkID;primaryKey"` 11 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 12 + User string `json:"user" gorm:"column:user;index"` 13 + Title string `json:"title" gorm:"column:title"` 14 + URL string `json:"url" gorm:"column:url"` 15 + Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 16 + ContentType string `json:"content_type" gorm:"column:content_type"` 17 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_link_client,priority:1"` 18 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_link_client,priority:2"` 19 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_link_client,priority:3"` 20 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 21 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 16 22 } 17 23 18 24 // TableName overrides the table name used by User to `ircLink` ··· 21 27 } 22 28 23 29 type Image struct { 24 - ID int `json:"imageID" gorm:"column:imageID;primaryKey"` 25 - Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 26 - Title string `json:"title" gorm:"column:title"` 27 - Link string `json:"link" gorm:"column:link"` 28 - URL string `json:"url" gorm:"column:url"` 29 - MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 30 + ID int `json:"imageID" gorm:"column:imageID;primaryKey"` 31 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 32 + Title string `json:"title" gorm:"column:title"` 33 + Link string `json:"link" gorm:"column:link"` 34 + URL string `json:"url" gorm:"column:url"` 35 + MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 36 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_image_client,priority:1"` 37 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_image_client,priority:2"` 38 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_image_client,priority:3"` 39 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 40 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 30 41 } 31 42 32 43 // TableName overrides the table name used by User to `image` ··· 35 46 } 36 47 37 48 type Quote struct { 38 - ID int `json:"quoteID" gorm:"column:quoteID;primaryKey"` 39 - Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 40 - Quote string `json:"quote" gorm:"column:quote"` 41 - Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 42 - Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 49 + ID int `json:"quoteID" gorm:"column:quoteID;primaryKey"` 50 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 51 + Quote string `json:"quote" gorm:"column:quote"` 52 + Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 53 + Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 54 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_quote_client,priority:1"` 55 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_quote_client,priority:2"` 56 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_quote_client,priority:3"` 57 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 58 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 43 59 } 44 60 45 61 // TableName overrides the table name used by User to `quote` ··· 54 70 } 55 71 56 72 type TimelineItem struct { 57 - Type string `json:"type"` // "link", "quote", or "image" 58 - ID int `json:"id"` 59 - Timestamp time.Time `json:"timestamp"` 60 - Title string `json:"title"` // For links and images 61 - URL string `json:"url"` // For links and images 62 - Content string `json:"content"` // For quotes 63 - Author string `json:"author"` // For quotes (and links/images as User) 64 - MD5Sum string `json:"md5sum"` // For images 65 - ContentType string `json:"contentType" gorm:"column:content_type"` // For links (to detect images) 73 + Type string `json:"type"` // "link", "quote", or "image" 74 + ID int `json:"id"` 75 + Timestamp time.Time `json:"timestamp"` 76 + Title string `json:"title"` // For links and images 77 + URL string `json:"url"` // For links and images 78 + Content string `json:"content"` // For quotes 79 + Author string `json:"author"` // For quotes (and links/images as User) 80 + MD5Sum string `json:"md5sum"` // For images 81 + ContentType string `json:"contentType" gorm:"column:content_type"` // For links (to detect images) 82 + ClientType *string `json:"client_type,omitempty"` 83 + ClientNetwork *string `json:"client_network,omitempty"` 84 + ClientChannel *string `json:"client_channel,omitempty"` 85 + ClientUserID *string `json:"client_user_id,omitempty"` 86 + ClientUserName *string `json:"client_user_name,omitempty"` 87 + } 88 + 89 + // ValidClientTypes is the set of allowed client_type values. 90 + var ValidClientTypes = map[string]bool{ 91 + "irc": true, 92 + "slack": true, 93 + "discord": true, 94 + "api": true, 95 + "web": true, 96 + } 97 + 98 + type ClientFilter struct { 99 + ClientType *string 100 + ClientNetwork *string 101 + ClientChannel *string 102 + } 103 + 104 + func (f ClientFilter) IsEmpty() bool { 105 + return f.ClientType == nil && f.ClientNetwork == nil && f.ClientChannel == nil 106 + } 107 + 108 + // Validate checks that hierarchical filter dependencies are satisfied. 109 + // client_network requires client_type, and client_channel requires both. 110 + func (f ClientFilter) Validate() error { 111 + if f.ClientType != nil && !ValidClientTypes[*f.ClientType] { 112 + return fmt.Errorf("invalid client_type: must be one of irc, slack, discord, api, web") 113 + } 114 + if f.ClientNetwork != nil && f.ClientType == nil { 115 + return fmt.Errorf("client_network requires client_type") 116 + } 117 + if f.ClientChannel != nil && (f.ClientType == nil || f.ClientNetwork == nil) { 118 + return fmt.Errorf("client_channel requires client_type and client_network") 119 + } 120 + return nil 66 121 } 67 122 68 123 type Tag struct { ··· 104 159 } 105 160 106 161 type Store interface { 107 - GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]IRCLink, error) 108 - GetRecentImages(ctx context.Context, days int, offsetDays int) ([]Image, error) 109 - GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]Quote, error) 162 + GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]IRCLink, error) 163 + GetRecentImages(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Image, error) 164 + GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Quote, error) 110 165 111 - SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) 112 - SearchQuotes(ctx context.Context, query string) ([]Quote, error) 166 + SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) 167 + SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) 113 168 GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) 114 169 GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) 115 170 GetIRCLinkURL(ctx context.Context, id int) (string, error) 116 - GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) 171 + GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) 117 172 IncrementClicks(ctx context.Context, id int) error 118 - InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) 173 + InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) 119 174 DeleteIRCLink(ctx context.Context, id int) error 120 - InsertQuote(ctx context.Context, quote, author, poster string) (int, error) 175 + InsertQuote(ctx context.Context, quote *Quote) (int, error) 121 176 GetRandomQuote(ctx context.Context) (*Quote, error) 122 177 GetQuoteByID(ctx context.Context, id int) (*Quote, error) 123 178 DeleteQuote(ctx context.Context, id int) error 124 179 125 180 // Stats 181 + CountIRCLinks(ctx context.Context) (int64, error) 182 + CountQuotes(ctx context.Context) (int64, error) 126 183 GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) 127 184 GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) 128 185 GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) ··· 149 206 DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error 150 207 151 208 // Image operations 152 - InsertImage(ctx context.Context, title, link, url string) (int, error) 209 + InsertImage(ctx context.Context, image *Image) (int, error) 153 210 GetTodayImageByLink(ctx context.Context, link string) (*Image, error) 154 211 DeleteTodayImageByLink(ctx context.Context, link string) error 155 212
+20
internal/handler/api_v1_helpers.go
··· 7 7 "net/http" 8 8 "strconv" 9 9 "strings" 10 + 11 + "tumble/internal/data" 10 12 ) 11 13 12 14 // writeJSON writes a JSON response with the given status code and data. ··· 125 127 return strings.TrimSuffix(path, ".txt") 126 128 } 127 129 return path 130 + } 131 + 132 + // parseClientFilter extracts client filter parameters from the request query string. 133 + func parseClientFilter(r *http.Request) (data.ClientFilter, error) { 134 + var f data.ClientFilter 135 + if st := r.URL.Query().Get("client_type"); st != "" { 136 + f.ClientType = &st 137 + } 138 + if sn := r.URL.Query().Get("client_network"); sn != "" { 139 + f.ClientNetwork = &sn 140 + } 141 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 142 + f.ClientChannel = &sc 143 + } 144 + if err := f.Validate(); err != nil { 145 + return f, err 146 + } 147 + return f, nil 128 148 } 129 149 130 150 // isAuthorizedAPIKey checks if the request has a valid API key.
+21 -7
internal/handler/api_v1_integration_test.go
··· 38 38 err error 39 39 } 40 40 41 - func (m *integrationMockStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 41 + func (m *integrationMockStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.IRCLink, error) { 42 42 if m.err != nil { 43 43 return nil, m.err 44 44 } ··· 58 58 return m.linkByID, nil 59 59 } 60 60 61 - func (m *integrationMockStore) GetIRCLinksByURL(ctx context.Context, url string) ([]data.IRCLink, error) { 61 + func (m *integrationMockStore) GetIRCLinksByURL(ctx context.Context, url string, filter data.ClientFilter) ([]data.IRCLink, error) { 62 62 if m.err != nil { 63 63 return nil, m.err 64 64 } 65 65 return nil, nil // No duplicates by default 66 66 } 67 67 68 - func (m *integrationMockStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 68 + func (m *integrationMockStore) InsertIRCLink(ctx context.Context, link *data.IRCLink) (int, error) { 69 69 if m.err != nil { 70 70 return 0, m.err 71 71 } ··· 76 76 return m.err 77 77 } 78 78 79 - func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 79 + func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.Quote, error) { 80 80 if m.err != nil { 81 81 return nil, m.err 82 82 } ··· 96 96 return m.quoteByID, nil 97 97 } 98 98 99 - func (m *integrationMockStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 99 + func (m *integrationMockStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 100 100 if m.err != nil { 101 101 return 0, m.err 102 102 } ··· 107 107 return m.err 108 108 } 109 109 110 + func (m *integrationMockStore) CountIRCLinks(ctx context.Context) (int64, error) { 111 + if m.err != nil { 112 + return 0, m.err 113 + } 114 + return int64(len(m.links)), nil 115 + } 116 + 117 + func (m *integrationMockStore) CountQuotes(ctx context.Context) (int64, error) { 118 + if m.err != nil { 119 + return 0, m.err 120 + } 121 + return int64(len(m.quotes)), nil 122 + } 123 + 110 124 func (m *integrationMockStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]data.UserStat, error) { 111 125 if m.err != nil { 112 126 return nil, m.err ··· 114 128 return m.userStats, nil 115 129 } 116 130 117 - func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 131 + func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string, filter data.ClientFilter) ([]data.IRCLink, error) { 118 132 if m.err != nil { 119 133 return nil, m.err 120 134 } 121 135 return m.searchLinks, nil 122 136 } 123 137 124 - func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 138 + func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string, filter data.ClientFilter) ([]data.Quote, error) { 125 139 if m.err != nil { 126 140 return nil, m.err 127 141 }
+4 -4
internal/handler/api_v1_kittens_test.go
··· 17 17 type mockKittenStore struct { 18 18 data.Store 19 19 getTodayImageByLinkFn func(ctx context.Context, link string) (*data.Image, error) 20 - insertImageFn func(ctx context.Context, title, link, url string) (int, error) 20 + insertImageFn func(ctx context.Context, image *data.Image) (int, error) 21 21 deleteTodayImageByLinkFn func(ctx context.Context, link string) error 22 22 } 23 23 ··· 28 28 return nil, nil 29 29 } 30 30 31 - func (m *mockKittenStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 31 + func (m *mockKittenStore) InsertImage(ctx context.Context, image *data.Image) (int, error) { 32 32 if m.insertImageFn != nil { 33 - return m.insertImageFn(ctx, title, link, url) 33 + return m.insertImageFn(ctx, image) 34 34 } 35 35 return 1, nil 36 36 } ··· 158 158 apiKey string 159 159 adminSecret string 160 160 getTodayFn func(ctx context.Context, link string) (*data.Image, error) 161 - insertImageFn func(ctx context.Context, title, link, url string) (int, error) 161 + insertImageFn func(ctx context.Context, image *data.Image) (int, error) 162 162 expectedStatus int 163 163 checkBody func(t *testing.T, body []byte) 164 164 }{
+72 -28
internal/handler/api_v1_links.go
··· 7 7 "strconv" 8 8 "strings" 9 9 "time" 10 + 11 + "tumble/internal/data" 10 12 ) 11 13 12 14 // APIv1LinksHandler routes requests to /api/v1/links endpoints. ··· 68 70 limit := parseIntParam(r, "limit", 50, 1000) 69 71 offset := parseIntParam(r, "offset", 0, 1000000) 70 72 73 + // Parse client filter query params 74 + clientFilter, err := parseClientFilter(r) 75 + if err != nil { 76 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 77 + return 78 + } 79 + 71 80 // Fetch all links from the last year 72 81 // We fetch more than needed so we can paginate in-memory 73 - links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0) 82 + links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, clientFilter) 74 83 if err != nil { 75 84 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 76 85 return ··· 94 103 data := make([]APILinkResponse, 0, len(links)) 95 104 for _, link := range links { 96 105 data = append(data, APILinkResponse{ 97 - ID: link.ID, 98 - URL: link.URL, 99 - Title: link.Title, 100 - User: link.User, 101 - Clicks: link.Clicks, 102 - CreatedAt: link.Timestamp, 103 - Tags: h.getTagStrings(ctx, "link", link.ID), 106 + ID: link.ID, 107 + URL: link.URL, 108 + Title: link.Title, 109 + User: link.User, 110 + Clicks: link.Clicks, 111 + CreatedAt: link.Timestamp, 112 + Tags: h.getTagStrings(ctx, "link", link.ID), 113 + ClientType: link.ClientType, 114 + ClientNetwork: link.ClientNetwork, 115 + ClientChannel: link.ClientChannel, 116 + ClientUserID: link.ClientUserID, 117 + ClientUserName: link.ClientUserName, 104 118 }) 105 119 } 106 120 ··· 118 132 119 133 // APILinkCreateRequest is the request body for POST /api/v1/links. 120 134 type APILinkCreateRequest struct { 121 - URL string `json:"url"` 122 - User string `json:"user"` 123 - Tags []string `json:"tags,omitempty"` 135 + URL string `json:"url"` 136 + User string `json:"user"` 137 + Tags []string `json:"tags,omitempty"` 138 + ClientType *string `json:"client_type,omitempty"` 139 + ClientNetwork *string `json:"client_network,omitempty"` 140 + ClientChannel *string `json:"client_channel,omitempty"` 141 + ClientUserID *string `json:"client_user_id,omitempty"` 142 + ClientUserName *string `json:"client_user_name,omitempty"` 124 143 } 125 144 126 145 // apiV1CreateLink handles POST /api/v1/links ··· 150 169 return 151 170 } 152 171 153 - // Check for duplicates 154 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL) 172 + // Check for duplicates (scoped by client if provided) 173 + clientFilter := data.ClientFilter{ 174 + ClientType: req.ClientType, 175 + ClientNetwork: req.ClientNetwork, 176 + ClientChannel: req.ClientChannel, 177 + } 178 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, clientFilter) 155 179 if err != nil { 156 180 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to check for duplicates") 157 181 return 158 182 } 159 183 160 184 // Insert the link (use URL as title for now; existing code fetches title async) 161 - linkID, err := h.Store.InsertIRCLink(ctx, req.User, req.URL, req.URL, "") 185 + linkID, err := h.Store.InsertIRCLink(ctx, &data.IRCLink{ 186 + User: req.User, 187 + Title: req.URL, 188 + URL: req.URL, 189 + ContentType: "", 190 + ClientType: req.ClientType, 191 + ClientNetwork: req.ClientNetwork, 192 + ClientChannel: req.ClientChannel, 193 + ClientUserID: req.ClientUserID, 194 + ClientUserName: req.ClientUserName, 195 + }) 162 196 if err != nil { 163 197 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create link") 164 198 return ··· 204 238 205 239 resp := APILinkCreateResponse{ 206 240 APILinkResponse: APILinkResponse{ 207 - ID: linkID, 208 - URL: req.URL, 209 - Title: req.URL, 210 - User: req.User, 211 - Clicks: 0, 212 - CreatedAt: time.Now(), 213 - Tags: tagStrings, 241 + ID: linkID, 242 + URL: req.URL, 243 + Title: req.URL, 244 + User: req.User, 245 + Clicks: 0, 246 + CreatedAt: time.Now(), 247 + Tags: tagStrings, 248 + ClientType: req.ClientType, 249 + ClientNetwork: req.ClientNetwork, 250 + ClientChannel: req.ClientChannel, 251 + ClientUserID: req.ClientUserID, 252 + ClientUserName: req.ClientUserName, 214 253 }, 215 254 IsDuplicate: isDuplicate, 216 255 PreviousSubmissions: previousSubmissions, ··· 242 281 } 243 282 244 283 writeJSON(w, http.StatusOK, APILinkResponse{ 245 - ID: link.ID, 246 - URL: link.URL, 247 - Title: link.Title, 248 - User: link.User, 249 - Clicks: link.Clicks, 250 - CreatedAt: link.Timestamp, 251 - Tags: h.getTagStrings(ctx, "link", link.ID), 284 + ID: link.ID, 285 + URL: link.URL, 286 + Title: link.Title, 287 + User: link.User, 288 + Clicks: link.Clicks, 289 + CreatedAt: link.Timestamp, 290 + Tags: h.getTagStrings(ctx, "link", link.ID), 291 + ClientType: link.ClientType, 292 + ClientNetwork: link.ClientNetwork, 293 + ClientChannel: link.ClientChannel, 294 + ClientUserID: link.ClientUserID, 295 + ClientUserName: link.ClientUserName, 252 296 }) 253 297 } 254 298
+62 -24
internal/handler/api_v1_quotes.go
··· 7 7 "strconv" 8 8 "strings" 9 9 "time" 10 + 11 + "tumble/internal/data" 10 12 ) 11 13 12 14 // APIv1QuotesHandler routes requests to /api/v1/quotes endpoints. ··· 68 70 limit := parseIntParam(r, "limit", 50, 1000) 69 71 offset := parseIntParam(r, "offset", 0, 1000000) 70 72 73 + // Parse client filter query params 74 + clientFilter, err := parseClientFilter(r) 75 + if err != nil { 76 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 77 + return 78 + } 79 + 71 80 // Fetch all quotes from the last year 72 81 // We fetch more than needed so we can paginate in-memory 73 - quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0) 82 + quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0, clientFilter) 74 83 if err != nil { 75 84 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 76 85 return ··· 94 103 data := make([]APIQuoteResponse, 0, len(quotes)) 95 104 for _, quote := range quotes { 96 105 data = append(data, APIQuoteResponse{ 97 - ID: quote.ID, 98 - Quote: quote.Quote, 99 - Author: quote.Author, 100 - Poster: quote.Poster, 101 - CreatedAt: quote.Timestamp, 102 - Tags: h.getTagStrings(ctx, "quote", quote.ID), 106 + ID: quote.ID, 107 + Quote: quote.Quote, 108 + Author: quote.Author, 109 + Poster: quote.Poster, 110 + CreatedAt: quote.Timestamp, 111 + Tags: h.getTagStrings(ctx, "quote", quote.ID), 112 + ClientType: quote.ClientType, 113 + ClientNetwork: quote.ClientNetwork, 114 + ClientChannel: quote.ClientChannel, 115 + ClientUserID: quote.ClientUserID, 116 + ClientUserName: quote.ClientUserName, 103 117 }) 104 118 } 105 119 ··· 117 131 118 132 // APIQuoteCreateRequest is the request body for POST /api/v1/quotes. 119 133 type APIQuoteCreateRequest struct { 120 - Quote string `json:"quote"` 121 - Author string `json:"author"` 122 - Poster string `json:"poster"` 123 - Tags []string `json:"tags,omitempty"` 134 + Quote string `json:"quote"` 135 + Author string `json:"author"` 136 + Poster string `json:"poster"` 137 + Tags []string `json:"tags,omitempty"` 138 + ClientType *string `json:"client_type,omitempty"` 139 + ClientNetwork *string `json:"client_network,omitempty"` 140 + ClientChannel *string `json:"client_channel,omitempty"` 141 + ClientUserID *string `json:"client_user_id,omitempty"` 142 + ClientUserName *string `json:"client_user_name,omitempty"` 124 143 } 125 144 126 145 // apiV1CreateQuote handles POST /api/v1/quotes ··· 146 165 } 147 166 148 167 // Insert the quote 149 - quoteID, err := h.Store.InsertQuote(ctx, req.Quote, req.Author, req.Poster) 168 + quoteID, err := h.Store.InsertQuote(ctx, &data.Quote{ 169 + Quote: req.Quote, 170 + Author: req.Author, 171 + Poster: req.Poster, 172 + ClientType: req.ClientType, 173 + ClientNetwork: req.ClientNetwork, 174 + ClientChannel: req.ClientChannel, 175 + ClientUserID: req.ClientUserID, 176 + ClientUserName: req.ClientUserName, 177 + }) 150 178 if err != nil { 151 179 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create quote") 152 180 return ··· 179 207 } 180 208 181 209 resp := APIQuoteResponse{ 182 - ID: quoteID, 183 - Quote: req.Quote, 184 - Author: req.Author, 185 - Poster: req.Poster, 186 - CreatedAt: time.Now(), 187 - Tags: tagStrings, 210 + ID: quoteID, 211 + Quote: req.Quote, 212 + Author: req.Author, 213 + Poster: req.Poster, 214 + CreatedAt: time.Now(), 215 + Tags: tagStrings, 216 + ClientType: req.ClientType, 217 + ClientNetwork: req.ClientNetwork, 218 + ClientChannel: req.ClientChannel, 219 + ClientUserID: req.ClientUserID, 220 + ClientUserName: req.ClientUserName, 188 221 } 189 222 190 223 writeJSON(w, http.StatusCreated, resp) ··· 217 250 } 218 251 219 252 writeJSON(w, http.StatusOK, APIQuoteResponse{ 220 - ID: quote.ID, 221 - Quote: quote.Quote, 222 - Author: quote.Author, 223 - Poster: quote.Poster, 224 - CreatedAt: quote.Timestamp, 225 - Tags: h.getTagStrings(ctx, "quote", quote.ID), 253 + ID: quote.ID, 254 + Quote: quote.Quote, 255 + Author: quote.Author, 256 + Poster: quote.Poster, 257 + CreatedAt: quote.Timestamp, 258 + Tags: h.getTagStrings(ctx, "quote", quote.ID), 259 + ClientType: quote.ClientType, 260 + ClientNetwork: quote.ClientNetwork, 261 + ClientChannel: quote.ClientChannel, 262 + ClientUserID: quote.ClientUserID, 263 + ClientUserName: quote.ClientUserName, 226 264 }) 227 265 } 228 266
+299 -11
internal/handler/api_v1_quotes_test.go
··· 16 16 // mockQuoteStore is a mock implementation of data.Store for testing quote API handlers. 17 17 type mockQuoteStore struct { 18 18 data.Store 19 - quotes []data.Quote 20 - quoteByID *data.Quote 21 - quoteByIDFn func(id int) (*data.Quote, error) 22 - insertedQuoteID int 23 - insertQuoteFn func(quote, author, poster string) (int, error) 24 - deleteQuoteFn func(id int) error 25 - err error 19 + quotes []data.Quote 20 + recentQuotesFn func(filter data.ClientFilter) ([]data.Quote, error) 21 + quoteByID *data.Quote 22 + quoteByIDFn func(id int) (*data.Quote, error) 23 + insertedQuoteID int 24 + insertQuoteFn func(quote *data.Quote) (int, error) 25 + lastInsertedQuote *data.Quote 26 + deleteQuoteFn func(id int) error 27 + err error 26 28 } 27 29 28 - func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 30 + func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.Quote, error) { 31 + if m.recentQuotesFn != nil { 32 + return m.recentQuotesFn(filter) 33 + } 29 34 if m.err != nil { 30 35 return nil, m.err 31 36 } ··· 42 47 return m.quoteByID, nil 43 48 } 44 49 45 - func (m *mockQuoteStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 50 + func (m *mockQuoteStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 51 + m.lastInsertedQuote = quote 46 52 if m.insertQuoteFn != nil { 47 - return m.insertQuoteFn(quote, author, poster) 53 + return m.insertQuoteFn(quote) 48 54 } 49 55 if m.err != nil { 50 56 return 0, m.err ··· 759 765 760 766 func TestAPIv1_CreateQuote_StoreError(t *testing.T) { 761 767 store := &mockQuoteStore{ 762 - insertQuoteFn: func(quote, author, poster string) (int, error) { 768 + insertQuoteFn: func(quote *data.Quote) (int, error) { 763 769 return 0, context.DeadlineExceeded 764 770 }, 765 771 } ··· 1018 1024 }) 1019 1025 } 1020 1026 } 1027 + 1028 + func TestAPIv1_CreateQuote_ClientFields(t *testing.T) { 1029 + irc := "irc" 1030 + freenode := "freenode" 1031 + channel := "#general" 1032 + userID := "U12345" 1033 + userName := "alice" 1034 + 1035 + t.Run("client fields passed through to InsertQuote", func(t *testing.T) { 1036 + store := &mockQuoteStore{insertedQuoteID: 50} 1037 + handler := &Handler{ 1038 + Store: store, 1039 + Config: &config.Config{}, 1040 + } 1041 + 1042 + body := `{"quote":"To be or not to be","author":"Shakespeare","poster":"alice","client_type":"irc","client_network":"freenode","client_channel":"#general","client_user_id":"U12345","client_user_name":"alice"}` 1043 + req := httptest.NewRequest(http.MethodPost, "/api/v1/quotes", strings.NewReader(body)) 1044 + req.RemoteAddr = "127.0.0.1:12345" 1045 + req.Header.Set("Content-Type", "application/json") 1046 + w := httptest.NewRecorder() 1047 + 1048 + handler.APIv1QuotesHandler(w, req) 1049 + 1050 + if w.Code != http.StatusCreated { 1051 + t.Fatalf("expected status 201, got %d. Body: %s", w.Code, w.Body.String()) 1052 + } 1053 + 1054 + // Verify client fields were passed to InsertQuote 1055 + inserted := store.lastInsertedQuote 1056 + if inserted == nil { 1057 + t.Fatal("expected InsertQuote to be called") 1058 + } 1059 + if inserted.ClientType == nil || *inserted.ClientType != irc { 1060 + t.Errorf("expected client_type=%q, got %v", irc, inserted.ClientType) 1061 + } 1062 + if inserted.ClientNetwork == nil || *inserted.ClientNetwork != freenode { 1063 + t.Errorf("expected client_network=%q, got %v", freenode, inserted.ClientNetwork) 1064 + } 1065 + if inserted.ClientChannel == nil || *inserted.ClientChannel != channel { 1066 + t.Errorf("expected client_channel=%q, got %v", channel, inserted.ClientChannel) 1067 + } 1068 + if inserted.ClientUserID == nil || *inserted.ClientUserID != userID { 1069 + t.Errorf("expected client_user_id=%q, got %v", userID, inserted.ClientUserID) 1070 + } 1071 + if inserted.ClientUserName == nil || *inserted.ClientUserName != userName { 1072 + t.Errorf("expected client_user_name=%q, got %v", userName, inserted.ClientUserName) 1073 + } 1074 + 1075 + // Verify response contains client fields 1076 + var resp APIQuoteResponse 1077 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 1078 + t.Fatalf("failed to unmarshal response: %v", err) 1079 + } 1080 + if resp.ClientType == nil || *resp.ClientType != irc { 1081 + t.Errorf("expected client_type=%q in response, got %v", irc, resp.ClientType) 1082 + } 1083 + if resp.ClientNetwork == nil || *resp.ClientNetwork != freenode { 1084 + t.Errorf("expected client_network=%q in response, got %v", freenode, resp.ClientNetwork) 1085 + } 1086 + if resp.ClientChannel == nil || *resp.ClientChannel != channel { 1087 + t.Errorf("expected client_channel=%q in response, got %v", channel, resp.ClientChannel) 1088 + } 1089 + if resp.ClientUserID == nil || *resp.ClientUserID != userID { 1090 + t.Errorf("expected client_user_id=%q in response, got %v", userID, resp.ClientUserID) 1091 + } 1092 + if resp.ClientUserName == nil || *resp.ClientUserName != userName { 1093 + t.Errorf("expected client_user_name=%q in response, got %v", userName, resp.ClientUserName) 1094 + } 1095 + }) 1096 + } 1097 + 1098 + func TestAPIv1_ListQuotes_ClientFiltering(t *testing.T) { 1099 + now := time.Now() 1100 + irc := "irc" 1101 + slack := "slack" 1102 + freenode := "freenode" 1103 + workspace := "myworkspace" 1104 + chanGeneral := "#general" 1105 + chanRandom := "#random" 1106 + 1107 + allQuotes := []data.Quote{ 1108 + {ID: 1, Timestamp: now, Quote: "IRC Quote", Author: "Author1", Poster: "poster1", 1109 + ClientType: &irc, ClientNetwork: &freenode, ClientChannel: &chanGeneral}, 1110 + {ID: 2, Timestamp: now, Quote: "Slack Quote", Author: "Author2", Poster: "poster2", 1111 + ClientType: &slack, ClientNetwork: &workspace, ClientChannel: &chanRandom}, 1112 + {ID: 3, Timestamp: now, Quote: "No Client Quote", Author: "Author3", Poster: "poster3"}, 1113 + } 1114 + 1115 + t.Run("filter by client_type returns subset", func(t *testing.T) { 1116 + ircOnly := []data.Quote{allQuotes[0]} 1117 + store := &mockQuoteStore{ 1118 + recentQuotesFn: func(filter data.ClientFilter) ([]data.Quote, error) { 1119 + if filter.ClientType != nil && *filter.ClientType == "irc" { 1120 + return ircOnly, nil 1121 + } 1122 + return allQuotes, nil 1123 + }, 1124 + } 1125 + handler := &Handler{ 1126 + Store: store, 1127 + Config: &config.Config{}, 1128 + } 1129 + 1130 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes?client_type=irc", nil) 1131 + req.RemoteAddr = "127.0.0.1:12345" 1132 + w := httptest.NewRecorder() 1133 + 1134 + handler.APIv1QuotesHandler(w, req) 1135 + 1136 + if w.Code != http.StatusOK { 1137 + t.Fatalf("expected status 200, got %d", w.Code) 1138 + } 1139 + 1140 + var resp APIQuotesResponse 1141 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 1142 + t.Fatalf("failed to unmarshal response: %v", err) 1143 + } 1144 + if len(resp.Data) != 1 { 1145 + t.Fatalf("expected 1 quote, got %d", len(resp.Data)) 1146 + } 1147 + if resp.Data[0].ClientType == nil || *resp.Data[0].ClientType != "irc" { 1148 + t.Errorf("expected client_type=irc, got %v", resp.Data[0].ClientType) 1149 + } 1150 + }) 1151 + 1152 + t.Run("client fields included in list response", func(t *testing.T) { 1153 + store := &mockQuoteStore{quotes: allQuotes} 1154 + handler := &Handler{ 1155 + Store: store, 1156 + Config: &config.Config{}, 1157 + } 1158 + 1159 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes", nil) 1160 + req.RemoteAddr = "127.0.0.1:12345" 1161 + w := httptest.NewRecorder() 1162 + 1163 + handler.APIv1QuotesHandler(w, req) 1164 + 1165 + if w.Code != http.StatusOK { 1166 + t.Fatalf("expected status 200, got %d", w.Code) 1167 + } 1168 + 1169 + var resp APIQuotesResponse 1170 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 1171 + t.Fatalf("failed to unmarshal response: %v", err) 1172 + } 1173 + 1174 + // First quote should have IRC client fields 1175 + q1 := resp.Data[0] 1176 + if q1.ClientType == nil || *q1.ClientType != "irc" { 1177 + t.Errorf("expected quote 1 client_type=irc, got %v", q1.ClientType) 1178 + } 1179 + if q1.ClientNetwork == nil || *q1.ClientNetwork != "freenode" { 1180 + t.Errorf("expected quote 1 client_network=freenode, got %v", q1.ClientNetwork) 1181 + } 1182 + if q1.ClientChannel == nil || *q1.ClientChannel != "#general" { 1183 + t.Errorf("expected quote 1 client_channel=#general, got %v", q1.ClientChannel) 1184 + } 1185 + 1186 + // Second quote should have Slack client fields 1187 + q2 := resp.Data[1] 1188 + if q2.ClientType == nil || *q2.ClientType != "slack" { 1189 + t.Errorf("expected quote 2 client_type=slack, got %v", q2.ClientType) 1190 + } 1191 + 1192 + // Third quote should have nil client fields 1193 + q3 := resp.Data[2] 1194 + if q3.ClientType != nil { 1195 + t.Errorf("expected quote 3 client_type=nil, got %v", q3.ClientType) 1196 + } 1197 + }) 1198 + } 1199 + 1200 + func TestAPIv1_QuoteResponse_ClientOmitEmpty(t *testing.T) { 1201 + t.Run("null client fields omitted from JSON", func(t *testing.T) { 1202 + now := time.Now() 1203 + store := &mockQuoteStore{ 1204 + quoteByID: &data.Quote{ 1205 + ID: 1, 1206 + Timestamp: now, 1207 + Quote: "To be or not to be", 1208 + Author: "Shakespeare", 1209 + Poster: "testuser", 1210 + }, 1211 + } 1212 + handler := &Handler{ 1213 + Store: store, 1214 + Config: &config.Config{}, 1215 + } 1216 + 1217 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes/1", nil) 1218 + req.RemoteAddr = "127.0.0.1:12345" 1219 + w := httptest.NewRecorder() 1220 + 1221 + handler.APIv1QuotesHandler(w, req) 1222 + 1223 + if w.Code != http.StatusOK { 1224 + t.Fatalf("expected status 200, got %d", w.Code) 1225 + } 1226 + 1227 + body := w.Body.String() 1228 + if strings.Contains(body, "client_type") { 1229 + t.Error("expected client_type to be omitted from JSON when nil") 1230 + } 1231 + if strings.Contains(body, "client_network") { 1232 + t.Error("expected client_network to be omitted from JSON when nil") 1233 + } 1234 + if strings.Contains(body, "client_channel") { 1235 + t.Error("expected client_channel to be omitted from JSON when nil") 1236 + } 1237 + if strings.Contains(body, "client_user_id") { 1238 + t.Error("expected client_user_id to be omitted from JSON when nil") 1239 + } 1240 + if strings.Contains(body, "client_user_name") { 1241 + t.Error("expected client_user_name to be omitted from JSON when nil") 1242 + } 1243 + }) 1244 + 1245 + t.Run("set client fields present in JSON", func(t *testing.T) { 1246 + now := time.Now() 1247 + irc := "irc" 1248 + freenode := "freenode" 1249 + channel := "#test" 1250 + userID := "U999" 1251 + userName := "bob" 1252 + 1253 + store := &mockQuoteStore{ 1254 + quoteByID: &data.Quote{ 1255 + ID: 2, 1256 + Timestamp: now, 1257 + Quote: "I think therefore I am", 1258 + Author: "Descartes", 1259 + Poster: "testuser", 1260 + ClientType: &irc, 1261 + ClientNetwork: &freenode, 1262 + ClientChannel: &channel, 1263 + ClientUserID: &userID, 1264 + ClientUserName: &userName, 1265 + }, 1266 + } 1267 + handler := &Handler{ 1268 + Store: store, 1269 + Config: &config.Config{}, 1270 + } 1271 + 1272 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes/2", nil) 1273 + req.RemoteAddr = "127.0.0.1:12345" 1274 + w := httptest.NewRecorder() 1275 + 1276 + handler.APIv1QuotesHandler(w, req) 1277 + 1278 + if w.Code != http.StatusOK { 1279 + t.Fatalf("expected status 200, got %d", w.Code) 1280 + } 1281 + 1282 + var resp APIQuoteResponse 1283 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 1284 + t.Fatalf("failed to unmarshal response: %v", err) 1285 + } 1286 + 1287 + if resp.ClientType == nil || *resp.ClientType != "irc" { 1288 + t.Errorf("expected client_type=irc, got %v", resp.ClientType) 1289 + } 1290 + if resp.ClientNetwork == nil || *resp.ClientNetwork != "freenode" { 1291 + t.Errorf("expected client_network=freenode, got %v", resp.ClientNetwork) 1292 + } 1293 + if resp.ClientChannel == nil || *resp.ClientChannel != "#test" { 1294 + t.Errorf("expected client_channel=#test, got %v", resp.ClientChannel) 1295 + } 1296 + if resp.ClientUserID == nil || *resp.ClientUserID != "U999" { 1297 + t.Errorf("expected client_user_id=U999, got %v", resp.ClientUserID) 1298 + } 1299 + if resp.ClientUserName == nil || *resp.ClientUserName != "bob" { 1300 + t.Errorf("expected client_user_name=bob, got %v", resp.ClientUserName) 1301 + } 1302 + 1303 + body := w.Body.String() 1304 + if !strings.Contains(body, `"client_type":"irc"`) { 1305 + t.Error("expected client_type in JSON response body") 1306 + } 1307 + }) 1308 + }
+30 -13
internal/handler/api_v1_search.go
··· 61 61 limit := parseIntParam(r, "limit", 50, 1000) 62 62 offset := parseIntParam(r, "offset", 0, 1000000) 63 63 64 + // Parse client filter query params 65 + clientFilter, err := parseClientFilter(r) 66 + if err != nil { 67 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 68 + return 69 + } 70 + 64 71 // Initialize response 65 72 resp := APISearchResponse{ 66 73 Links: []APILinkResponse{}, ··· 78 85 79 86 // Search links if requested 80 87 if searchLinks { 81 - links, err := h.Store.SearchIRCLinks(ctx, query) 88 + links, err := h.Store.SearchIRCLinks(ctx, query, clientFilter) 82 89 if err != nil { 83 90 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search links") 84 91 return ··· 102 109 // Convert to API response format 103 110 for _, link := range links { 104 111 resp.Links = append(resp.Links, APILinkResponse{ 105 - ID: link.ID, 106 - URL: link.URL, 107 - Title: link.Title, 108 - User: link.User, 109 - Clicks: link.Clicks, 110 - CreatedAt: link.Timestamp, 112 + ID: link.ID, 113 + URL: link.URL, 114 + Title: link.Title, 115 + User: link.User, 116 + Clicks: link.Clicks, 117 + CreatedAt: link.Timestamp, 118 + ClientType: link.ClientType, 119 + ClientNetwork: link.ClientNetwork, 120 + ClientChannel: link.ClientChannel, 121 + ClientUserID: link.ClientUserID, 122 + ClientUserName: link.ClientUserName, 111 123 }) 112 124 } 113 125 } 114 126 115 127 // Search quotes if requested 116 128 if searchQuotes { 117 - quotes, err := h.Store.SearchQuotes(ctx, query) 129 + quotes, err := h.Store.SearchQuotes(ctx, query, clientFilter) 118 130 if err != nil { 119 131 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search quotes") 120 132 return ··· 138 150 // Convert to API response format 139 151 for _, quote := range quotes { 140 152 resp.Quotes = append(resp.Quotes, APIQuoteResponse{ 141 - ID: quote.ID, 142 - Quote: quote.Quote, 143 - Author: quote.Author, 144 - Poster: quote.Poster, 145 - CreatedAt: quote.Timestamp, 153 + ID: quote.ID, 154 + Quote: quote.Quote, 155 + Author: quote.Author, 156 + Poster: quote.Poster, 157 + CreatedAt: quote.Timestamp, 158 + ClientType: quote.ClientType, 159 + ClientNetwork: quote.ClientNetwork, 160 + ClientChannel: quote.ClientChannel, 161 + ClientUserID: quote.ClientUserID, 162 + ClientUserName: quote.ClientUserName, 146 163 }) 147 164 } 148 165 }
+2 -2
internal/handler/api_v1_search_test.go
··· 22 22 err error 23 23 } 24 24 25 - func (m *mockSearchStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 25 + func (m *mockSearchStore) SearchIRCLinks(ctx context.Context, query string, filter data.ClientFilter) ([]data.IRCLink, error) { 26 26 if m.searchLinksFn != nil { 27 27 return m.searchLinksFn(query) 28 28 } ··· 32 32 return m.links, nil 33 33 } 34 34 35 - func (m *mockSearchStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 35 + func (m *mockSearchStore) SearchQuotes(ctx context.Context, query string, filter data.ClientFilter) ([]data.Quote, error) { 36 36 if m.searchQuotesFn != nil { 37 37 return m.searchQuotesFn(query) 38 38 }
+6 -6
internal/handler/api_v1_stats.go
··· 60 60 } 61 61 62 62 // Get total links and quotes for site stats 63 - links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0) // ~100 years to get all 63 + totalLinks, err := h.Store.CountIRCLinks(ctx) 64 64 if err != nil { 65 - writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 65 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to count links") 66 66 return 67 67 } 68 68 69 - quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0) // ~100 years to get all 69 + totalQuotes, err := h.Store.CountQuotes(ctx) 70 70 if err != nil { 71 - writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 71 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to count quotes") 72 72 return 73 73 } 74 74 ··· 84 84 85 85 resp := APIStatsResponse{ 86 86 Site: APISiteStats{ 87 - TotalLinks: len(links), 88 - TotalQuotes: len(quotes), 87 + TotalLinks: int(totalLinks), 88 + TotalQuotes: int(totalQuotes), 89 89 TotalUsers: totalUsers, 90 90 }, 91 91 Leaderboard: leaderboardData,
+18 -32
internal/handler/api_v1_stats_test.go
··· 16 16 data.Store 17 17 userStats []data.UserStat 18 18 userStatsFn func(sortBy string, limit int, offset int) ([]data.UserStat, error) 19 - links []data.IRCLink 20 - quotes []data.Quote 19 + linkCount int64 20 + quoteCount int64 21 21 err error 22 22 } 23 23 ··· 31 31 return m.userStats, nil 32 32 } 33 33 34 - func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 34 + func (m *mockStatsStore) CountIRCLinks(ctx context.Context) (int64, error) { 35 35 if m.err != nil { 36 - return nil, m.err 36 + return 0, m.err 37 37 } 38 - return m.links, nil 38 + return m.linkCount, nil 39 39 } 40 40 41 - func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 41 + func (m *mockStatsStore) CountQuotes(ctx context.Context) (int64, error) { 42 42 if m.err != nil { 43 - return nil, m.err 43 + return 0, m.err 44 44 } 45 - return m.quotes, nil 45 + return m.quoteCount, nil 46 46 } 47 47 48 48 func TestAPIv1_Stats(t *testing.T) { ··· 51 51 method string 52 52 path string 53 53 userStats []data.UserStat 54 - links []data.IRCLink 55 - quotes []data.Quote 54 + linkCount int64 55 + quoteCount int64 56 56 storeErr error 57 57 expectedStatus int 58 58 checkBody func(t *testing.T, body []byte) ··· 62 62 method: http.MethodGet, 63 63 path: "/api/v1/stats", 64 64 userStats: []data.UserStat{}, 65 - links: []data.IRCLink{}, 66 - quotes: []data.Quote{}, 65 + linkCount: 0, 66 + quoteCount: 0, 67 67 expectedStatus: http.StatusOK, 68 68 checkBody: func(t *testing.T, body []byte) { 69 69 var resp APIStatsResponse ··· 101 101 {User: "alice", LinkCount: 500, QuoteCount: 120}, 102 102 {User: "bob", LinkCount: 300, QuoteCount: 80}, 103 103 }, 104 - links: make([]data.IRCLink, 15000), 105 - quotes: make([]data.Quote, 3200), 104 + linkCount: 15000, 105 + quoteCount: 3200, 106 106 expectedStatus: http.StatusOK, 107 107 checkBody: func(t *testing.T, body []byte) { 108 108 var resp APIStatsResponse ··· 143 143 {User: "alice", LinkCount: 500, QuoteCount: 120}, 144 144 {User: "bob", LinkCount: 300, QuoteCount: 80}, 145 145 }, 146 - links: []data.IRCLink{}, 147 - quotes: []data.Quote{}, 148 146 expectedStatus: http.StatusOK, 149 147 checkBody: func(t *testing.T, body []byte) { 150 148 var resp APIStatsResponse ··· 171 169 {User: "alice", LinkCount: 500, QuoteCount: 120}, 172 170 {User: "bob", LinkCount: 300, QuoteCount: 80}, 173 171 }, 174 - links: []data.IRCLink{}, 175 - quotes: []data.Quote{}, 176 172 expectedStatus: http.StatusOK, 177 173 checkBody: func(t *testing.T, body []byte) { 178 174 var resp APIStatsResponse ··· 195 191 method: http.MethodGet, 196 192 path: "/api/v1/stats?limit=5000", 197 193 userStats: []data.UserStat{}, 198 - links: []data.IRCLink{}, 199 - quotes: []data.Quote{}, 200 194 expectedStatus: http.StatusOK, 201 195 checkBody: func(t *testing.T, body []byte) { 202 196 var resp APIStatsResponse ··· 213 207 method: http.MethodGet, 214 208 path: "/api/v1/stats?limit=abc", 215 209 userStats: []data.UserStat{}, 216 - links: []data.IRCLink{}, 217 - quotes: []data.Quote{}, 218 210 expectedStatus: http.StatusOK, 219 211 checkBody: func(t *testing.T, body []byte) { 220 212 var resp APIStatsResponse ··· 233 225 userStats: []data.UserStat{ 234 226 {User: "alice", LinkCount: 500, QuoteCount: 120}, 235 227 }, 236 - links: []data.IRCLink{}, 237 - quotes: []data.Quote{}, 238 228 expectedStatus: http.StatusOK, 239 229 checkBody: func(t *testing.T, body []byte) { 240 230 var resp APIStatsResponse ··· 254 244 method: http.MethodPost, 255 245 path: "/api/v1/stats", 256 246 userStats: []data.UserStat{}, 257 - links: []data.IRCLink{}, 258 - quotes: []data.Quote{}, 259 247 expectedStatus: http.StatusMethodNotAllowed, 260 248 checkBody: func(t *testing.T, body []byte) { 261 249 var resp APIErrorResponse ··· 274 262 userStats: []data.UserStat{ 275 263 {User: "alice", LinkCount: 500, QuoteCount: 120}, 276 264 }, 277 - links: []data.IRCLink{}, 278 - quotes: []data.Quote{}, 279 265 expectedStatus: http.StatusOK, 280 266 checkBody: func(t *testing.T, body []byte) { 281 267 var resp APIStatsResponse ··· 292 278 for _, tt := range tests { 293 279 t.Run(tt.name, func(t *testing.T) { 294 280 store := &mockStatsStore{ 295 - userStats: tt.userStats, 296 - links: tt.links, 297 - quotes: tt.quotes, 298 - err: tt.storeErr, 281 + userStats: tt.userStats, 282 + linkCount: tt.linkCount, 283 + quoteCount: tt.quoteCount, 284 + err: tt.storeErr, 299 285 } 300 286 handler := &Handler{ 301 287 Store: store,
+23 -13
internal/handler/api_v1_types.go
··· 25 25 26 26 // APILinkResponse represents a single link in API responses. 27 27 type APILinkResponse struct { 28 - ID int `json:"id"` 29 - URL string `json:"url"` 30 - Title string `json:"title"` 31 - User string `json:"user"` 32 - Clicks int `json:"clicks"` 33 - CreatedAt time.Time `json:"created_at"` 34 - Tags []string `json:"tags,omitempty"` 28 + ID int `json:"id"` 29 + URL string `json:"url"` 30 + Title string `json:"title"` 31 + User string `json:"user"` 32 + Clicks int `json:"clicks"` 33 + CreatedAt time.Time `json:"created_at"` 34 + Tags []string `json:"tags,omitempty"` 35 + ClientType *string `json:"client_type,omitempty"` 36 + ClientNetwork *string `json:"client_network,omitempty"` 37 + ClientChannel *string `json:"client_channel,omitempty"` 38 + ClientUserID *string `json:"client_user_id,omitempty"` 39 + ClientUserName *string `json:"client_user_name,omitempty"` 35 40 } 36 41 37 42 // APIPreviousSubmission contains information about a previous submission ··· 58 63 59 64 // APIQuoteResponse represents a single quote in API responses. 60 65 type APIQuoteResponse struct { 61 - ID int `json:"id"` 62 - Quote string `json:"quote"` 63 - Author string `json:"author"` 64 - Poster string `json:"poster,omitempty"` 65 - CreatedAt time.Time `json:"created_at"` 66 - Tags []string `json:"tags,omitempty"` 66 + ID int `json:"id"` 67 + Quote string `json:"quote"` 68 + Author string `json:"author"` 69 + Poster string `json:"poster,omitempty"` 70 + CreatedAt time.Time `json:"created_at"` 71 + Tags []string `json:"tags,omitempty"` 72 + ClientType *string `json:"client_type,omitempty"` 73 + ClientNetwork *string `json:"client_network,omitempty"` 74 + ClientChannel *string `json:"client_channel,omitempty"` 75 + ClientUserID *string `json:"client_user_id,omitempty"` 76 + ClientUserName *string `json:"client_user_name,omitempty"` 67 77 } 68 78 69 79 // APIQuotesResponse is the paginated response for a list of quotes.
+13 -4
internal/handler/handlers.go
··· 258 258 } else { 259 259 // Standard View 260 260 wg.Add(3) 261 - go func() { defer wg.Done(); ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays) }() 262 - go func() { defer wg.Done(); images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays) }() 263 - go func() { defer wg.Done(); quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays) }() 261 + go func() { 262 + defer wg.Done() 263 + ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays, data.ClientFilter{}) 264 + }() 265 + go func() { 266 + defer wg.Done() 267 + images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays, data.ClientFilter{}) 268 + }() 269 + go func() { 270 + defer wg.Done() 271 + quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays, data.ClientFilter{}) 272 + }() 264 273 wg.Wait() 265 274 } 266 275 ··· 538 547 return 539 548 } 540 549 541 - links, err := h.Store.SearchIRCLinks(ctx, query) 550 + links, err := h.Store.SearchIRCLinks(ctx, query, data.ClientFilter{}) 542 551 if err != nil { 543 552 h.ServerError(w, r, err) 544 553 return
+4 -2
internal/handler/irclink.go
··· 14 14 "strings" 15 15 "time" 16 16 17 + "tumble/internal/data" 18 + 17 19 "github.com/doyensec/safeurl" 18 20 ) 19 21 ··· 95 97 96 98 // Handle link posting 97 99 // Check for existing submissions first 98 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url) 100 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url, data.ClientFilter{}) 99 101 if err != nil { 100 102 h.ServerError(w, r, err) 101 103 return ··· 137 139 } 138 140 139 141 // Insert the link (always insert, even if duplicate) 140 - id, err := h.Store.InsertIRCLink(ctx, user, title, url, contentType) 142 + id, err := h.Store.InsertIRCLink(ctx, &data.IRCLink{User: user, Title: title, URL: url, ContentType: contentType}) 141 143 if err != nil { 142 144 h.ServerError(w, r, err) 143 145 return
+7 -7
internal/handler/preview.go
··· 44 44 45 45 // OEmbedResponse represents standard OEmbed keys 46 46 type OEmbedResponse struct { 47 - Type string `json:"type"` 47 + Type string `json:"type"` 48 48 Version interface{} `json:"version"` 49 - Title string `json:"title"` 50 - AuthorName string `json:"author_name"` 51 - AuthorURL string `json:"author_url"` 52 - ProviderName string `json:"provider_name"` 53 - ProviderURL string `json:"provider_url"` 49 + Title string `json:"title"` 50 + AuthorName string `json:"author_name"` 51 + AuthorURL string `json:"author_url"` 52 + ProviderName string `json:"provider_name"` 53 + ProviderURL string `json:"provider_url"` 54 54 55 55 // CacheAge int64 `json:"cache_age"` 56 56 ThumbnailURL string `json:"thumbnail_url"` ··· 97 97 // For error entries, look up the link's timestamp to determine TTL tier 98 98 var linkTimestamp time.Time 99 99 if strings.Contains(string(cached.Data), `"error":`) { 100 - if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam); err == nil && len(links) > 0 { 100 + if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam, data.ClientFilter{}); err == nil && len(links) > 0 { 101 101 linkTimestamp = links[len(links)-1].Timestamp // oldest link (results ordered DESC) 102 102 } 103 103 }
+8 -2
internal/handler/preview_youtube.go
··· 34 34 title = strings.TrimSuffix(title, " - YouTube") 35 35 meta["title"] = title 36 36 37 - // If scrape yielded no real title or image, fall through to OEmbed 37 + // Empty title after stripping suffix means the page had no real video title 38 + // (e.g. " - YouTube"), which is another form of YouTube's soft 404. 39 + if title == "" { 40 + return nil, fmt.Errorf("status 404") 41 + } 42 + 43 + // If scrape yielded no image, fall through to OEmbed 38 44 // which is more reliable for YouTube 39 - if title == "" || meta["image"] == "" { 45 + if meta["image"] == "" { 40 46 return nil, fmt.Errorf("incomplete scrape, falling back to oembed") 41 47 } 42 48
+3 -1
internal/handler/quote.go
··· 8 8 "net/http" 9 9 "strconv" 10 10 "strings" 11 + 12 + "tumble/internal/data" 11 13 ) 12 14 13 15 // QuoteHandler handles /quote/ submissions and /quote/{id} permalinks ··· 115 117 } 116 118 117 119 // Quote provided -> Insert Quote (author is optional) 118 - id, err := h.Store.InsertQuote(ctx, quote, author, poster) 120 + id, err := h.Store.InsertQuote(ctx, &data.Quote{Quote: quote, Author: author, Poster: poster}) 119 121 if err != nil { 120 122 http.Error(w, "Database Error", http.StatusInternalServerError) 121 123 return
+4 -4
internal/handler/quote_test.go
··· 20 20 NextID int 21 21 } 22 22 23 - func (m *MockStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 24 - m.LastQuote = quote 25 - m.LastAuthor = author 26 - m.LastPoster = poster 23 + func (m *MockStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 24 + m.LastQuote = quote.Quote 25 + m.LastAuthor = quote.Author 26 + m.LastPoster = quote.Poster 27 27 if m.NextID == 0 { 28 28 m.NextID = 1 29 29 }
+2 -2
internal/scheduler/dailycat.go
··· 77 77 } 78 78 79 79 // Store the image 80 - id, err := store.InsertImage(ctx, kittenTitle, catAASUser, catURL) 80 + id, err := store.InsertImage(ctx, &data.Image{Title: kittenTitle, Link: catAASUser, URL: catURL}) 81 81 if err != nil { 82 82 return false, fmt.Errorf("failed to insert cat image: %w", err) 83 83 } ··· 101 101 } 102 102 103 103 // Store the image 104 - id, err := store.InsertImage(ctx, kittenTitle, catAASUser, catURL) 104 + id, err := store.InsertImage(ctx, &data.Image{Title: kittenTitle, Link: catAASUser, URL: catURL}) 105 105 if err != nil { 106 106 return "", fmt.Errorf("failed to insert cat image: %w", err) 107 107 }
+24
sql/backfill_clients.sql
··· 1 + -- One-time backfill: set client metadata on all existing rows. 2 + -- All existing data originates from IRC, #soggies channel on jameswhite.org. 3 + -- Run this manually after deploying the client fields migration. 4 + -- 5 + -- Usage (SQLite): sqlite3 tumble.db < sql/backfill_clients.sql 6 + -- Usage (MySQL): mysql -u user -p tumble < sql/backfill_clients.sql 7 + 8 + UPDATE ircLink 9 + SET client_type = 'irc', 10 + client_network = 'jameswhite.org', 11 + client_channel = '#soggies' 12 + WHERE client_type IS NULL; 13 + 14 + UPDATE quote 15 + SET client_type = 'irc', 16 + client_network = 'jameswhite.org', 17 + client_channel = '#soggies' 18 + WHERE client_type IS NULL; 19 + 20 + UPDATE image 21 + SET client_type = 'irc', 22 + client_network = 'jameswhite.org', 23 + client_channel = '#soggies' 24 + WHERE client_type IS NULL;
+3 -3
tests/api_test.sh
··· 78 78 -H "Content-Type: application/json" \ 79 79 -H "Accept: text/plain" \ 80 80 -d '{"user":"testdel","url":"http://delete-test.com"}') 81 - # Check if we got an ID (numeric) 82 - if [[ "$CREATE_OUT" =~ ^[0-9]+$ ]]; then 83 - DEL_ID=$CREATE_OUT 81 + # Parse ID from response (format: "Created link N: URL") 82 + if [[ "$CREATE_OUT" =~ Created\ link\ ([0-9]+) ]]; then 83 + DEL_ID=${BASH_REMATCH[1]} 84 84 # Delete it 85 85 DEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "X-API-Key: test-admin-secret" "$BASE_URL/api/v1/links/$DEL_ID") 86 86 if [ "$DEL_STATUS" == "204" ]; then
+3 -2
tests/preview_test.sh
··· 3 3 # Usage: ./tests/preview_test.sh [BASE_URL] 4 4 5 5 BASE_URL="${1:-http://localhost:8080}" 6 - ENDPOINT="$BASE_URL/ogpreview" 6 + ENDPOINT="$BASE_URL/api/v1/preview" 7 7 8 8 echo "Running Preview Tests against $ENDPOINT" 9 9 ··· 50 50 '.provider_name == "Reddit" or .title != null' 51 51 52 52 # Invalid: specific non-existent post. 53 + # Reddit may return an error, a generic title, or a minimal response with just provider_name. 53 54 test_preview "Reddit Invalid" \ 54 55 "https://www.reddit.com/r/valheim/comments/INVALID_ID_12345/" \ 55 - '.error != null or (.title | contains("Page not found") or contains("Reddit"))' 56 + '.error != null or .title == null or (.title | contains("Page not found") or contains("Reddit"))' 56 57 57 58 # --- SPOTIFY --- 58 59 # Valid