this repo has no description
1
fork

Configure Feed

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

refactor: rename source to client across the codebase

The term "client" better describes the origin platform concept
(IRC client, Slack client, etc.) than the generic "source".
Renames all struct fields, JSON tags, GORM columns, database
indexes, query parameters, SQL scripts, OpenAPI spec, and docs.

+753 -753
+247 -247
docs/plans/2026-02-13-multi-source-implementation.md
··· 1 - # Multi-Source Support Implementation Plan 1 + # Multi-Client Support Implementation Plan 2 2 3 3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 4 5 - **Goal:** Add source metadata to links, quotes, and images so Tumble can track where posts originate and scope duplicate detection per source. 5 + **Goal:** Add client metadata to links, quotes, and images so Tumble can track where posts originate and scope duplicate detection per client. 6 6 7 - **Architecture:** Add five nullable source columns to all three content tables via GORM struct changes. Modify the Store interface to accept a `SourceFilter` for queries and duplicate detection. Update API handlers to parse, pass through, and return source fields. 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 8 9 9 **Tech Stack:** Go, GORM, SQLite/MySQL, `net/http`, `httptest` for testing 10 10 11 11 --- 12 12 13 - ### Task 1: Add source fields to data models 13 + ### Task 1: Add client fields to data models 14 14 15 15 **Files:** 16 16 - Modify: `internal/data/store.go` 17 17 18 - **Step 1: Add source fields to IRCLink struct** 18 + **Step 1: Add client fields to IRCLink struct** 19 19 20 20 In `internal/data/store.go`, add five fields to the `IRCLink` struct (after the `ContentType` field, line 15): 21 21 ··· 28 28 URL string `json:"url" gorm:"column:url"` 29 29 Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 30 30 ContentType string `json:"content_type" gorm:"column:content_type"` 31 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 32 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 33 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 34 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 35 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 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 36 } 37 37 ``` 38 38 39 - **Step 2: Add source fields to Image struct** 39 + **Step 2: Add client fields to Image struct** 40 40 41 41 Same five fields added to `Image` (after `MD5Sum`, line 29): 42 42 ··· 48 48 Link string `json:"link" gorm:"column:link"` 49 49 URL string `json:"url" gorm:"column:url"` 50 50 MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 51 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 52 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 53 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 54 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 55 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 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 56 } 57 57 ``` 58 58 59 - **Step 3: Add source fields to Quote struct** 59 + **Step 3: Add client fields to Quote struct** 60 60 61 61 Same five fields added to `Quote` (after `Poster`, line 42): 62 62 ··· 67 67 Quote string `json:"quote" gorm:"column:quote"` 68 68 Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 69 69 Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 70 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 71 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 72 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 73 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 74 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 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 75 } 76 76 ``` 77 77 78 - **Step 4: Add source fields to TimelineItem struct** 78 + **Step 4: Add client fields to TimelineItem struct** 79 79 80 - Add source fields to `TimelineItem` (after `ContentType`, line 65): 80 + Add client fields to `TimelineItem` (after `ContentType`, line 65): 81 81 82 82 ```go 83 83 type TimelineItem struct { ··· 90 90 Author string `json:"author"` 91 91 MD5Sum string `json:"md5sum"` 92 92 ContentType string `json:"contentType" gorm:"column:content_type"` 93 - SourceType *string `json:"source_type,omitempty"` 94 - SourceNetwork *string `json:"source_network,omitempty"` 95 - SourceChannel *string `json:"source_channel,omitempty"` 96 - SourceUserID *string `json:"source_user_id,omitempty"` 97 - SourceUserName *string `json:"source_user_name,omitempty"` 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 98 } 99 99 ``` 100 100 101 - **Step 5: Add SourceFilter type** 101 + **Step 5: Add ClientFilter type** 102 102 103 103 Add a new type after the `TimelineItem` struct: 104 104 105 105 ```go 106 - // SourceFilter is used to filter queries by source metadata. 107 - // When all fields are nil, no source filtering is applied. 108 - type SourceFilter struct { 109 - SourceType *string 110 - SourceNetwork *string 111 - SourceChannel *string 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 112 } 113 113 ``` 114 114 ··· 121 121 122 122 ```bash 123 123 git add internal/data/store.go 124 - git commit -m "feat: add source metadata fields to data models" 124 + git commit -m "feat: add client metadata fields to data models" 125 125 ``` 126 126 127 127 --- 128 128 129 - ### Task 2: Update Store interface and GormStore for source-aware queries 129 + ### Task 2: Update Store interface and GormStore for client-aware queries 130 130 131 131 **Files:** 132 132 - Modify: `internal/data/store.go` (interface) 133 133 - Modify: `internal/data/gorm_store.go` (implementation) 134 134 135 - **Step 1: Write failing test for source-scoped InsertIRCLink** 135 + **Step 1: Write failing test for client-scoped InsertIRCLink** 136 136 137 - Create test in a new file `internal/data/source_filter_test.go`: 137 + Create test in a new file `internal/data/client_filter_test.go`: 138 138 139 139 ```go 140 140 package data ··· 143 143 "testing" 144 144 ) 145 145 146 - func TestSourceFilter_IsEmpty(t *testing.T) { 146 + func TestClientFilter_IsEmpty(t *testing.T) { 147 147 tests := []struct { 148 148 name string 149 - filter SourceFilter 149 + filter ClientFilter 150 150 expected bool 151 151 }{ 152 - {"all nil", SourceFilter{}, true}, 153 - {"type set", SourceFilter{SourceType: strPtr("irc")}, false}, 154 - {"network set", SourceFilter{SourceNetwork: strPtr("server")}, false}, 155 - {"channel set", SourceFilter{SourceChannel: strPtr("#chan")}, false}, 156 - {"all set", SourceFilter{ 157 - SourceType: strPtr("irc"), 158 - SourceNetwork: strPtr("server"), 159 - SourceChannel: strPtr("#chan"), 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 160 }, false}, 161 161 } 162 162 ··· 177 177 178 178 **Step 2: Run test to verify it fails** 179 179 180 - Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/data/ -run TestSourceFilter` 181 - Expected: FAIL — `SourceFilter` has no `IsEmpty` method 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 182 183 - **Step 3: Implement IsEmpty on SourceFilter** 183 + **Step 3: Implement IsEmpty on ClientFilter** 184 184 185 - In `internal/data/store.go`, add method after the `SourceFilter` struct: 185 + In `internal/data/store.go`, add method after the `ClientFilter` struct: 186 186 187 187 ```go 188 - // IsEmpty returns true if no source filter fields are set. 189 - func (f SourceFilter) IsEmpty() bool { 190 - return f.SourceType == nil && f.SourceNetwork == nil && f.SourceChannel == nil 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 191 } 192 192 ``` 193 193 194 194 **Step 4: Run test to verify it passes** 195 195 196 - Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/data/ -run TestSourceFilter` 196 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/data/ -run TestClientFilter` 197 197 Expected: PASS 198 198 199 199 **Step 5: Update Store interface signatures** ··· 215 215 New: 216 216 ```go 217 217 InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) 218 - GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) 219 - GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]IRCLink, error) 220 - GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Quote, error) 221 - GetRecentImages(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Image, 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 222 InsertQuote(ctx context.Context, quote *Quote) (int, error) 223 223 InsertImage(ctx context.Context, image *Image) (int, error) 224 - SearchIRCLinks(ctx context.Context, query string, filter SourceFilter) ([]IRCLink, error) 225 - SearchQuotes(ctx context.Context, query string, filter SourceFilter) ([]Quote, error) 224 + SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) 225 + SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) 226 226 ``` 227 227 228 228 **Step 6: Update GormStore.InsertIRCLink** ··· 307 307 } 308 308 ``` 309 309 310 - **Step 9: Update GormStore.GetIRCLinksByURL for source-scoped duplicate detection** 310 + **Step 9: Update GormStore.GetIRCLinksByURL for client-scoped duplicate detection** 311 311 312 312 In `gorm_store.go` (line 191): 313 313 ··· 325 325 326 326 New: 327 327 ```go 328 - func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) { 328 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) { 329 329 var links []IRCLink 330 330 query := s.db.WithContext(ctx).Where("url = ?", url) 331 - query = applySourceFilter(query, filter) 331 + query = applyClientFilter(query, filter) 332 332 err := query.Order("timestamp DESC").Find(&links).Error 333 333 return links, err 334 334 } 335 335 ``` 336 336 337 - **Step 10: Update GormStore.GetRecentIRCLinks for source filtering** 337 + **Step 10: Update GormStore.GetRecentIRCLinks for client filtering** 338 338 339 339 In `gorm_store.go` (line 32): 340 340 ··· 356 356 357 357 New: 358 358 ```go 359 - func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]IRCLink, error) { 359 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]IRCLink, error) { 360 360 var links []IRCLink 361 361 now := time.Now() 362 362 startDate := now.AddDate(0, 0, -startDays) ··· 364 364 365 365 query := s.db.WithContext(ctx). 366 366 Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 367 - query = applySourceFilter(query, filter) 367 + query = applyClientFilter(query, filter) 368 368 err := query.Order("timestamp DESC").Find(&links).Error 369 369 return links, err 370 370 } ··· 372 372 373 373 **Step 11: Apply same pattern to GetRecentQuotes, GetRecentImages, SearchIRCLinks, SearchQuotes** 374 374 375 - Add `filter SourceFilter` parameter and `applySourceFilter(query, filter)` call to each. Follow exact same pattern as Step 10. 375 + Add `filter ClientFilter` parameter and `applyClientFilter(query, filter)` call to each. Follow exact same pattern as Step 10. 376 376 377 - **Step 12: Add applySourceFilter helper** 377 + **Step 12: Add applyClientFilter helper** 378 378 379 379 Add this function to `gorm_store.go`: 380 380 381 381 ```go 382 - // applySourceFilter adds WHERE clauses for source metadata fields. 382 + // applyClientFilter adds WHERE clauses for client metadata fields. 383 383 // When filter is empty, no clauses are added (backward compatible). 384 - func applySourceFilter(query *gorm.DB, filter SourceFilter) *gorm.DB { 385 - if filter.SourceType != nil { 386 - query = query.Where("source_type = ?", *filter.SourceType) 384 + func applyClientFilter(query *gorm.DB, filter ClientFilter) *gorm.DB { 385 + if filter.ClientType != nil { 386 + query = query.Where("client_type = ?", *filter.ClientType) 387 387 } 388 - if filter.SourceNetwork != nil { 389 - query = query.Where("source_network = ?", *filter.SourceNetwork) 388 + if filter.ClientNetwork != nil { 389 + query = query.Where("client_network = ?", *filter.ClientNetwork) 390 390 } 391 - if filter.SourceChannel != nil { 392 - query = query.Where("source_channel = ?", *filter.SourceChannel) 391 + if filter.ClientChannel != nil { 392 + query = query.Where("client_channel = ?", *filter.ClientChannel) 393 393 } 394 394 return query 395 395 } ··· 397 397 398 398 **Step 13: Update all other callers of changed methods** 399 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 source context, pass `data.SourceFilter{}` (empty filter). 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 401 402 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 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 409 - All test mock implementations 410 410 411 411 **Step 14: Update mock stores in test files** ··· 413 413 Update `mockAPIStore` in `internal/handler/api_v1_links_test.go` to match new signatures: 414 414 415 415 ```go 416 - func (m *mockAPIStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.IRCLink, error) { 416 + func (m *mockAPIStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.IRCLink, error) { 417 417 if m.err != nil { 418 418 return nil, m.err 419 419 } 420 420 return m.links, nil 421 421 } 422 422 423 - func (m *mockAPIStore) GetIRCLinksByURL(ctx context.Context, url string, filter data.SourceFilter) ([]data.IRCLink, error) { 423 + func (m *mockAPIStore) GetIRCLinksByURL(ctx context.Context, url string, filter data.ClientFilter) ([]data.IRCLink, error) { 424 424 if m.linksByURLFn != nil { 425 425 return m.linksByURLFn(url) 426 426 } ··· 451 451 **Step 16: Commit** 452 452 453 453 ```bash 454 - git add internal/data/store.go internal/data/gorm_store.go internal/data/source_filter_test.go 454 + git add internal/data/store.go internal/data/gorm_store.go internal/data/client_filter_test.go 455 455 git add internal/handler/ 456 - git commit -m "feat: update Store interface for source-aware queries 456 + git commit -m "feat: update Store interface for client-aware queries 457 457 458 458 Change Insert methods to accept struct pointers instead of 459 - individual parameters. Add SourceFilter to Get and Search 460 - methods. Add applySourceFilter helper for GORM queries." 459 + individual parameters. Add ClientFilter to Get and Search 460 + methods. Add applyClientFilter helper for GORM queries." 461 461 ``` 462 462 463 463 --- 464 464 465 - ### Task 3: Update API handlers to accept and return source fields 465 + ### Task 3: Update API handlers to accept and return client fields 466 466 467 467 **Files:** 468 468 - Modify: `internal/handler/api_v1_types.go` ··· 470 470 - Modify: `internal/handler/api_v1_quotes.go` 471 471 - Modify: `internal/handler/api_v1_search.go` 472 472 473 - **Step 1: Write failing test for source fields in link creation** 473 + **Step 1: Write failing test for client fields in link creation** 474 474 475 475 Add to `internal/handler/api_v1_links_test.go`, in the `TestAPIv1_CreateLink` function's test table: 476 476 477 477 ```go 478 478 { 479 - name: "valid link with source fields", 480 - body: `{"url":"https://example.com","user":"testuser","source_type":"slack","source_network":"T12345","source_channel":"C67890","source_user_id":"U99999","source_user_name":"testuser"}`, 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 481 store: &mockAPIStore{insertedLinkID: 42}, 482 482 expectedStatus: http.StatusCreated, 483 483 checkBody: func(t *testing.T, body []byte) { ··· 485 485 if err := json.Unmarshal(body, &resp); err != nil { 486 486 t.Fatalf("failed to unmarshal: %v", err) 487 487 } 488 - if resp.SourceType == nil || *resp.SourceType != "slack" { 489 - t.Errorf("expected source_type 'slack', got %v", resp.SourceType) 488 + if resp.ClientType == nil || *resp.ClientType != "slack" { 489 + t.Errorf("expected client_type 'slack', got %v", resp.ClientType) 490 490 } 491 - if resp.SourceNetwork == nil || *resp.SourceNetwork != "T12345" { 492 - t.Errorf("expected source_network 'T12345', got %v", resp.SourceNetwork) 491 + if resp.ClientNetwork == nil || *resp.ClientNetwork != "T12345" { 492 + t.Errorf("expected client_network 'T12345', got %v", resp.ClientNetwork) 493 493 } 494 494 }, 495 495 }, ··· 497 497 498 498 **Step 2: Run test to verify it fails** 499 499 500 - Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/handler/ -run TestAPIv1_CreateLink/valid_link_with_source_fields` 501 - Expected: FAIL — `APILinkCreateResponse` has no `SourceType` field 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 502 503 - **Step 3: Add source fields to API response types** 503 + **Step 3: Add client fields to API response types** 504 504 505 - In `internal/handler/api_v1_types.go`, add source fields to `APILinkResponse`: 505 + In `internal/handler/api_v1_types.go`, add client fields to `APILinkResponse`: 506 506 507 507 ```go 508 508 type APILinkResponse struct { ··· 513 513 Clicks int `json:"clicks"` 514 514 CreatedAt time.Time `json:"created_at"` 515 515 Tags []string `json:"tags,omitempty"` 516 - SourceType *string `json:"source_type,omitempty"` 517 - SourceNetwork *string `json:"source_network,omitempty"` 518 - SourceChannel *string `json:"source_channel,omitempty"` 519 - SourceUserID *string `json:"source_user_id,omitempty"` 520 - SourceUserName *string `json:"source_user_name,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 521 } 522 522 ``` 523 523 524 - Add source fields to `APIQuoteResponse`: 524 + Add client fields to `APIQuoteResponse`: 525 525 526 526 ```go 527 527 type APIQuoteResponse struct { ··· 531 531 Poster string `json:"poster,omitempty"` 532 532 CreatedAt time.Time `json:"created_at"` 533 533 Tags []string `json:"tags,omitempty"` 534 - SourceType *string `json:"source_type,omitempty"` 535 - SourceNetwork *string `json:"source_network,omitempty"` 536 - SourceChannel *string `json:"source_channel,omitempty"` 537 - SourceUserID *string `json:"source_user_id,omitempty"` 538 - SourceUserName *string `json:"source_user_name,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 539 } 540 540 ``` 541 541 542 - **Step 4: Add source fields to request types** 542 + **Step 4: Add client fields to request types** 543 543 544 544 In `internal/handler/api_v1_links.go`, update `APILinkCreateRequest`: 545 545 ··· 548 548 URL string `json:"url"` 549 549 User string `json:"user"` 550 550 Tags []string `json:"tags,omitempty"` 551 - SourceType *string `json:"source_type,omitempty"` 552 - SourceNetwork *string `json:"source_network,omitempty"` 553 - SourceChannel *string `json:"source_channel,omitempty"` 554 - SourceUserID *string `json:"source_user_id,omitempty"` 555 - SourceUserName *string `json:"source_user_name,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 556 } 557 557 ``` 558 558 ··· 564 564 Author string `json:"author"` 565 565 Poster string `json:"poster"` 566 566 Tags []string `json:"tags,omitempty"` 567 - SourceType *string `json:"source_type,omitempty"` 568 - SourceNetwork *string `json:"source_network,omitempty"` 569 - SourceChannel *string `json:"source_channel,omitempty"` 570 - SourceUserID *string `json:"source_user_id,omitempty"` 571 - SourceUserName *string `json:"source_user_name,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 572 } 573 573 ``` 574 574 575 - **Step 5: Update apiV1CreateLink to pass source fields through** 575 + **Step 5: Update apiV1CreateLink to pass client fields through** 576 576 577 - In `internal/handler/api_v1_links.go`, update `apiV1CreateLink` to build an `IRCLink` struct and pass source fields: 577 + In `internal/handler/api_v1_links.go`, update `apiV1CreateLink` to build an `IRCLink` struct and pass client fields: 578 578 579 579 ```go 580 - // Build source filter for duplicate detection 581 - sourceFilter := data.SourceFilter{ 582 - SourceType: req.SourceType, 583 - SourceNetwork: req.SourceNetwork, 584 - SourceChannel: req.SourceChannel, 580 + // Build client filter for duplicate detection 581 + clientFilter := data.ClientFilter{ 582 + ClientType: req.ClientType, 583 + ClientNetwork: req.ClientNetwork, 584 + ClientChannel: req.ClientChannel, 585 585 } 586 586 587 - // Check for duplicates (scoped by source when provided) 588 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, sourceFilter) 587 + // Check for duplicates (scoped by client when provided) 588 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, clientFilter) 589 589 590 - // Build the link struct with source metadata 590 + // Build the link struct with client metadata 591 591 link := &data.IRCLink{ 592 592 User: req.User, 593 593 Title: req.URL, 594 594 URL: req.URL, 595 595 ContentType: "", 596 - SourceType: req.SourceType, 597 - SourceNetwork: req.SourceNetwork, 598 - SourceChannel: req.SourceChannel, 599 - SourceUserID: req.SourceUserID, 600 - SourceUserName: req.SourceUserName, 596 + ClientType: req.ClientType, 597 + ClientNetwork: req.ClientNetwork, 598 + ClientChannel: req.ClientChannel, 599 + ClientUserID: req.ClientUserID, 600 + ClientUserName: req.ClientUserName, 601 601 } 602 602 linkID, err := h.Store.InsertIRCLink(ctx, link) 603 603 ``` 604 604 605 - Update the response builder to include source fields: 605 + Update the response builder to include client fields: 606 606 607 607 ```go 608 608 resp := APILinkCreateResponse{ ··· 614 614 Clicks: 0, 615 615 CreatedAt: time.Now(), 616 616 Tags: tagStrings, 617 - SourceType: req.SourceType, 618 - SourceNetwork: req.SourceNetwork, 619 - SourceChannel: req.SourceChannel, 620 - SourceUserID: req.SourceUserID, 621 - SourceUserName: req.SourceUserName, 617 + ClientType: req.ClientType, 618 + ClientNetwork: req.ClientNetwork, 619 + ClientChannel: req.ClientChannel, 620 + ClientUserID: req.ClientUserID, 621 + ClientUserName: req.ClientUserName, 622 622 }, 623 623 IsDuplicate: isDuplicate, 624 624 PreviousSubmissions: previousSubmissions, ··· 627 627 628 628 **Step 6: Update apiV1CreateQuote similarly** 629 629 630 - Pass source fields through to `InsertQuote` and include in response. 630 + Pass client fields through to `InsertQuote` and include in response. 631 631 632 - **Step 7: Update apiV1ListLinks to parse source query params and pass filter** 632 + **Step 7: Update apiV1ListLinks to parse client query params and pass filter** 633 633 634 634 In `internal/handler/api_v1_links.go`, in `apiV1ListLinks`: 635 635 636 636 ```go 637 - // Parse source filter parameters 638 - var sourceFilter data.SourceFilter 639 - if st := r.URL.Query().Get("source_type"); st != "" { 640 - sourceFilter.SourceType = &st 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 641 } 642 - if sn := r.URL.Query().Get("source_network"); sn != "" { 643 - sourceFilter.SourceNetwork = &sn 642 + if sn := r.URL.Query().Get("client_network"); sn != "" { 643 + clientFilter.ClientNetwork = &sn 644 644 } 645 - if sc := r.URL.Query().Get("source_channel"); sc != "" { 646 - sourceFilter.SourceChannel = &sc 645 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 646 + clientFilter.ClientChannel = &sc 647 647 } 648 648 649 - links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, sourceFilter) 649 + links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, clientFilter) 650 650 ``` 651 651 652 - Update the response conversion loop to include source fields from each link: 652 + Update the response conversion loop to include client fields from each link: 653 653 654 654 ```go 655 655 data = append(data, APILinkResponse{ ··· 660 660 Clicks: link.Clicks, 661 661 CreatedAt: link.Timestamp, 662 662 Tags: h.getTagStrings(ctx, "link", link.ID), 663 - SourceType: link.SourceType, 664 - SourceNetwork: link.SourceNetwork, 665 - SourceChannel: link.SourceChannel, 666 - SourceUserID: link.SourceUserID, 667 - SourceUserName: link.SourceUserName, 663 + ClientType: link.ClientType, 664 + ClientNetwork: link.ClientNetwork, 665 + ClientChannel: link.ClientChannel, 666 + ClientUserID: link.ClientUserID, 667 + ClientUserName: link.ClientUserName, 668 668 }) 669 669 ``` 670 670 671 671 **Step 8: Update apiV1ListQuotes the same way** 672 672 673 - Parse source query params, pass filter to `GetRecentQuotes`, include source fields in response. 673 + Parse client query params, pass filter to `GetRecentQuotes`, include client fields in response. 674 674 675 675 **Step 9: Update apiV1GetLink and apiV1GetQuote responses** 676 676 677 - Include source fields from the fetched link/quote in the response structs. 677 + Include client fields from the fetched link/quote in the response structs. 678 678 679 679 **Step 10: Update APIv1SearchHandler** 680 680 681 - In `internal/handler/api_v1_search.go`, parse source query params and pass filter to `SearchIRCLinks` and `SearchQuotes`. Include source fields in the response conversion loops. 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 682 683 683 **Step 11: Run tests** 684 684 685 685 Run: `cd /Users/stahnma/development/personal/tumble/tumble && make test` 686 - Expected: All tests pass including the new source fields test 686 + Expected: All tests pass including the new client fields test 687 687 688 688 **Step 12: Commit** 689 689 690 690 ```bash 691 691 git add internal/handler/ 692 - git commit -m "feat: accept and return source fields in API endpoints 692 + git commit -m "feat: accept and return client fields in API endpoints 693 693 694 - POST links/quotes accepts optional source_type, source_network, 695 - source_channel, source_user_id, source_user_name fields. 696 - GET links/quotes/search supports source_type, source_network, 697 - source_channel query parameters for filtering. 698 - All responses include source fields with omitempty." 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 699 ``` 700 700 701 701 --- 702 702 703 - ### Task 4: Write comprehensive tests for source-scoped duplicate detection 703 + ### Task 4: Write comprehensive tests for client-scoped duplicate detection 704 704 705 705 **Files:** 706 706 - Modify: `internal/handler/api_v1_links_test.go` 707 707 708 708 **Step 1: Write test cases for scoped duplicate detection** 709 709 710 - Add a new test function `TestAPIv1_CreateLink_SourceDuplicates`: 710 + Add a new test function `TestAPIv1_CreateLink_ClientDuplicates`: 711 711 712 712 ```go 713 - func TestAPIv1_CreateLink_SourceDuplicates(t *testing.T) { 713 + func TestAPIv1_CreateLink_ClientDuplicates(t *testing.T) { 714 714 now := time.Now() 715 715 716 716 tests := []struct { ··· 721 721 expectedDuplicate bool 722 722 }{ 723 723 { 724 - name: "same URL different source is not duplicate", 725 - body: `{"url":"https://example.com","user":"alice","source_type":"slack","source_network":"T111","source_channel":"C222"}`, 726 - existingLinks: nil, // source-scoped query returns nothing 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 727 expectedStatus: http.StatusCreated, 728 728 expectedDuplicate: false, 729 729 }, 730 730 { 731 - name: "same URL same source is duplicate", 732 - body: `{"url":"https://example.com","user":"bob","source_type":"slack","source_network":"T111","source_channel":"C222"}`, 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 733 existingLinks: []data.IRCLink{ 734 734 {ID: 10, User: "alice", URL: "https://example.com", Timestamp: now}, 735 735 }, ··· 737 737 expectedDuplicate: true, 738 738 }, 739 739 { 740 - name: "no source fields uses global duplicate check", 740 + name: "no client fields uses global duplicate check", 741 741 body: `{"url":"https://example.com","user":"charlie"}`, 742 742 existingLinks: []data.IRCLink{ 743 743 {ID: 10, User: "alice", URL: "https://example.com", Timestamp: now}, ··· 778 778 779 779 **Step 2: Run tests** 780 780 781 - Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/handler/ -run TestAPIv1_CreateLink_SourceDuplicates` 781 + Run: `cd /Users/stahnma/development/personal/tumble/tumble && go test -v ./internal/handler/ -run TestAPIv1_CreateLink_ClientDuplicates` 782 782 Expected: PASS 783 783 784 - **Step 3: Write test for source filtering on GET** 784 + **Step 3: Write test for client filtering on GET** 785 785 786 - Add `TestAPIv1_ListLinks_SourceFiltering`: 786 + Add `TestAPIv1_ListLinks_ClientFiltering`: 787 787 788 788 ```go 789 - func TestAPIv1_ListLinks_SourceFiltering(t *testing.T) { 789 + func TestAPIv1_ListLinks_ClientFiltering(t *testing.T) { 790 790 slackType := "slack" 791 791 slackNetwork := "T12345" 792 792 slackChannel := "C67890" ··· 802 802 queryParams: "", 803 803 links: []data.IRCLink{ 804 804 {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now()}, 805 - {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), SourceType: &slackType}, 805 + {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), ClientType: &slackType}, 806 806 }, 807 807 expectedTotal: 2, 808 808 }, 809 809 { 810 - name: "filter by source_type", 811 - queryParams: "?source_type=slack", 810 + name: "filter by client_type", 811 + queryParams: "?client_type=slack", 812 812 links: []data.IRCLink{ 813 - {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), SourceType: &slackType}, 813 + {ID: 2, User: "bob", URL: "https://b.com", Timestamp: time.Now(), ClientType: &slackType}, 814 814 }, 815 815 expectedTotal: 1, 816 816 }, ··· 839 839 840 840 **Step 4: Write test for omitempty serialization** 841 841 842 - Add `TestAPIv1_LinkResponse_SourceOmitEmpty`: 842 + Add `TestAPIv1_LinkResponse_ClientOmitEmpty`: 843 843 844 844 ```go 845 - func TestAPIv1_LinkResponse_SourceOmitEmpty(t *testing.T) { 846 - t.Run("null source fields omitted from JSON", func(t *testing.T) { 845 + func TestAPIv1_LinkResponse_ClientOmitEmpty(t *testing.T) { 846 + t.Run("null client fields omitted from JSON", func(t *testing.T) { 847 847 store := &mockAPIStore{ 848 848 links: []data.IRCLink{ 849 849 {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now()}, ··· 856 856 handler.APIv1LinksHandler(w, req) 857 857 858 858 body := w.Body.String() 859 - if strings.Contains(body, "source_type") { 860 - t.Error("expected source_type to be omitted when null") 859 + if strings.Contains(body, "client_type") { 860 + t.Error("expected client_type to be omitted when null") 861 861 } 862 862 }) 863 863 864 - t.Run("source fields present when set", func(t *testing.T) { 864 + t.Run("client fields present when set", func(t *testing.T) { 865 865 slackType := "slack" 866 866 store := &mockAPIStore{ 867 867 links: []data.IRCLink{ 868 - {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now(), SourceType: &slackType}, 868 + {ID: 1, User: "alice", URL: "https://a.com", Timestamp: time.Now(), ClientType: &slackType}, 869 869 }, 870 870 } 871 871 handler := NewHandler(store, &config.Config{}) ··· 875 875 handler.APIv1LinksHandler(w, req) 876 876 877 877 body := w.Body.String() 878 - if !strings.Contains(body, `"source_type":"slack"`) { 879 - t.Errorf("expected source_type in response, got: %s", body) 878 + if !strings.Contains(body, `"client_type":"slack"`) { 879 + t.Errorf("expected client_type in response, got: %s", body) 880 880 } 881 881 }) 882 882 } ··· 891 891 892 892 ```bash 893 893 git add internal/handler/api_v1_links_test.go 894 - git commit -m "test: add tests for source-scoped duplicate detection and filtering" 894 + git commit -m "test: add tests for client-scoped duplicate detection and filtering" 895 895 ``` 896 896 897 897 --- ··· 899 899 ### Task 5: Write backfill SQL script 900 900 901 901 **Files:** 902 - - Create: `sql/backfill_sources.sql` 902 + - Create: `sql/backfill_clients.sql` 903 903 904 904 **Step 1: Create the backfill script** 905 905 906 906 ```sql 907 - -- One-time backfill: set source metadata on all existing rows. 907 + -- One-time backfill: set client metadata on all existing rows. 908 908 -- All existing data originates from IRC, #soggies channel on jameswhite.org. 909 - -- Run this manually after deploying the source fields migration. 909 + -- Run this manually after deploying the client fields migration. 910 910 -- 911 - -- Usage (SQLite): sqlite3 tumble.db < sql/backfill_sources.sql 912 - -- Usage (MySQL): mysql -u user -p tumble < sql/backfill_sources.sql 911 + -- Usage (SQLite): sqlite3 tumble.db < sql/backfill_clients.sql 912 + -- Usage (MySQL): mysql -u user -p tumble < sql/backfill_clients.sql 913 913 914 914 UPDATE ircLink 915 - SET source_type = 'irc', 916 - source_network = 'jameswhite.org', 917 - source_channel = '#soggies' 918 - WHERE source_type IS NULL; 915 + SET client_type = 'irc', 916 + client_network = 'jameswhite.org', 917 + client_channel = '#soggies' 918 + WHERE client_type IS NULL; 919 919 920 920 UPDATE quote 921 - SET source_type = 'irc', 922 - source_network = 'jameswhite.org', 923 - source_channel = '#soggies' 924 - WHERE source_type IS NULL; 921 + SET client_type = 'irc', 922 + client_network = 'jameswhite.org', 923 + client_channel = '#soggies' 924 + WHERE client_type IS NULL; 925 925 926 926 UPDATE image 927 - SET source_type = 'irc', 928 - source_network = 'jameswhite.org', 929 - source_channel = '#soggies' 930 - WHERE source_type IS NULL; 927 + SET client_type = 'irc', 928 + client_network = 'jameswhite.org', 929 + client_channel = '#soggies' 930 + WHERE client_type IS NULL; 931 931 ``` 932 932 933 933 **Step 2: Commit** 934 934 935 935 ```bash 936 - git add sql/backfill_sources.sql 937 - git commit -m "feat: add one-time backfill script for source metadata" 936 + git add sql/backfill_clients.sql 937 + git commit -m "feat: add one-time backfill script for client metadata" 938 938 ``` 939 939 940 940 --- ··· 944 944 **Files:** 945 945 - Modify: `internal/assets/openapi.json` 946 946 947 - **Step 1: Add source fields to LinkCreateRequest schema** 947 + **Step 1: Add client fields to LinkCreateRequest schema** 948 948 949 949 Find the `LinkCreateRequest` schema in `openapi.json` and add: 950 950 951 951 ```json 952 - "source_type": { 952 + "client_type": { 953 953 "type": "string", 954 - "description": "Source platform (e.g., irc, slack, discord, api, web)", 954 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 955 955 "example": "slack" 956 956 }, 957 - "source_network": { 957 + "client_network": { 958 958 "type": "string", 959 - "description": "Source network identifier (e.g., IRC server, Slack team ID)", 959 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 960 960 "example": "T12345" 961 961 }, 962 - "source_channel": { 962 + "client_channel": { 963 963 "type": "string", 964 - "description": "Source channel identifier (e.g., IRC channel, Slack channel ID)", 964 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 965 965 "example": "C67890" 966 966 }, 967 - "source_user_id": { 967 + "client_user_id": { 968 968 "type": "string", 969 969 "description": "Platform-specific user ID", 970 970 "example": "U99999" 971 971 }, 972 - "source_user_name": { 972 + "client_user_name": { 973 973 "type": "string", 974 974 "description": "Display/mention name at time of post", 975 975 "example": "stahnma" ··· 980 980 981 981 Same five fields. 982 982 983 - **Step 3: Add source fields to APILinkResponse and APIQuoteResponse schemas** 983 + **Step 3: Add client fields to APILinkResponse and APIQuoteResponse schemas** 984 984 985 985 Same five fields added to response schemas. 986 986 987 - **Step 4: Add source query parameters to GET /api/v1/links** 987 + **Step 4: Add client query parameters to GET /api/v1/links** 988 988 989 989 Add three optional query parameters: 990 990 991 991 ```json 992 992 { 993 - "name": "source_type", 993 + "name": "client_type", 994 994 "in": "query", 995 995 "required": false, 996 996 "schema": { "type": "string" }, 997 - "description": "Filter by source platform (e.g., irc, slack, discord)" 997 + "description": "Filter by client platform (e.g., irc, slack, discord)" 998 998 }, 999 999 { 1000 - "name": "source_network", 1000 + "name": "client_network", 1001 1001 "in": "query", 1002 1002 "required": false, 1003 1003 "schema": { "type": "string" }, 1004 - "description": "Filter by source network (requires source_type)" 1004 + "description": "Filter by client network (requires client_type)" 1005 1005 }, 1006 1006 { 1007 - "name": "source_channel", 1007 + "name": "client_channel", 1008 1008 "in": "query", 1009 1009 "required": false, 1010 1010 "schema": { "type": "string" }, 1011 - "description": "Filter by source channel (requires source_type and source_network)" 1011 + "description": "Filter by client channel (requires client_type and client_network)" 1012 1012 } 1013 1013 ``` 1014 1014 ··· 1016 1016 1017 1017 **Step 6: Update 208 response description** 1018 1018 1019 - Update the duplicate detection documentation for POST /api/v1/links to note that duplicate detection is scoped per source when source fields are provided. 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 1020 1021 1021 **Step 7: Run the app to verify docs render** 1022 1022 ··· 1028 1028 1029 1029 ```bash 1030 1030 git add internal/assets/openapi.json 1031 - git commit -m "docs: update OpenAPI spec with source metadata fields 1031 + git commit -m "docs: update OpenAPI spec with client metadata fields 1032 1032 1033 - Add source_type, source_network, source_channel, source_user_id, 1034 - and source_user_name to request/response schemas. Add source 1033 + Add client_type, client_network, client_channel, client_user_id, 1034 + and client_user_name to request/response schemas. Add client 1035 1035 filter query parameters to GET endpoints." 1036 1036 ``` 1037 1037 ··· 1053 1053 1054 1054 Run: `cd /Users/stahnma/development/personal/tumble/tumble && make restart` 1055 1055 1056 - Test creating a link with source fields: 1056 + Test creating a link with client fields: 1057 1057 ```bash 1058 1058 curl -s -X POST http://localhost:8080/api/v1/links \ 1059 1059 -H "Content-Type: application/json" \ 1060 - -d '{"url":"https://example.com/test","user":"testuser","source_type":"slack","source_network":"T12345","source_channel":"C67890","source_user_id":"U99999","source_user_name":"testuser"}' | jq . 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 1061 ``` 1062 1062 1063 - Verify source fields in response. Then test filtering: 1063 + Verify client fields in response. Then test filtering: 1064 1064 ```bash 1065 - curl -s "http://localhost:8080/api/v1/links?source_type=slack" | jq . 1065 + curl -s "http://localhost:8080/api/v1/links?client_type=slack" | jq . 1066 1066 ``` 1067 1067 1068 - Test that creating same URL without source fields still works: 1068 + Test that creating same URL without client fields still works: 1069 1069 ```bash 1070 1070 curl -s -X POST http://localhost:8080/api/v1/links \ 1071 1071 -H "Content-Type: application/json" \
+67 -67
docs/plans/2026-02-13-multi-source-support-design.md
··· 1 - # Multi-Source Support 1 + # Multi-Client Support 2 2 3 3 **Date:** 2026-02-13 4 4 **Status:** Proposed ··· 8 8 Tumble currently serves a single community with no tracking of where 9 9 posts originate. As usage expands to multiple IRC channels, Slack 10 10 teams, and potentially Discord servers, posts need metadata about 11 - their source. This enables per-source duplicate detection, per-source 12 - filtering, and eventually source-scoped UI views. 11 + their client. This enables per-client duplicate detection, per-client 12 + filtering, and eventually client-scoped UI views. 13 13 14 14 ## Solution 15 15 16 - Add source metadata columns to all three content tables (links, 17 - quotes, images). Duplicate detection becomes scoped per source. 18 - API endpoints accept optional source fields on creation and support 19 - filtering by source on reads. No UI changes in this phase. 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 20 21 21 ## Data Model 22 22 ··· 24 24 25 25 | Column | Type | Purpose | 26 26 |--------|------|---------| 27 - | `source_type` | VARCHAR(50) | Platform: "irc", "slack", "discord", "api", "web" | 28 - | `source_network` | VARCHAR(255) | IRC server, Slack team ID, Discord guild ID | 29 - | `source_channel` | VARCHAR(255) | IRC channel, Slack/Discord channel ID | 30 - | `source_user_id` | VARCHAR(255) | Platform-specific user ID (null for IRC) | 31 - | `source_user_name` | VARCHAR(255) | Display/mention name at time of post (null for IRC) | 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 32 33 - Composite index on `(source_type, source_network, source_channel)` 33 + Composite index on `(client_type, client_network, client_channel)` 34 34 on each table. 35 35 36 36 The existing `user` column is unchanged. It continues to store the 37 - poster's name. The new `source_user_id` and `source_user_name` fields 37 + poster's name. The new `client_user_id` and `client_user_name` fields 38 38 provide platform-specific identity alongside it. 39 39 40 40 Platform mapping: 41 41 42 42 | Field | IRC | Slack | Discord | 43 43 |-------|-----|-------|---------| 44 - | `source_type` | "irc" | "slack" | "discord" | 45 - | `source_network` | server hostname | team ID | guild ID | 46 - | `source_channel` | channel name | channel ID | channel ID | 47 - | `source_user_id` | null | Slack user ID | Discord user ID | 48 - | `source_user_name` | null | mention name | display name | 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 49 50 50 GORM model additions (same for all three structs): 51 51 52 52 ```go 53 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type"` 54 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network"` 55 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel"` 56 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id"` 57 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name"` 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 58 ``` 59 59 60 60 Pointer types for correct null handling and `omitempty` serialization. ··· 67 67 68 68 ## Data Backfill 69 69 70 - All existing rows predate multi-source support and originate from IRC. 70 + All existing rows predate multi-client support and originate from IRC. 71 71 A standalone SQL script (run once, manually) backfills them: 72 72 73 73 ```sql 74 - UPDATE ircLink SET source_type = 'irc', source_network = 'jameswhite.org', source_channel = '#soggies' WHERE source_type IS NULL; 75 - UPDATE quote SET source_type = 'irc', source_network = 'jameswhite.org', source_channel = '#soggies' WHERE source_type IS NULL; 76 - UPDATE image SET source_type = 'irc', source_network = 'jameswhite.org', source_channel = '#soggies' WHERE source_type IS NULL; 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 77 ``` 78 78 79 79 No backfill code in the application. No dead code paths. ··· 81 81 ## Duplicate Detection 82 82 83 83 Currently global: any POST of an existing URL returns 208. Changes to 84 - be scoped by `source_type + source_network + source_channel`. 84 + be scoped by `client_type + client_network + client_channel`. 85 85 86 86 **Rules:** 87 - - POST with source fields: check duplicates within that source tuple 88 - only. Same URL from a different source is a new link (201). 89 - - POST with null/omitted source fields: fall back to global duplicate 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 90 check (backward compatibility). 91 - - Same URL within same source: 208 Already Reported with previous 91 + - Same URL within same client: 208 Already Reported with previous 92 92 submission info, as before. 93 93 94 94 **Example:** ··· 111 111 { 112 112 "url": "https://example.com", 113 113 "user": "stahnma", 114 - "source_type": "slack", 115 - "source_network": "T12345", 116 - "source_channel": "C67890", 117 - "source_user_id": "U99999", 118 - "source_user_name": "stahnma" 114 + "client_type": "slack", 115 + "client_network": "T12345", 116 + "client_channel": "C67890", 117 + "client_user_id": "U99999", 118 + "client_user_name": "stahnma" 119 119 } 120 120 ``` 121 121 122 - All source fields are optional. Omitting them stores nulls. 122 + All client fields are optional. Omitting them stores nulls. 123 123 124 124 ### GET Endpoints 125 125 126 126 `GET /api/v1/links`, `GET /api/v1/quotes`, `GET /api/v1/search` gain 127 127 three optional query parameters: 128 128 129 - - `source_type` - filter by platform 130 - - `source_network` - filter by team/server (requires source_type) 131 - - `source_channel` - filter by channel (requires source_type + 132 - source_network) 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 133 134 134 Cumulative filtering. Omitting all returns everything (current 135 135 behavior). 136 136 137 137 ### Response Payloads 138 138 139 - Source fields included with `omitempty`. Null source fields are 139 + Client fields included with `omitempty`. Null client fields are 140 140 omitted from responses: 141 141 142 142 ```json ··· 145 145 "url": "https://example.com", 146 146 "user": "stahnma", 147 147 "title": "Example", 148 - "source_type": "slack", 149 - "source_network": "T12345", 150 - "source_channel": "C67890", 151 - "source_user_id": "U99999", 152 - "source_user_name": "stahnma" 148 + "client_type": "slack", 149 + "client_network": "T12345", 150 + "client_channel": "C67890", 151 + "client_user_id": "U99999", 152 + "client_user_name": "stahnma" 153 153 } 154 154 ``` 155 155 156 156 ## Store Interface Changes 157 157 158 - `GetLinkByURL` becomes source-scoped. New filter struct for query 158 + `GetLinkByURL` becomes client-scoped. New filter struct for query 159 159 methods: 160 160 161 161 ```go 162 - type SourceFilter struct { 163 - SourceType *string 164 - SourceNetwork *string 165 - SourceChannel *string 162 + type ClientFilter struct { 163 + ClientType *string 164 + ClientNetwork *string 165 + ClientChannel *string 166 166 } 167 167 ``` 168 168 ··· 170 170 detection lookup. When all fields are nil, behaves identically to 171 171 current implementation. 172 172 173 - `TimelineItem` gains source fields for future frontend use. 173 + `TimelineItem` gains client fields for future frontend use. 174 174 175 175 ## Testing 176 176 177 - - Duplicate detection: same URL + same source = 208; same URL + 178 - different source = 201; null source = global fallback 179 - - Filtering: source params return correct subset; params combine to 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 180 narrow results; no params returns everything 181 181 - Backfill: existing rows have correct values after running script 182 - - Serialization: source fields omitted from JSON when null; present 182 + - Serialization: client fields omitted from JSON when null; present 183 183 when populated 184 - - Round-trip: POST with source fields, GET returns them; POST 184 + - Round-trip: POST with client fields, GET returns them; POST 185 185 without, GET omits them 186 186 187 187 ## API Documentation 188 188 189 189 Update `internal/assets/openapi.json` to reflect all API changes: 190 190 191 - - Add `source_type`, `source_network`, `source_channel`, 192 - `source_user_id`, and `source_user_name` to request schemas for 191 + - Add `client_type`, `client_network`, `client_channel`, 192 + `client_user_id`, and `client_user_name` to request schemas for 193 193 `POST /api/v1/links` and `POST /api/v1/quotes` 194 - - Add `source_type`, `source_network`, `source_channel` as optional 194 + - Add `client_type`, `client_network`, `client_channel` as optional 195 195 query parameters on `GET /api/v1/links`, `GET /api/v1/quotes`, and 196 196 `GET /api/v1/search` 197 - - Add source fields to all response schemas (links, quotes, images) 197 + - Add client fields to all response schemas (links, quotes, images) 198 198 - Document the scoped duplicate detection behavior (208 is now 199 - per-source when source fields are provided) 199 + per-client when client fields are provided) 200 200 201 201 ## Out of Scope 202 202 203 203 - UI changes 204 - - Authentication/authorization per source 204 + - Authentication/authorization per client 205 205 - User identity lookup table for name history tracking 206 206 - Slack, Discord, or other client/bot implementations 207 207 - Changes to the existing `source` request parameter (controls 208 - response format for irc/api/html — separate concept) 208 + response format for irc/api/html -- separate concept)
+52 -52
internal/assets/openapi.json
··· 108 108 }, 109 109 "description": "Tags associated with this link" 110 110 }, 111 - "source_type": { 111 + "client_type": { 112 112 "type": "string", 113 - "description": "Source platform (e.g., irc, slack, discord, api, web)", 113 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 114 114 "example": "slack" 115 115 }, 116 - "source_network": { 116 + "client_network": { 117 117 "type": "string", 118 - "description": "Source network identifier (e.g., IRC server, Slack team ID)", 118 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 119 119 "example": "T12345" 120 120 }, 121 - "source_channel": { 121 + "client_channel": { 122 122 "type": "string", 123 - "description": "Source channel identifier (e.g., IRC channel, Slack channel ID)", 123 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 124 124 "example": "C67890" 125 125 }, 126 - "source_user_id": { 126 + "client_user_id": { 127 127 "type": "string", 128 128 "description": "Platform-specific user ID", 129 129 "example": "U99999" 130 130 }, 131 - "source_user_name": { 131 + "client_user_name": { 132 132 "type": "string", 133 133 "description": "Display/mention name at time of post", 134 134 "example": "stahnma" ··· 252 252 }, 253 253 "description": "Tags associated with this quote" 254 254 }, 255 - "source_type": { 255 + "client_type": { 256 256 "type": "string", 257 - "description": "Source platform (e.g., irc, slack, discord, api, web)", 257 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 258 258 "example": "slack" 259 259 }, 260 - "source_network": { 260 + "client_network": { 261 261 "type": "string", 262 - "description": "Source network identifier (e.g., IRC server, Slack team ID)", 262 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 263 263 "example": "T12345" 264 264 }, 265 - "source_channel": { 265 + "client_channel": { 266 266 "type": "string", 267 - "description": "Source channel identifier (e.g., IRC channel, Slack channel ID)", 267 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 268 268 "example": "C67890" 269 269 }, 270 - "source_user_id": { 270 + "client_user_id": { 271 271 "type": "string", 272 272 "description": "Platform-specific user ID", 273 273 "example": "U99999" 274 274 }, 275 - "source_user_name": { 275 + "client_user_name": { 276 276 "type": "string", 277 277 "description": "Display/mention name at time of post", 278 278 "example": "stahnma" ··· 519 519 }, 520 520 "description": "Optional tags to add to the link (lowercased, no spaces)" 521 521 }, 522 - "source_type": { 522 + "client_type": { 523 523 "type": "string", 524 - "description": "Source platform (e.g., irc, slack, discord, api, web)", 524 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 525 525 "example": "slack" 526 526 }, 527 - "source_network": { 527 + "client_network": { 528 528 "type": "string", 529 - "description": "Source network identifier (e.g., IRC server, Slack team ID)", 529 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 530 530 "example": "T12345" 531 531 }, 532 - "source_channel": { 532 + "client_channel": { 533 533 "type": "string", 534 - "description": "Source channel identifier (e.g., IRC channel, Slack channel ID)", 534 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 535 535 "example": "C67890" 536 536 }, 537 - "source_user_id": { 537 + "client_user_id": { 538 538 "type": "string", 539 539 "description": "Platform-specific user ID", 540 540 "example": "U99999" 541 541 }, 542 - "source_user_name": { 542 + "client_user_name": { 543 543 "type": "string", 544 544 "description": "Display/mention name at time of post", 545 545 "example": "stahnma" ··· 647 647 }, 648 648 "description": "Optional tags to add to the quote (lowercased, no spaces)" 649 649 }, 650 - "source_type": { 650 + "client_type": { 651 651 "type": "string", 652 - "description": "Source platform (e.g., irc, slack, discord, api, web)", 652 + "description": "Client platform (e.g., irc, slack, discord, api, web)", 653 653 "example": "slack" 654 654 }, 655 - "source_network": { 655 + "client_network": { 656 656 "type": "string", 657 - "description": "Source network identifier (e.g., IRC server, Slack team ID)", 657 + "description": "Client network identifier (e.g., IRC server, Slack team ID)", 658 658 "example": "T12345" 659 659 }, 660 - "source_channel": { 660 + "client_channel": { 661 661 "type": "string", 662 - "description": "Source channel identifier (e.g., IRC channel, Slack channel ID)", 662 + "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)", 663 663 "example": "C67890" 664 664 }, 665 - "source_user_id": { 665 + "client_user_id": { 666 666 "type": "string", 667 667 "description": "Platform-specific user ID", 668 668 "example": "U99999" 669 669 }, 670 - "source_user_name": { 670 + "client_user_name": { 671 671 "type": "string", 672 672 "description": "Display/mention name at time of post", 673 673 "example": "stahnma" ··· 762 762 } 763 763 }, 764 764 { 765 - "name": "source_type", 765 + "name": "client_type", 766 766 "in": "query", 767 767 "required": false, 768 768 "schema": { "type": "string" }, 769 - "description": "Filter by source platform (e.g., irc, slack, discord)" 769 + "description": "Filter by client platform (e.g., irc, slack, discord)" 770 770 }, 771 771 { 772 - "name": "source_network", 772 + "name": "client_network", 773 773 "in": "query", 774 774 "required": false, 775 775 "schema": { "type": "string" }, 776 - "description": "Filter by source network (requires source_type)" 776 + "description": "Filter by client network (requires client_type)" 777 777 }, 778 778 { 779 - "name": "source_channel", 779 + "name": "client_channel", 780 780 "in": "query", 781 781 "required": false, 782 782 "schema": { "type": "string" }, 783 - "description": "Filter by source channel (requires source_type and source_network)" 783 + "description": "Filter by client channel (requires client_type and client_network)" 784 784 } 785 785 ], 786 786 "responses": { ··· 814 814 }, 815 815 "post": { 816 816 "summary": "Create Link", 817 - "description": "Submits a new link. Links are always created even if the URL was previously submitted. Duplicate information is included in the response. When source fields (source_type, source_network, source_channel) are provided, duplicate detection is scoped per source: a URL is only considered a duplicate if it was previously submitted from the same source.", 817 + "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.", 818 818 "tags": ["Links"], 819 819 "requestBody": { 820 820 "required": true, ··· 828 828 }, 829 829 "responses": { 830 830 "201": { 831 - "description": "Link created successfully. If duplicate detection finds a prior submission (scoped per source when source fields are provided), the response includes is_duplicate=true and previous_submissions. Returns 208 instead when the link is an exact duplicate within the same source scope.", 831 + "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. Returns 208 instead when the link is an exact duplicate within the same client scope.", 832 832 "content": { 833 833 "application/json": { 834 834 "schema": { ··· 1005 1005 } 1006 1006 }, 1007 1007 { 1008 - "name": "source_type", 1008 + "name": "client_type", 1009 1009 "in": "query", 1010 1010 "required": false, 1011 1011 "schema": { "type": "string" }, 1012 - "description": "Filter by source platform (e.g., irc, slack, discord)" 1012 + "description": "Filter by client platform (e.g., irc, slack, discord)" 1013 1013 }, 1014 1014 { 1015 - "name": "source_network", 1015 + "name": "client_network", 1016 1016 "in": "query", 1017 1017 "required": false, 1018 1018 "schema": { "type": "string" }, 1019 - "description": "Filter by source network (requires source_type)" 1019 + "description": "Filter by client network (requires client_type)" 1020 1020 }, 1021 1021 { 1022 - "name": "source_channel", 1022 + "name": "client_channel", 1023 1023 "in": "query", 1024 1024 "required": false, 1025 1025 "schema": { "type": "string" }, 1026 - "description": "Filter by source channel (requires source_type and source_network)" 1026 + "description": "Filter by client channel (requires client_type and client_network)" 1027 1027 } 1028 1028 ], 1029 1029 "responses": { ··· 1832 1832 } 1833 1833 }, 1834 1834 { 1835 - "name": "source_type", 1835 + "name": "client_type", 1836 1836 "in": "query", 1837 1837 "required": false, 1838 1838 "schema": { "type": "string" }, 1839 - "description": "Filter by source platform (e.g., irc, slack, discord)" 1839 + "description": "Filter by client platform (e.g., irc, slack, discord)" 1840 1840 }, 1841 1841 { 1842 - "name": "source_network", 1842 + "name": "client_network", 1843 1843 "in": "query", 1844 1844 "required": false, 1845 1845 "schema": { "type": "string" }, 1846 - "description": "Filter by source network (requires source_type)" 1846 + "description": "Filter by client network (requires client_type)" 1847 1847 }, 1848 1848 { 1849 - "name": "source_channel", 1849 + "name": "client_channel", 1850 1850 "in": "query", 1851 1851 "required": false, 1852 1852 "schema": { "type": "string" }, 1853 - "description": "Filter by source channel (requires source_type and source_network)" 1853 + "description": "Filter by client channel (requires client_type and client_network)" 1854 1854 } 1855 1855 ], 1856 1856 "responses": {
+55
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 strPtr(s string) *string { 54 + return &s 55 + }
+19 -19
internal/data/gorm_store.go
··· 29 29 return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}, &LinkPreview{}, &Tag{}, &ArchiveLookup{}) 30 30 } 31 31 32 - func applySourceFilter(query *gorm.DB, filter SourceFilter) *gorm.DB { 33 - if filter.SourceType != nil { 34 - query = query.Where("source_type = ?", *filter.SourceType) 32 + func applyClientFilter(query *gorm.DB, filter ClientFilter) *gorm.DB { 33 + if filter.ClientType != nil { 34 + query = query.Where("client_type = ?", *filter.ClientType) 35 35 } 36 - if filter.SourceNetwork != nil { 37 - query = query.Where("source_network = ?", *filter.SourceNetwork) 36 + if filter.ClientNetwork != nil { 37 + query = query.Where("client_network = ?", *filter.ClientNetwork) 38 38 } 39 - if filter.SourceChannel != nil { 40 - query = query.Where("source_channel = ?", *filter.SourceChannel) 39 + if filter.ClientChannel != nil { 40 + query = query.Where("client_channel = ?", *filter.ClientChannel) 41 41 } 42 42 return query 43 43 } 44 44 45 - func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]IRCLink, error) { 45 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]IRCLink, error) { 46 46 var links []IRCLink 47 47 // timestamp >= NOW() - startDays AND timestamp <= NOW() - endDays 48 48 // Note: startDays is "further back" (larger number), endDays is "closer" (smaller number) ··· 52 52 53 53 query := s.db.WithContext(ctx). 54 54 Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 55 - query = applySourceFilter(query, filter) 55 + query = applyClientFilter(query, filter) 56 56 err := query.Order("timestamp DESC"). 57 57 Find(&links).Error 58 58 return links, err 59 59 } 60 60 61 - func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]Image, error) { 61 + func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]Image, error) { 62 62 var images []Image 63 63 now := time.Now() 64 64 startDate := now.AddDate(0, 0, -startDays) ··· 66 66 67 67 query := s.db.WithContext(ctx). 68 68 Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 69 - query = applySourceFilter(query, filter) 69 + query = applyClientFilter(query, filter) 70 70 err := query.Order("timestamp DESC"). 71 71 Find(&images).Error 72 72 return images, err ··· 106 106 Delete(&Image{}).Error 107 107 } 108 108 109 - func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]Quote, error) { 109 + func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int, filter ClientFilter) ([]Quote, error) { 110 110 var quotes []Quote 111 111 now := time.Now() 112 112 startDate := now.AddDate(0, 0, -startDays) ··· 114 114 115 115 query := s.db.WithContext(ctx). 116 116 Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 117 - query = applySourceFilter(query, filter) 117 + query = applyClientFilter(query, filter) 118 118 err := query.Order("timestamp DESC"). 119 119 Find(&quotes).Error 120 120 return quotes, err 121 121 } 122 122 123 - func (s *GormStore) SearchIRCLinks(ctx context.Context, query string, filter SourceFilter) ([]IRCLink, error) { 123 + func (s *GormStore) SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) { 124 124 var links []IRCLink 125 125 // Simple LIKE search for cross-db compatibility 126 126 term := "%" + query + "%" ··· 148 148 (NOT EXISTS (SELECT 1 FROM ircLink il WHERE il.url = lp.url AND il.timestamp > ?) AND lp.updated_at > ?) 149 149 ) 150 150 )`, term, term, term, linkAgeCutoff, recentCutoff, linkAgeCutoff, oldCutoff) 151 - q = applySourceFilter(q, filter) 151 + q = applyClientFilter(q, filter) 152 152 err := q.Order("clicks DESC"). 153 153 Limit(50). 154 154 Find(&links).Error 155 155 return links, err 156 156 } 157 157 158 - func (s *GormStore) SearchQuotes(ctx context.Context, query string, filter SourceFilter) ([]Quote, error) { 158 + func (s *GormStore) SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) { 159 159 var quotes []Quote 160 160 // Simple LIKE search for cross-db compatibility 161 161 term := "%" + query + "%" 162 162 q := s.db.WithContext(ctx). 163 163 Where("quote LIKE ? OR author LIKE ? OR quoteID IN (SELECT resource_id FROM tags WHERE resource_type = 'quote' AND tag LIKE ?)", term, term, term) 164 - q = applySourceFilter(q, filter) 164 + q = applyClientFilter(q, filter) 165 165 err := q.Order("timestamp DESC"). 166 166 Limit(50). 167 167 Find(&quotes).Error ··· 201 201 return link.URL, err 202 202 } 203 203 204 - func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) { 204 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) { 205 205 var links []IRCLink 206 206 query := s.db.WithContext(ctx). 207 207 Where("url = ?", url) 208 - query = applySourceFilter(query, filter) 208 + query = applyClientFilter(query, filter) 209 209 err := query.Order("timestamp DESC"). 210 210 Find(&links).Error 211 211 return links, err
+1 -1
internal/data/image_test.go
··· 30 30 } 31 31 32 32 // Verify it's in the database via GetRecentImages 33 - images, err := store.GetRecentImages(context.Background(), 1, 0, SourceFilter{}) 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 }
+12 -12
internal/data/search_mysql_test.go
··· 59 59 t.Fatalf("InsertIRCLink failed: %v", err) 60 60 } 61 61 62 - links, err := store.SearchIRCLinks(ctx, "Golang", SourceFilter{}) 62 + links, err := store.SearchIRCLinks(ctx, "Golang", ClientFilter{}) 63 63 if err != nil { 64 64 t.Fatalf("SearchIRCLinks failed: %v", err) 65 65 } ··· 80 80 t.Fatalf("InsertIRCLink failed: %v", err) 81 81 } 82 82 83 - links, err := store.SearchIRCLinks(ctx, "unique-mysql-path", SourceFilter{}) 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 } ··· 111 111 t.Fatalf("CreateTag failed: %v", err) 112 112 } 113 113 114 - links, err := store.SearchIRCLinks(ctx, "special-mysql-topic", SourceFilter{}) 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 } ··· 132 132 t.Fatalf("InsertIRCLink failed: %v", err) 133 133 } 134 134 135 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql", SourceFilter{}) 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 } ··· 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", SourceFilter{}) 161 + links, err := store.SearchIRCLinks(ctx, "SearchM", ClientFilter{}) 162 162 if err != nil { 163 163 t.Fatalf("SearchIRCLinks failed: %v", err) 164 164 } ··· 191 191 t.Fatalf("InsertLinkPreview failed: %v", err) 192 192 } 193 193 194 - links, err := store.SearchIRCLinks(ctx, "Link", SourceFilter{}) 194 + links, err := store.SearchIRCLinks(ctx, "Link", ClientFilter{}) 195 195 if err != nil { 196 196 t.Fatalf("SearchIRCLinks failed: %v", err) 197 197 } ··· 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", SourceFilter{}) 223 + links, err := store.SearchIRCLinks(ctx, "Recoverable", ClientFilter{}) 224 224 if err != nil { 225 225 t.Fatalf("SearchIRCLinks failed: %v", err) 226 226 } ··· 247 247 t.Fatalf("InsertQuote failed: %v", err) 248 248 } 249 249 250 - quotes, err := store.SearchQuotes(ctx, "not to be", SourceFilter{}) 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 } ··· 268 268 t.Fatalf("InsertQuote failed: %v", err) 269 269 } 270 270 271 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", SourceFilter{}) 271 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", ClientFilter{}) 272 272 if err != nil { 273 273 t.Fatalf("SearchQuotes failed: %v", err) 274 274 } ··· 299 299 t.Fatalf("CreateTag failed: %v", err) 300 300 } 301 301 302 - quotes, err := store.SearchQuotes(ctx, "philosophy", SourceFilter{}) 302 + quotes, err := store.SearchQuotes(ctx, "philosophy", ClientFilter{}) 303 303 if err != nil { 304 304 t.Fatalf("SearchQuotes failed: %v", err) 305 305 } ··· 320 320 t.Fatalf("InsertQuote failed: %v", err) 321 321 } 322 322 323 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql", SourceFilter{}) 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 } ··· 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", SourceFilter{}) 350 + quotes, err := store.SearchQuotes(ctx, "SearchM", ClientFilter{}) 351 351 if err != nil { 352 352 t.Fatalf("SearchQuotes failed: %v", err) 353 353 }
+12 -12
internal/data/search_test.go
··· 21 21 t.Fatalf("InsertIRCLink failed: %v", err) 22 22 } 23 23 24 - links, err := store.SearchIRCLinks(ctx, "Golang", SourceFilter{}) 24 + links, err := store.SearchIRCLinks(ctx, "Golang", ClientFilter{}) 25 25 if err != nil { 26 26 t.Fatalf("SearchIRCLinks failed: %v", err) 27 27 } ··· 42 42 t.Fatalf("InsertIRCLink failed: %v", err) 43 43 } 44 44 45 - links, err := store.SearchIRCLinks(ctx, "unique-path", SourceFilter{}) 45 + links, err := store.SearchIRCLinks(ctx, "unique-path", ClientFilter{}) 46 46 if err != nil { 47 47 t.Fatalf("SearchIRCLinks failed: %v", err) 48 48 } ··· 73 73 t.Fatalf("CreateTag failed: %v", err) 74 74 } 75 75 76 - links, err := store.SearchIRCLinks(ctx, "special-topic", SourceFilter{}) 76 + links, err := store.SearchIRCLinks(ctx, "special-topic", ClientFilter{}) 77 77 if err != nil { 78 78 t.Fatalf("SearchIRCLinks failed: %v", err) 79 79 } ··· 94 94 t.Fatalf("InsertIRCLink failed: %v", err) 95 95 } 96 96 97 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy", SourceFilter{}) 97 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy", ClientFilter{}) 98 98 if err != nil { 99 99 t.Fatalf("SearchIRCLinks failed: %v", err) 100 100 } ··· 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", SourceFilter{}) 124 + links, err := store.SearchIRCLinks(ctx, "Search", ClientFilter{}) 125 125 if err != nil { 126 126 t.Fatalf("SearchIRCLinks failed: %v", err) 127 127 } ··· 156 156 t.Fatalf("InsertLinkPreview failed: %v", err) 157 157 } 158 158 159 - links, err := store.SearchIRCLinks(ctx, "Link", SourceFilter{}) 159 + links, err := store.SearchIRCLinks(ctx, "Link", ClientFilter{}) 160 160 if err != nil { 161 161 t.Fatalf("SearchIRCLinks failed: %v", err) 162 162 } ··· 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", SourceFilter{}) 191 + links, err := store.SearchIRCLinks(ctx, "Recoverable", ClientFilter{}) 192 192 if err != nil { 193 193 t.Fatalf("SearchIRCLinks failed: %v", err) 194 194 } ··· 215 215 t.Fatalf("InsertQuote failed: %v", err) 216 216 } 217 217 218 - quotes, err := store.SearchQuotes(ctx, "not to be", SourceFilter{}) 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 } ··· 236 236 t.Fatalf("InsertQuote failed: %v", err) 237 237 } 238 238 239 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", SourceFilter{}) 239 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", ClientFilter{}) 240 240 if err != nil { 241 241 t.Fatalf("SearchQuotes failed: %v", err) 242 242 } ··· 267 267 t.Fatalf("CreateTag failed: %v", err) 268 268 } 269 269 270 - quotes, err := store.SearchQuotes(ctx, "philosophy", SourceFilter{}) 270 + quotes, err := store.SearchQuotes(ctx, "philosophy", ClientFilter{}) 271 271 if err != nil { 272 272 t.Fatalf("SearchQuotes failed: %v", err) 273 273 } ··· 288 288 t.Fatalf("InsertQuote failed: %v", err) 289 289 } 290 290 291 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy", SourceFilter{}) 291 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy", ClientFilter{}) 292 292 if err != nil { 293 293 t.Fatalf("SearchQuotes failed: %v", err) 294 294 } ··· 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", SourceFilter{}) 319 + quotes, err := store.SearchQuotes(ctx, "Search", ClientFilter{}) 320 320 if err != nil { 321 321 t.Fatalf("SearchQuotes failed: %v", err) 322 322 }
-55
internal/data/source_filter_test.go
··· 1 - package data 2 - 3 - import "testing" 4 - 5 - func TestSourceFilter_IsEmpty(t *testing.T) { 6 - tests := []struct { 7 - name string 8 - filter SourceFilter 9 - expected bool 10 - }{ 11 - { 12 - name: "all nil fields", 13 - filter: SourceFilter{}, 14 - expected: true, 15 - }, 16 - { 17 - name: "only SourceType set", 18 - filter: SourceFilter{SourceType: strPtr("irc")}, 19 - expected: false, 20 - }, 21 - { 22 - name: "only SourceNetwork set", 23 - filter: SourceFilter{SourceNetwork: strPtr("libera")}, 24 - expected: false, 25 - }, 26 - { 27 - name: "only SourceChannel set", 28 - filter: SourceFilter{SourceChannel: strPtr("#general")}, 29 - expected: false, 30 - }, 31 - { 32 - name: "all fields set", 33 - filter: SourceFilter{SourceType: strPtr("irc"), SourceNetwork: strPtr("libera"), SourceChannel: strPtr("#general")}, 34 - expected: false, 35 - }, 36 - { 37 - name: "two fields set", 38 - filter: SourceFilter{SourceType: strPtr("slack"), SourceNetwork: 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("SourceFilter.IsEmpty() = %v, want %v", got, tt.expected) 48 - } 49 - }) 50 - } 51 - } 52 - 53 - func strPtr(s string) *string { 54 - return &s 55 - }
+32 -32
internal/data/store.go
··· 13 13 URL string `json:"url" gorm:"column:url"` 14 14 Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 15 15 ContentType string `json:"content_type" gorm:"column:content_type"` 16 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_link_source,priority:1"` 17 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_link_source,priority:2"` 18 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_link_source,priority:3"` 19 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 20 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 16 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_link_client,priority:1"` 17 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_link_client,priority:2"` 18 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_link_client,priority:3"` 19 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 20 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 21 21 } 22 22 23 23 // TableName overrides the table name used by User to `ircLink` ··· 32 32 Link string `json:"link" gorm:"column:link"` 33 33 URL string `json:"url" gorm:"column:url"` 34 34 MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 35 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_image_source,priority:1"` 36 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_image_source,priority:2"` 37 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_image_source,priority:3"` 38 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 39 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 35 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_image_client,priority:1"` 36 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_image_client,priority:2"` 37 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_image_client,priority:3"` 38 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 39 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 40 40 } 41 41 42 42 // TableName overrides the table name used by User to `image` ··· 50 50 Quote string `json:"quote" gorm:"column:quote"` 51 51 Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 52 52 Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 53 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_quote_source,priority:1"` 54 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_quote_source,priority:2"` 55 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_quote_source,priority:3"` 56 - SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 57 - SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 53 + ClientType *string `json:"client_type,omitempty" gorm:"column:client_type;type:varchar(50);index:idx_quote_client,priority:1"` 54 + ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network;type:varchar(255);index:idx_quote_client,priority:2"` 55 + ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel;type:varchar(255);index:idx_quote_client,priority:3"` 56 + ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id;type:varchar(255)"` 57 + ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name;type:varchar(255)"` 58 58 } 59 59 60 60 // TableName overrides the table name used by User to `quote` ··· 78 78 Author string `json:"author"` // For quotes (and links/images as User) 79 79 MD5Sum string `json:"md5sum"` // For images 80 80 ContentType string `json:"contentType" gorm:"column:content_type"` // For links (to detect images) 81 - SourceType *string `json:"source_type,omitempty"` 82 - SourceNetwork *string `json:"source_network,omitempty"` 83 - SourceChannel *string `json:"source_channel,omitempty"` 84 - SourceUserID *string `json:"source_user_id,omitempty"` 85 - SourceUserName *string `json:"source_user_name,omitempty"` 81 + ClientType *string `json:"client_type,omitempty"` 82 + ClientNetwork *string `json:"client_network,omitempty"` 83 + ClientChannel *string `json:"client_channel,omitempty"` 84 + ClientUserID *string `json:"client_user_id,omitempty"` 85 + ClientUserName *string `json:"client_user_name,omitempty"` 86 86 } 87 87 88 - type SourceFilter struct { 89 - SourceType *string 90 - SourceNetwork *string 91 - SourceChannel *string 88 + type ClientFilter struct { 89 + ClientType *string 90 + ClientNetwork *string 91 + ClientChannel *string 92 92 } 93 93 94 - func (f SourceFilter) IsEmpty() bool { 95 - return f.SourceType == nil && f.SourceNetwork == nil && f.SourceChannel == nil 94 + func (f ClientFilter) IsEmpty() bool { 95 + return f.ClientType == nil && f.ClientNetwork == nil && f.ClientChannel == nil 96 96 } 97 97 98 98 type Tag struct { ··· 134 134 } 135 135 136 136 type Store interface { 137 - GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]IRCLink, error) 138 - GetRecentImages(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Image, error) 139 - GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Quote, error) 137 + GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]IRCLink, error) 138 + GetRecentImages(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Image, error) 139 + GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter ClientFilter) ([]Quote, error) 140 140 141 - SearchIRCLinks(ctx context.Context, query string, filter SourceFilter) ([]IRCLink, error) 142 - SearchQuotes(ctx context.Context, query string, filter SourceFilter) ([]Quote, error) 141 + SearchIRCLinks(ctx context.Context, query string, filter ClientFilter) ([]IRCLink, error) 142 + SearchQuotes(ctx context.Context, query string, filter ClientFilter) ([]Quote, error) 143 143 GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) 144 144 GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) 145 145 GetIRCLinkURL(ctx context.Context, id int) (string, error) 146 - GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) 146 + GetIRCLinksByURL(ctx context.Context, url string, filter ClientFilter) ([]IRCLink, error) 147 147 IncrementClicks(ctx context.Context, id int) error 148 148 InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) 149 149 DeleteIRCLink(ctx context.Context, id int) error
+5 -5
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, filter data.SourceFilter) ([]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, filter data.SourceFilter) ([]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 } ··· 76 76 return m.err 77 77 } 78 78 79 - func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]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 } ··· 114 114 return m.userStats, nil 115 115 } 116 116 117 - func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string, filter data.SourceFilter) ([]data.IRCLink, error) { 117 + func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string, filter data.ClientFilter) ([]data.IRCLink, error) { 118 118 if m.err != nil { 119 119 return nil, m.err 120 120 } 121 121 return m.searchLinks, nil 122 122 } 123 123 124 - func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string, filter data.SourceFilter) ([]data.Quote, error) { 124 + func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string, filter data.ClientFilter) ([]data.Quote, error) { 125 125 if m.err != nil { 126 126 return nil, m.err 127 127 }
+40 -40
internal/handler/api_v1_links.go
··· 70 70 limit := parseIntParam(r, "limit", 50, 1000) 71 71 offset := parseIntParam(r, "offset", 0, 1000000) 72 72 73 - // Parse source filter query params 74 - var sourceFilter data.SourceFilter 75 - if st := r.URL.Query().Get("source_type"); st != "" { 76 - sourceFilter.SourceType = &st 73 + // Parse client filter query params 74 + var clientFilter data.ClientFilter 75 + if st := r.URL.Query().Get("client_type"); st != "" { 76 + clientFilter.ClientType = &st 77 77 } 78 - if sn := r.URL.Query().Get("source_network"); sn != "" { 79 - sourceFilter.SourceNetwork = &sn 78 + if sn := r.URL.Query().Get("client_network"); sn != "" { 79 + clientFilter.ClientNetwork = &sn 80 80 } 81 - if sc := r.URL.Query().Get("source_channel"); sc != "" { 82 - sourceFilter.SourceChannel = &sc 81 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 82 + clientFilter.ClientChannel = &sc 83 83 } 84 84 85 85 // Fetch all links from the last year 86 86 // We fetch more than needed so we can paginate in-memory 87 - links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, sourceFilter) 87 + links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, clientFilter) 88 88 if err != nil { 89 89 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 90 90 return ··· 115 115 Clicks: link.Clicks, 116 116 CreatedAt: link.Timestamp, 117 117 Tags: h.getTagStrings(ctx, "link", link.ID), 118 - SourceType: link.SourceType, 119 - SourceNetwork: link.SourceNetwork, 120 - SourceChannel: link.SourceChannel, 121 - SourceUserID: link.SourceUserID, 122 - SourceUserName: link.SourceUserName, 118 + ClientType: link.ClientType, 119 + ClientNetwork: link.ClientNetwork, 120 + ClientChannel: link.ClientChannel, 121 + ClientUserID: link.ClientUserID, 122 + ClientUserName: link.ClientUserName, 123 123 }) 124 124 } 125 125 ··· 140 140 URL string `json:"url"` 141 141 User string `json:"user"` 142 142 Tags []string `json:"tags,omitempty"` 143 - SourceType *string `json:"source_type,omitempty"` 144 - SourceNetwork *string `json:"source_network,omitempty"` 145 - SourceChannel *string `json:"source_channel,omitempty"` 146 - SourceUserID *string `json:"source_user_id,omitempty"` 147 - SourceUserName *string `json:"source_user_name,omitempty"` 143 + ClientType *string `json:"client_type,omitempty"` 144 + ClientNetwork *string `json:"client_network,omitempty"` 145 + ClientChannel *string `json:"client_channel,omitempty"` 146 + ClientUserID *string `json:"client_user_id,omitempty"` 147 + ClientUserName *string `json:"client_user_name,omitempty"` 148 148 } 149 149 150 150 // apiV1CreateLink handles POST /api/v1/links ··· 174 174 return 175 175 } 176 176 177 - // Check for duplicates (scoped by source if provided) 178 - sourceFilter := data.SourceFilter{ 179 - SourceType: req.SourceType, 180 - SourceNetwork: req.SourceNetwork, 181 - SourceChannel: req.SourceChannel, 177 + // Check for duplicates (scoped by client if provided) 178 + clientFilter := data.ClientFilter{ 179 + ClientType: req.ClientType, 180 + ClientNetwork: req.ClientNetwork, 181 + ClientChannel: req.ClientChannel, 182 182 } 183 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, sourceFilter) 183 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, clientFilter) 184 184 if err != nil { 185 185 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to check for duplicates") 186 186 return ··· 192 192 Title: req.URL, 193 193 URL: req.URL, 194 194 ContentType: "", 195 - SourceType: req.SourceType, 196 - SourceNetwork: req.SourceNetwork, 197 - SourceChannel: req.SourceChannel, 198 - SourceUserID: req.SourceUserID, 199 - SourceUserName: req.SourceUserName, 195 + ClientType: req.ClientType, 196 + ClientNetwork: req.ClientNetwork, 197 + ClientChannel: req.ClientChannel, 198 + ClientUserID: req.ClientUserID, 199 + ClientUserName: req.ClientUserName, 200 200 }) 201 201 if err != nil { 202 202 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create link") ··· 250 250 Clicks: 0, 251 251 CreatedAt: time.Now(), 252 252 Tags: tagStrings, 253 - SourceType: req.SourceType, 254 - SourceNetwork: req.SourceNetwork, 255 - SourceChannel: req.SourceChannel, 256 - SourceUserID: req.SourceUserID, 257 - SourceUserName: req.SourceUserName, 253 + ClientType: req.ClientType, 254 + ClientNetwork: req.ClientNetwork, 255 + ClientChannel: req.ClientChannel, 256 + ClientUserID: req.ClientUserID, 257 + ClientUserName: req.ClientUserName, 258 258 }, 259 259 IsDuplicate: isDuplicate, 260 260 PreviousSubmissions: previousSubmissions, ··· 293 293 Clicks: link.Clicks, 294 294 CreatedAt: link.Timestamp, 295 295 Tags: h.getTagStrings(ctx, "link", link.ID), 296 - SourceType: link.SourceType, 297 - SourceNetwork: link.SourceNetwork, 298 - SourceChannel: link.SourceChannel, 299 - SourceUserID: link.SourceUserID, 300 - SourceUserName: link.SourceUserName, 296 + ClientType: link.ClientType, 297 + ClientNetwork: link.ClientNetwork, 298 + ClientChannel: link.ClientChannel, 299 + ClientUserID: link.ClientUserID, 300 + ClientUserName: link.ClientUserName, 301 301 }) 302 302 } 303 303
+34 -34
internal/handler/api_v1_quotes.go
··· 70 70 limit := parseIntParam(r, "limit", 50, 1000) 71 71 offset := parseIntParam(r, "offset", 0, 1000000) 72 72 73 - // Parse source filter query params 74 - var sourceFilter data.SourceFilter 75 - if st := r.URL.Query().Get("source_type"); st != "" { 76 - sourceFilter.SourceType = &st 73 + // Parse client filter query params 74 + var clientFilter data.ClientFilter 75 + if st := r.URL.Query().Get("client_type"); st != "" { 76 + clientFilter.ClientType = &st 77 77 } 78 - if sn := r.URL.Query().Get("source_network"); sn != "" { 79 - sourceFilter.SourceNetwork = &sn 78 + if sn := r.URL.Query().Get("client_network"); sn != "" { 79 + clientFilter.ClientNetwork = &sn 80 80 } 81 - if sc := r.URL.Query().Get("source_channel"); sc != "" { 82 - sourceFilter.SourceChannel = &sc 81 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 82 + clientFilter.ClientChannel = &sc 83 83 } 84 84 85 85 // Fetch all quotes from the last year 86 86 // We fetch more than needed so we can paginate in-memory 87 - quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0, sourceFilter) 87 + quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0, clientFilter) 88 88 if err != nil { 89 89 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 90 90 return ··· 114 114 Poster: quote.Poster, 115 115 CreatedAt: quote.Timestamp, 116 116 Tags: h.getTagStrings(ctx, "quote", quote.ID), 117 - SourceType: quote.SourceType, 118 - SourceNetwork: quote.SourceNetwork, 119 - SourceChannel: quote.SourceChannel, 120 - SourceUserID: quote.SourceUserID, 121 - SourceUserName: quote.SourceUserName, 117 + ClientType: quote.ClientType, 118 + ClientNetwork: quote.ClientNetwork, 119 + ClientChannel: quote.ClientChannel, 120 + ClientUserID: quote.ClientUserID, 121 + ClientUserName: quote.ClientUserName, 122 122 }) 123 123 } 124 124 ··· 140 140 Author string `json:"author"` 141 141 Poster string `json:"poster"` 142 142 Tags []string `json:"tags,omitempty"` 143 - SourceType *string `json:"source_type,omitempty"` 144 - SourceNetwork *string `json:"source_network,omitempty"` 145 - SourceChannel *string `json:"source_channel,omitempty"` 146 - SourceUserID *string `json:"source_user_id,omitempty"` 147 - SourceUserName *string `json:"source_user_name,omitempty"` 143 + ClientType *string `json:"client_type,omitempty"` 144 + ClientNetwork *string `json:"client_network,omitempty"` 145 + ClientChannel *string `json:"client_channel,omitempty"` 146 + ClientUserID *string `json:"client_user_id,omitempty"` 147 + ClientUserName *string `json:"client_user_name,omitempty"` 148 148 } 149 149 150 150 // apiV1CreateQuote handles POST /api/v1/quotes ··· 174 174 Quote: req.Quote, 175 175 Author: req.Author, 176 176 Poster: req.Poster, 177 - SourceType: req.SourceType, 178 - SourceNetwork: req.SourceNetwork, 179 - SourceChannel: req.SourceChannel, 180 - SourceUserID: req.SourceUserID, 181 - SourceUserName: req.SourceUserName, 177 + ClientType: req.ClientType, 178 + ClientNetwork: req.ClientNetwork, 179 + ClientChannel: req.ClientChannel, 180 + ClientUserID: req.ClientUserID, 181 + ClientUserName: req.ClientUserName, 182 182 }) 183 183 if err != nil { 184 184 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create quote") ··· 218 218 Poster: req.Poster, 219 219 CreatedAt: time.Now(), 220 220 Tags: tagStrings, 221 - SourceType: req.SourceType, 222 - SourceNetwork: req.SourceNetwork, 223 - SourceChannel: req.SourceChannel, 224 - SourceUserID: req.SourceUserID, 225 - SourceUserName: req.SourceUserName, 221 + ClientType: req.ClientType, 222 + ClientNetwork: req.ClientNetwork, 223 + ClientChannel: req.ClientChannel, 224 + ClientUserID: req.ClientUserID, 225 + ClientUserName: req.ClientUserName, 226 226 } 227 227 228 228 writeJSON(w, http.StatusCreated, resp) ··· 261 261 Poster: quote.Poster, 262 262 CreatedAt: quote.Timestamp, 263 263 Tags: h.getTagStrings(ctx, "quote", quote.ID), 264 - SourceType: quote.SourceType, 265 - SourceNetwork: quote.SourceNetwork, 266 - SourceChannel: quote.SourceChannel, 267 - SourceUserID: quote.SourceUserID, 268 - SourceUserName: quote.SourceUserName, 264 + ClientType: quote.ClientType, 265 + ClientNetwork: quote.ClientNetwork, 266 + ClientChannel: quote.ClientChannel, 267 + ClientUserID: quote.ClientUserID, 268 + ClientUserName: quote.ClientUserName, 269 269 }) 270 270 } 271 271
+1 -1
internal/handler/api_v1_quotes_test.go
··· 25 25 err error 26 26 } 27 27 28 - func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.Quote, error) { 28 + func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.Quote, error) { 29 29 if m.err != nil { 30 30 return nil, m.err 31 31 }
+20 -20
internal/handler/api_v1_search.go
··· 63 63 limit := parseIntParam(r, "limit", 50, 1000) 64 64 offset := parseIntParam(r, "offset", 0, 1000000) 65 65 66 - // Parse source filter query params 67 - var sourceFilter data.SourceFilter 68 - if st := r.URL.Query().Get("source_type"); st != "" { 69 - sourceFilter.SourceType = &st 66 + // Parse client filter query params 67 + var clientFilter data.ClientFilter 68 + if st := r.URL.Query().Get("client_type"); st != "" { 69 + clientFilter.ClientType = &st 70 70 } 71 - if sn := r.URL.Query().Get("source_network"); sn != "" { 72 - sourceFilter.SourceNetwork = &sn 71 + if sn := r.URL.Query().Get("client_network"); sn != "" { 72 + clientFilter.ClientNetwork = &sn 73 73 } 74 - if sc := r.URL.Query().Get("source_channel"); sc != "" { 75 - sourceFilter.SourceChannel = &sc 74 + if sc := r.URL.Query().Get("client_channel"); sc != "" { 75 + clientFilter.ClientChannel = &sc 76 76 } 77 77 78 78 // Initialize response ··· 92 92 93 93 // Search links if requested 94 94 if searchLinks { 95 - links, err := h.Store.SearchIRCLinks(ctx, query, sourceFilter) 95 + links, err := h.Store.SearchIRCLinks(ctx, query, clientFilter) 96 96 if err != nil { 97 97 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search links") 98 98 return ··· 122 122 User: link.User, 123 123 Clicks: link.Clicks, 124 124 CreatedAt: link.Timestamp, 125 - SourceType: link.SourceType, 126 - SourceNetwork: link.SourceNetwork, 127 - SourceChannel: link.SourceChannel, 128 - SourceUserID: link.SourceUserID, 129 - SourceUserName: link.SourceUserName, 125 + ClientType: link.ClientType, 126 + ClientNetwork: link.ClientNetwork, 127 + ClientChannel: link.ClientChannel, 128 + ClientUserID: link.ClientUserID, 129 + ClientUserName: link.ClientUserName, 130 130 }) 131 131 } 132 132 } 133 133 134 134 // Search quotes if requested 135 135 if searchQuotes { 136 - quotes, err := h.Store.SearchQuotes(ctx, query, sourceFilter) 136 + quotes, err := h.Store.SearchQuotes(ctx, query, clientFilter) 137 137 if err != nil { 138 138 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search quotes") 139 139 return ··· 162 162 Author: quote.Author, 163 163 Poster: quote.Poster, 164 164 CreatedAt: quote.Timestamp, 165 - SourceType: quote.SourceType, 166 - SourceNetwork: quote.SourceNetwork, 167 - SourceChannel: quote.SourceChannel, 168 - SourceUserID: quote.SourceUserID, 169 - SourceUserName: quote.SourceUserName, 165 + ClientType: quote.ClientType, 166 + ClientNetwork: quote.ClientNetwork, 167 + ClientChannel: quote.ClientChannel, 168 + ClientUserID: quote.ClientUserID, 169 + ClientUserName: quote.ClientUserName, 170 170 }) 171 171 } 172 172 }
+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, filter data.SourceFilter) ([]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, filter data.SourceFilter) ([]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 }
+2 -2
internal/handler/api_v1_stats.go
··· 62 62 } 63 63 64 64 // Get total links and quotes for site stats 65 - links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0, data.SourceFilter{}) // ~100 years to get all 65 + links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0, data.ClientFilter{}) // ~100 years to get all 66 66 if err != nil { 67 67 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 68 68 return 69 69 } 70 70 71 - quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0, data.SourceFilter{}) // ~100 years to get all 71 + quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0, data.ClientFilter{}) // ~100 years to get all 72 72 if err != nil { 73 73 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 74 74 return
+2 -2
internal/handler/api_v1_stats_test.go
··· 31 31 return m.userStats, nil 32 32 } 33 33 34 - func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.IRCLink, error) { 34 + func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.IRCLink, error) { 35 35 if m.err != nil { 36 36 return nil, m.err 37 37 } 38 38 return m.links, nil 39 39 } 40 40 41 - func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.Quote, error) { 41 + func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.Quote, error) { 42 42 if m.err != nil { 43 43 return nil, m.err 44 44 }
+10 -10
internal/handler/api_v1_types.go
··· 32 32 Clicks int `json:"clicks"` 33 33 CreatedAt time.Time `json:"created_at"` 34 34 Tags []string `json:"tags,omitempty"` 35 - SourceType *string `json:"source_type,omitempty"` 36 - SourceNetwork *string `json:"source_network,omitempty"` 37 - SourceChannel *string `json:"source_channel,omitempty"` 38 - SourceUserID *string `json:"source_user_id,omitempty"` 39 - SourceUserName *string `json:"source_user_name,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"` 40 40 } 41 41 42 42 // APIPreviousSubmission contains information about a previous submission ··· 69 69 Poster string `json:"poster,omitempty"` 70 70 CreatedAt time.Time `json:"created_at"` 71 71 Tags []string `json:"tags,omitempty"` 72 - SourceType *string `json:"source_type,omitempty"` 73 - SourceNetwork *string `json:"source_network,omitempty"` 74 - SourceChannel *string `json:"source_channel,omitempty"` 75 - SourceUserID *string `json:"source_user_id,omitempty"` 76 - SourceUserName *string `json:"source_user_name,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"` 77 77 } 78 78 79 79 // APIQuotesResponse is the paginated response for a list of quotes.
+4 -4
internal/handler/handlers.go
··· 260 260 wg.Add(3) 261 261 go func() { 262 262 defer wg.Done() 263 - ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays, data.SourceFilter{}) 263 + ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays, data.ClientFilter{}) 264 264 }() 265 265 go func() { 266 266 defer wg.Done() 267 - images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays, data.SourceFilter{}) 267 + images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays, data.ClientFilter{}) 268 268 }() 269 269 go func() { 270 270 defer wg.Done() 271 - quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays, data.SourceFilter{}) 271 + quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays, data.ClientFilter{}) 272 272 }() 273 273 wg.Wait() 274 274 } ··· 547 547 return 548 548 } 549 549 550 - links, err := h.Store.SearchIRCLinks(ctx, query, data.SourceFilter{}) 550 + links, err := h.Store.SearchIRCLinks(ctx, query, data.ClientFilter{}) 551 551 if err != nil { 552 552 h.ServerError(w, r, err) 553 553 return
+1 -1
internal/handler/irclink.go
··· 97 97 98 98 // Handle link posting 99 99 // Check for existing submissions first 100 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url, data.SourceFilter{}) 100 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url, data.ClientFilter{}) 101 101 if err != nil { 102 102 h.ServerError(w, r, err) 103 103 return
+1 -1
internal/handler/preview.go
··· 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, data.SourceFilter{}); 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 }
+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;
-24
sql/backfill_sources.sql
··· 1 - -- One-time backfill: set source metadata on all existing rows. 2 - -- All existing data originates from IRC, #soggies channel on jameswhite.org. 3 - -- Run this manually after deploying the source fields migration. 4 - -- 5 - -- Usage (SQLite): sqlite3 tumble.db < sql/backfill_sources.sql 6 - -- Usage (MySQL): mysql -u user -p tumble < sql/backfill_sources.sql 7 - 8 - UPDATE ircLink 9 - SET source_type = 'irc', 10 - source_network = 'jameswhite.org', 11 - source_channel = '#soggies' 12 - WHERE source_type IS NULL; 13 - 14 - UPDATE quote 15 - SET source_type = 'irc', 16 - source_network = 'jameswhite.org', 17 - source_channel = '#soggies' 18 - WHERE source_type IS NULL; 19 - 20 - UPDATE image 21 - SET source_type = 'irc', 22 - source_network = 'jameswhite.org', 23 - source_channel = '#soggies' 24 - WHERE source_type IS NULL;