diff --git a/README.md b/README.md index cbbcc8e..6d01b6b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ own their Kick chat archive instead of depending on short-lived browser chat his - Infinite-scroll message results with reply context, clickable links, and inline emotes. - Public user and channel profile pages with activity summaries. - Prediction pages that run client-side and do not store prediction data. -- Admin dashboard for followed channels, users, listener health, storage, and cleanup previews. +- Public request form for channel suggestions and feedback, reviewed from the admin dashboard. +- Admin dashboard for followed channels, requests, users, listener health, storage, and cleanup previews. - Durable ingestion: raw events pass through NATS JetStream before ClickHouse normalization. - Docker Compose runtime with Go, Next.js, NATS JetStream, ClickHouse, and SQLite. @@ -76,7 +77,8 @@ The app is organized around: message volume. - **Prediction:** a public channel prediction view that fetches live Kick prediction data in the browser without storing it. -- **Admin:** a protected dashboard for managing followed channels and watching ingestion health. +- **Requests:** a public form for channel suggestions and feedback. +- **Admin:** a protected dashboard for managing followed channels, requests, and ingestion health. The default stack stores high-volume chat data in ClickHouse and keeps control-plane data such as admins, followed channels, sender profiles, retention settings, and heartbeats in SQLite. diff --git a/apps/api-go/cmd/api/main.go b/apps/api-go/cmd/api/main.go index 7a0974a..d118659 100644 --- a/apps/api-go/cmd/api/main.go +++ b/apps/api-go/cmd/api/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -22,15 +23,19 @@ import ( "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/natsstream" operationsinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/operations" ratelimitinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/ratelimit" + "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/snapshots" sqliteinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/sqlite" "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" analyticsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/analytics" authusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/auth" channelsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/channels" datamanagementusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/data_management" + directoryusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/directory" + homepageusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/homepage" kicksyncusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/kicksync" messagesusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/messages" profilesusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/profiles" + requestsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/requests" webhookprocessorusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/webhookprocessor" ) @@ -146,16 +151,21 @@ func main() { } var messageService *messagesusecase.Service var analyticsService *analyticsusecase.Service + var homepageRepository ports.AnalyticsRepository var profileService *profilesusecase.Service + var requestService *requestsusecase.Service var subPeriodRepoForAPI ports.SubscriptionPeriodRepository if clickHouseConn != nil { messageRepository := clickhouseinfra.NewMessageRepository(clickHouseConn) analyticsRepository := clickhouseinfra.NewAnalyticsRepository(clickHouseConn) subPeriodRepo := clickhouseinfra.NewSubscriptionPeriodRepository(clickHouseConn) + userRequestRepo := clickhouseinfra.NewUserRequestRepository(clickHouseConn) subPeriodRepoForAPI = subPeriodRepo messageService = messagesusecase.NewService(messageRepository) analyticsService = analyticsusecase.NewService(analyticsRepository) + homepageRepository = clickhouseinfra.NewHomepageAnalyticsRepository(clickHouseConn) profileService = profilesusecase.NewService(analyticsRepository, channelRepo, senderRepo) + requestService = requestsusecase.NewService(userRequestRepo) processorSvc := webhookprocessorusecase.NewService( logger, @@ -167,6 +177,21 @@ func main() { ) processorSvc.Start(context.Background()) } + homepageService := homepageusecase.NewService( + homepageRepository, + snapshots.NewHomepageStore(filepath.Join(filepath.Dir(cfg.SQLitePath), "homepage-analytics-v1.json")), + logger, + ) + homepageCtx, cancelHomepage := context.WithCancel(context.Background()) + homepageDone := make(chan struct{}) + go func() { + defer close(homepageDone) + homepageService.Run(homepageCtx) + }() + defer func() { + cancelHomepage() + <-homepageDone + }() operationsRepo := operationsinfra.NewRepository( sqliteDB, cfg.SQLitePath, @@ -181,11 +206,14 @@ func main() { Config: cfg, Auth: authService, Analytics: analyticsService, + Homepage: homepageService, Channels: channelService, Messages: messageService, Profiles: profileService, Data: dataManagementService, + Directory: directoryusecase.NewService(sqliteinfra.NewDirectoryRepository(sqliteDB)), KickSync: kickSyncService, + Requests: requestService, WebhookEvents: webhookEventRepo, WebhookVerifier: webhookVerifier, WebhookEventSubs: eventSubRepo, diff --git a/apps/api-go/internal/domain/directory.go b/apps/api-go/internal/domain/directory.go new file mode 100644 index 0000000..4edb63a --- /dev/null +++ b/apps/api-go/internal/domain/directory.go @@ -0,0 +1,32 @@ +package domain + +type DirectoryKind string + +const ( + DirectoryUsers DirectoryKind = "users" + DirectoryChannels DirectoryKind = "channels" +) + +type DirectoryIdentity struct { + ID int64 + Name string + Slug string + ProfileImageURL string + SortSlug string +} + +type DirectoryPosition struct { + Slug string + ID int64 +} + +type DirectoryQuery struct { + Prefix string + Limit int + After DirectoryPosition +} + +type DirectoryPage struct { + Items []DirectoryIdentity + NextCursor string +} diff --git a/apps/api-go/internal/domain/homepage.go b/apps/api-go/internal/domain/homepage.go new file mode 100644 index 0000000..9aa9021 --- /dev/null +++ b/apps/api-go/internal/domain/homepage.go @@ -0,0 +1,16 @@ +package domain + +import "time" + +// HomepageSnapshot is a disposable read model, never the source of message history. +type HomepageSnapshot struct { + Version int + AsOf time.Time + Start time.Time + End time.Time + Overview AnalyticsOverview + Volume []MessageVolumePoint + TopChannels []TopChannelAnalytics + TopSenders []TopSenderAnalytics + TopEmotes []TopEmoteAnalytics +} diff --git a/apps/api-go/internal/domain/models.go b/apps/api-go/internal/domain/models.go index 9e1d0a7..eaebf51 100644 --- a/apps/api-go/internal/domain/models.go +++ b/apps/api-go/internal/domain/models.go @@ -549,3 +549,103 @@ type ChannelSubscriptionSummary struct { ActiveGiftedCount int64 LatestEventAt time.Time } + +type ChannelSubscriberFilter struct { + FollowedChannelID int64 + GiftOnly bool + Limit uint64 + Offset uint64 +} + +type ChannelSubscriber struct { + SubscriberKickUserID int64 + Username string + Slug string + ProfileImageURL string + IsGift bool + GifterKickUserID int64 + GifterUsername string + GifterSlug string + GifterProfileImageURL string + StartedAt time.Time + ExpiresAt time.Time +} + +type ChannelSubscriberPage struct { + Items []ChannelSubscriber + Count int64 + Limit uint64 + Offset uint64 +} + +type UserRequestType string + +const ( + UserRequestTypeChannelRequest UserRequestType = "channel_request" + UserRequestTypeFeedback UserRequestType = "feedback" +) + +type UserRequestStatus string + +const ( + UserRequestStatusNew UserRequestStatus = "new" + UserRequestStatusReviewing UserRequestStatus = "reviewing" + UserRequestStatusApproved UserRequestStatus = "approved" + UserRequestStatusRejected UserRequestStatus = "rejected" + UserRequestStatusDone UserRequestStatus = "done" + UserRequestStatusDuplicate UserRequestStatus = "duplicate" +) + +type UserRequestEventType string + +const ( + UserRequestEventStatusChanged UserRequestEventType = "status_changed" + UserRequestEventNoteAdded UserRequestEventType = "note_added" + UserRequestEventArchived UserRequestEventType = "archived" +) + +type UserRequest struct { + ID string + Type UserRequestType + Title string + Message string + ChannelSlug string + ChannelDisplayName string + Contact string + IPHash string + UserAgentHash string + CreatedAt time.Time +} + +type UserRequestEvent struct { + ID string + RequestID string + EventType UserRequestEventType + Status UserRequestStatus + Note string + AdminID int64 + CreatedAt time.Time +} + +type UserRequestState struct { + Request UserRequest + CurrentStatus UserRequestStatus + IsArchived bool + LatestEventAt time.Time +} + +type UserRequestDetail struct { + State UserRequestState + Events []UserRequestEvent +} + +type UserRequestListFilter struct { + Type UserRequestType + Status UserRequestStatus + Archived *bool + Query string + Start time.Time + End time.Time + Limit uint64 + Offset uint64 +} diff --git a/apps/api-go/internal/http/analytics_profile_routes_test.go b/apps/api-go/internal/http/analytics_profile_routes_test.go index 763fa3a..edeb165 100644 --- a/apps/api-go/internal/http/analytics_profile_routes_test.go +++ b/apps/api-go/internal/http/analytics_profile_routes_test.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "database/sql" + "encoding/json" "errors" "io" "log/slog" @@ -130,6 +131,36 @@ func TestProfileRoutesCacheChannelAnalyticsBySlug(t *testing.T) { } } +func TestProfileRoutesReturnFourteenDayVolumeSeries(t *testing.T) { + handler, analyticsRepo := newAnalyticsProfileTestRouterWithRepo() + + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/channels/hype/analytics", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", response.Code, response.Body.String()) + } + + var payload struct { + MessageVolume []struct { + BucketStart string `json:"bucket_start"` + MessageCount int64 `json:"message_count"` + } `json:"message_volume"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.MessageVolume) != 14 { + t.Fatalf("message volume length = %d body = %s", len(payload.MessageVolume), response.Body.String()) + } + if len(analyticsRepo.volumeFilters) == 0 { + t.Fatal("expected profile route to request message volume") + } + filter := analyticsRepo.volumeFilters[len(analyticsRepo.volumeFilters)-1] + if filter.Start.IsZero() || filter.End.IsZero() { + t.Fatalf("volume filter = %#v", filter) + } +} + func newAnalyticsProfileTestRouter() http.Handler { handler, _ := newAnalyticsProfileTestRouterWithRepo() return handler @@ -152,6 +183,7 @@ type fakeAnalyticsRepository struct { now time.Time topEmotesErr error topEmotesCalls int + volumeFilters []domain.AnalyticsFilter } func newFakeAnalyticsRepository() *fakeAnalyticsRepository { @@ -174,9 +206,10 @@ func (repo *fakeAnalyticsRepository) Overview( func (repo *fakeAnalyticsRepository) MessageVolume( _ context.Context, - _ domain.AnalyticsFilter, + filter domain.AnalyticsFilter, _ domain.AnalyticsBucket, ) ([]domain.MessageVolumePoint, error) { + repo.volumeFilters = append(repo.volumeFilters, filter) return []domain.MessageVolumePoint{{BucketStart: repo.now, MessageCount: 2}}, nil } diff --git a/apps/api-go/internal/http/directory_routes_test.go b/apps/api-go/internal/http/directory_routes_test.go new file mode 100644 index 0000000..c63aaaa --- /dev/null +++ b/apps/api-go/internal/http/directory_routes_test.go @@ -0,0 +1,237 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/config" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/routes" + "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/migrations" + ratelimitinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/ratelimit" + "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/sqlite" + "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/directory" +) + +type directoryTestPage struct { + Items []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ProfileImageURL *string `json:"profile_image_url"` + } `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +func newDirectoryTestRouter(t *testing.T, rateLimited bool) (http.Handler, *sql.DB) { + t.Helper() + ctx := context.Background() + db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "directory.sqlite3")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := migrations.ApplySQLite(ctx, db); err != nil { + t.Fatal(err) + } + cfg := config.Config{RateLimitEnabled: rateLimited} + deps := routes.Dependencies{Directory: directory.NewService(sqlite.NewDirectoryRepository(db))} + if rateLimited { + deps.RateLimiter, err = ratelimitinfra.NewGCRA(1000) + if err != nil { + t.Fatal(err) + } + } + return NewRouter(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), deps), db +} + +func seedDirectoryIdentity(t *testing.T, db *sql.DB, kind, slug, name string, n int) { + t.Helper() + var err error + if kind == "users" { + _, err = sqlite.NewSenderProfileRepository(db).Upsert(context.Background(), domain.SenderProfile{ + KickUserID: int64(n), Slug: slug, Username: name, ProfileImageURL: "https://example.com/avatar.png", + }) + } else { + _, err = sqlite.NewFollowedChannelRepository(db).Upsert(context.Background(), domain.FollowedChannel{ + Slug: slug, DisplayName: name, IsEnabled: false, ProfileImageURL: "https://example.com/avatar.png", + }) + } + if err != nil { + t.Fatal(err) + } +} + +func directoryRequest(t *testing.T, router http.Handler, path string, status int) directoryTestPage { + t.Helper() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != status { + t.Fatalf("GET %s: status %d, want %d: %s", path, w.Code, status, w.Body.String()) + } + var page directoryTestPage + if status == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if page.Items == nil { + t.Fatal("items must be an array, not null") + } + for _, forbidden := range []string{"message_count", "latest_message_at", "first_message_at", "total", "count"} { + if strings.Contains(w.Body.String(), `"`+forbidden+`"`) { + t.Fatalf("analytics field in directory: %s", w.Body.String()) + } + } + } + return page +} + +func TestDirectoryPrefixCaseWildcardsAndHistoricalIdentities(t *testing.T) { + for _, kind := range []string{"users", "channels"} { + t.Run(kind, func(t *testing.T) { + router, db := newDirectoryTestRouter(t, false) + for i, entry := range [][2]string{ + {"yavuz", "YaVuZ"}, {"notyavuz", "notYavuz"}, {"yavu-two", "Yavu_Two"}, + {"other", "YavuAlias"}, {"ya%literal", "Ya%Literal"}, {"yaXliteral", "YaXLiteral"}, + {"ya_literal", "Ya_Literal"}, {"ya-literal-two", "Ya-Literal-Two"}, + {`ya\literal`, `Ya\Literal`}, {"inject", "ya' OR 1=1 --"}, + } { + seedDirectoryIdentity(t, db, kind, entry[0], entry[1], i+1) + } + for _, tc := range []struct { + prefix string + want []string + }{ + {" YaVu ", []string{"other", "yavu-two", "yavuz"}}, + {"ya%", []string{"ya%literal"}}, + {"ya_", []string{"ya_literal", "ya-literal-two"}}, + {"ya-", []string{"ya_literal", "ya-literal-two"}}, + {`ya\`, []string{`ya\literal`}}, + {"ya' OR 1=1 --", []string{"inject"}}, + {"zz", []string{}}, + } { + page := directoryRequest(t, router, "/directory/"+kind+"?prefix="+url.QueryEscape(tc.prefix), http.StatusOK) + got := []string{} + for _, item := range page.Items { + got = append(got, item.Slug) + if item.Name == "" || item.ProfileImageURL == nil { + t.Fatalf("lost metadata: %+v", item) + } + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("prefix %q: got %v, want %v", tc.prefix, got, tc.want) + } + if page.NextCursor != nil { + t.Fatal("unexpected next page") + } + } + }) + } +} + +func TestDirectoryKeysetPagesAndCursorScope(t *testing.T) { + for _, kind := range []string{"users", "channels"} { + t.Run(kind, func(t *testing.T) { + router, db := newDirectoryTestRouter(t, false) + for i, slug := range []string{"test_b", "test-b", "test-a", "test-c"} { + seedDirectoryIdentity(t, db, kind, slug, slug, i+1) + } + base := "/directory/" + kind + "?prefix=test&limit=2" + first := directoryRequest(t, router, base, 200) + if len(first.Items) != 2 || first.NextCursor == nil || first.Items[0].Slug != "test-a" || first.Items[1].Slug != "test_b" { + t.Fatalf("first page: %+v", first) + } + seedDirectoryIdentity(t, db, kind, "test-0", "test-0", 5) + second := directoryRequest(t, router, base+"&after="+url.QueryEscape(*first.NextCursor), 200) + if len(second.Items) != 2 || second.NextCursor != nil || second.Items[0].Slug != "test-b" || second.Items[1].Slug != "test-c" { + t.Fatalf("second page: %+v", second) + } + withCursor := directoryRequest(t, router, base+"&cursor="+url.QueryEscape(*first.NextCursor), 200) + if !reflect.DeepEqual(second, withCursor) { + t.Fatal("cursor alias differs from after") + } + directoryRequest(t, router, "/directory/"+kind+"?prefix=other&after="+url.QueryEscape(*first.NextCursor), 422) + otherKind := "users" + if kind == "users" { + otherKind = "channels" + } + directoryRequest(t, router, "/directory/"+otherKind+"?prefix=test&after="+url.QueryEscape(*first.NextCursor), 422) + }) + } +} + +func TestDirectoryBoundedDefaultAndMaximumPages(t *testing.T) { + router, db := newDirectoryTestRouter(t, false) + for i := 1; i <= 101; i++ { + seedDirectoryIdentity(t, db, "users", fmt.Sprintf("page-%03d", i), "Page", i) + } + for _, tc := range []struct { + query string + size int + }{{"", 50}, {"&limit=100", 100}, {"&limit=1", 1}} { + page := directoryRequest(t, router, "/directory/users?prefix=page"+tc.query, 200) + if len(page.Items) != tc.size || page.NextCursor == nil { + t.Fatalf("page: got %d items, cursor %v", len(page.Items), page.NextCursor) + } + } +} + +func TestDirectoryRejectsMalformedParameters(t *testing.T) { + router, _ := newDirectoryTestRouter(t, false) + for _, query := range []string{ + "", "prefix=", "prefix=a", "prefix=%20%20", "prefix=ab%00", "prefix=ab%0A", "prefix=%FF%FF", + "prefix=" + strings.Repeat("a", 161), "prefix=ab&limit=0", "prefix=ab&limit=-1", "prefix=ab&limit=101", + "prefix=ab&limit=1.5", "prefix=ab&limit=no", "prefix=ab&limit=", "prefix=ab&limit=999999999999999999999", + "prefix=ab&after=bad!", "prefix=ab&cursor=bad", "prefix=ab&after=" + strings.Repeat("x", 4097), + "prefix=ab&after=" + base64.RawURLEncoding.EncodeToString([]byte(`{"v":1}`)), + "prefix=ab&after=x&cursor=y", "prefix=ab&prefix=cd", "prefix=ab&limit=2&limit=3", "prefix=ab&offset=3", "prefix=%ZZ", + } { + directoryRequest(t, router, "/directory/users?"+query, 422) + } +} + +func TestDirectoryDatabaseErrorIsGeneric(t *testing.T) { + router, db := newDirectoryTestRouter(t, false) + _ = db.Close() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/directory/users?prefix=test", nil)) + if w.Code != 500 || w.Body.String() != "{\"detail\":\"Internal server error.\"}\n" { + t.Fatalf("response: %d %s", w.Code, w.Body.String()) + } +} + +func TestDirectoryRateLimitSharesOnlyDirectoryBucket(t *testing.T) { + router, _ := newDirectoryTestRouter(t, true) + for i := 0; i < 16; i++ { + kind := "users" + if i%2 == 1 { + kind = "channels" + } + directoryRequest(t, router, "/directory/"+kind+"?prefix=test", 200) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/directory/users?prefix=test", nil)) + if w.Code != 429 || w.Header().Get("Retry-After") == "" { + t.Fatalf("rate limit: %d %s", w.Code, w.Body.String()) + } + directoryRequest(t, router, "/analytics/top-senders?q=test", 500) + request := httptest.NewRequest(http.MethodGet, "/directory/users?prefix=test", nil) + request.RemoteAddr = "10.0.0.2:9000" + w = httptest.NewRecorder() + router.ServeHTTP(w, request) + if w.Code != 200 { + t.Fatalf("independent IP: %d", w.Code) + } +} diff --git a/apps/api-go/internal/http/homepage_routes_test.go b/apps/api-go/internal/http/homepage_routes_test.go new file mode 100644 index 0000000..e633863 --- /dev/null +++ b/apps/api-go/internal/http/homepage_routes_test.go @@ -0,0 +1,35 @@ +package httpapi + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/config" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/routes" + ratelimitinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/ratelimit" + "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/homepage" +) + +func TestHomepageRegisteredAndRateLimitedWithoutDatabaseReads(t *testing.T) { + limiter, err := ratelimitinfra.NewGCRA(1000) + if err != nil { + t.Fatal(err) + } + router := NewRouter(config.Config{RateLimitEnabled: true}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Homepage: homepage.NewService(nil, nil, nil), RateLimiter: limiter, + }) + for i := 0; i < 17; i++ { + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/analytics/homepage", nil)) + want := http.StatusAccepted + if i == 16 { + want = http.StatusTooManyRequests + } + if response.Code != want || response.Header().Get("Retry-After") == "" { + t.Fatalf("request %d: status %d, want %d; %s", i, response.Code, want, response.Body.String()) + } + } +} diff --git a/apps/api-go/internal/http/middleware/ratelimit.go b/apps/api-go/internal/http/middleware/ratelimit.go index f585407..cef9a9b 100644 --- a/apps/api-go/internal/http/middleware/ratelimit.go +++ b/apps/api-go/internal/http/middleware/ratelimit.go @@ -103,6 +103,48 @@ func DefaultPolicies(trustProxy bool, clientIPHeader string) []RateLimitPolicy { PeriodSeconds: 60, MaxBurst: 10, }, + { + Name: "channel-subscribers-export", + Match: func(method, path string) bool { + return method == "GET" && + strings.HasPrefix(path, "/channels/") && + strings.HasSuffix(path, "/subscribers/export") + }, + Key: ip, + PerPeriod: 3, + PeriodSeconds: 60, + MaxBurst: 2, + }, + { + Name: "channel-subscribers", + Match: func(method, path string) bool { + return method == "GET" && + strings.HasPrefix(path, "/channels/") && + strings.HasSuffix(path, "/subscribers") + }, + Key: ip, + PerPeriod: 60, + PeriodSeconds: 60, + MaxBurst: 15, + }, + { + Name: "public-requests", + Match: exactMatch("POST", "/requests"), + Key: ip, + PerPeriod: 5, + PeriodSeconds: 600, + MaxBurst: 2, + }, + { + Name: "directory", + Match: func(method, path string) bool { + return method == "GET" && (path == "/directory/users" || path == "/directory/channels") + }, + Key: ip, + PerPeriod: 60, + PeriodSeconds: 60, + MaxBurst: 15, + }, { Name: "analytics", Match: prefixMatch("GET", "/analytics/"), diff --git a/apps/api-go/internal/http/profile_query_routes_test.go b/apps/api-go/internal/http/profile_query_routes_test.go new file mode 100644 index 0000000..70a8e68 --- /dev/null +++ b/apps/api-go/internal/http/profile_query_routes_test.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/config" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/routes" + profilesusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/profiles" +) + +func TestProfileCombinedQueriesPreserveResponseAndCache(t *testing.T) { + for _, path := range []string{"/users/profile-user/analytics", "/channels/hype/analytics"} { + t.Run(path, func(t *testing.T) { + want := profileQueryResponse(t, newAnalyticsProfileTestRouter(), path) + repo := &combinedProfileRepository{fakeAnalyticsRepository: newFakeAnalyticsRepository()} + handler := combinedProfileRouter(repo) + for range 2 { + got := profileQueryResponse(t, handler, path) + if !reflect.DeepEqual(got, want) { + t.Fatalf("profile response changed: got=%#v want=%#v", got, want) + } + } + if repo.combinedCalls != 1 || repo.legacyCalls != 0 { + t.Fatalf("combined calls=%d legacy calls=%d; want 1 and 0", repo.combinedCalls, repo.legacyCalls) + } + if !repo.filter.Start.IsZero() || !repo.filter.End.IsZero() { + t.Fatalf("profile summary must remain all-time: %+v", repo.filter) + } + if repo.limit != 5 || (repo.filter.Channel != "hype" && repo.filter.Sender != "profile_user") { + t.Fatalf("unexpected summary filter=%+v limit=%d", repo.filter, repo.limit) + } + }) + } +} + +func TestProfileCombinedQueryFailurePreservesLegacyResults(t *testing.T) { + for _, path := range []string{"/users/profile-user/analytics", "/channels/hype/analytics"} { + t.Run(path, func(t *testing.T) { + want := profileQueryResponse(t, newAnalyticsProfileTestRouter(), path) + repo := &combinedProfileRepository{ + fakeAnalyticsRepository: newFakeAnalyticsRepository(), + combinedErr: errors.New("combined query unavailable"), + } + got := profileQueryResponse(t, combinedProfileRouter(repo), path) + if !reflect.DeepEqual(got, want) || repo.combinedCalls != 1 || repo.legacyCalls != 2 { + t.Fatalf("fallback changed result: combined=%d legacy=%d got=%#v", repo.combinedCalls, repo.legacyCalls, got) + } + }) + } +} + +func combinedProfileRouter(repo *combinedProfileRepository) http.Handler { + cfg := config.Config{} + return NewRouter(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Config: cfg, + Profiles: profilesusecase.NewService( + repo, newFakeProfileChannelRepository(), newFakeProfileSenderRepository(), + ), + }) +} + +func profileQueryResponse(t *testing.T, handler http.Handler, path string) any { + t.Helper() + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + var payload any + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + return payload +} + +type combinedProfileRepository struct { + *fakeAnalyticsRepository + combinedCalls int + legacyCalls int + combinedErr error + filter domain.AnalyticsFilter + limit uint64 +} + +func (repo *combinedProfileRepository) Overview(ctx context.Context, filter domain.AnalyticsFilter) (domain.AnalyticsOverview, error) { + repo.legacyCalls++ + return repo.fakeAnalyticsRepository.Overview(ctx, filter) +} + +func (repo *combinedProfileRepository) TopSenders(ctx context.Context, filter domain.AnalyticsFilter, limit uint64) ([]domain.TopSenderAnalytics, error) { + repo.legacyCalls++ + return repo.fakeAnalyticsRepository.TopSenders(ctx, filter, limit) +} + +func (repo *combinedProfileRepository) TopChannels(ctx context.Context, filter domain.AnalyticsFilter, limit uint64) ([]domain.TopChannelAnalytics, error) { + repo.legacyCalls++ + return repo.fakeAnalyticsRepository.TopChannels(ctx, filter, limit) +} + +func (repo *combinedProfileRepository) OverviewAndTopSenders(ctx context.Context, filter domain.AnalyticsFilter, limit uint64) (domain.AnalyticsOverview, []domain.TopSenderAnalytics, error) { + repo.combinedCalls++ + repo.filter, repo.limit = filter, limit + if repo.combinedErr != nil { + return domain.AnalyticsOverview{}, nil, repo.combinedErr + } + overview, _ := repo.fakeAnalyticsRepository.Overview(ctx, filter) + senders, _ := repo.fakeAnalyticsRepository.TopSenders(ctx, filter, limit) + return overview, senders, nil +} + +func (repo *combinedProfileRepository) OverviewAndTopChannels(ctx context.Context, filter domain.AnalyticsFilter, limit uint64) (domain.AnalyticsOverview, []domain.TopChannelAnalytics, error) { + repo.combinedCalls++ + repo.filter, repo.limit = filter, limit + if repo.combinedErr != nil { + return domain.AnalyticsOverview{}, nil, repo.combinedErr + } + overview, _ := repo.fakeAnalyticsRepository.Overview(ctx, filter) + channels, _ := repo.fakeAnalyticsRepository.TopChannels(ctx, filter, limit) + return overview, channels, nil +} diff --git a/apps/api-go/internal/http/routes/dependencies.go b/apps/api-go/internal/http/routes/dependencies.go index f8e8a3e..541d375 100644 --- a/apps/api-go/internal/http/routes/dependencies.go +++ b/apps/api-go/internal/http/routes/dependencies.go @@ -11,20 +11,26 @@ import ( authusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/auth" channelsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/channels" datamanagementusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/data_management" + directoryusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/directory" + homepageusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/homepage" kicksyncusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/kicksync" messagesusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/messages" profilesusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/profiles" + requestsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/requests" ) type Dependencies struct { Config config.Config Auth *authusecase.Service Analytics *analyticsusecase.Service + Homepage *homepageusecase.Service Channels *channelsusecase.Service Messages *messagesusecase.Service Profiles *profilesusecase.Service Data *datamanagementusecase.Service + Directory *directoryusecase.Service KickSync *kicksyncusecase.Service + Requests *requestsusecase.Service WebhookEvents ports.KickWebhookEventRepository WebhookVerifier ports.KickWebhookVerifier WebhookEventSubs ports.KickEventSubscriptionRepository diff --git a/apps/api-go/internal/http/routes/directory.go b/apps/api-go/internal/http/routes/directory.go new file mode 100644 index 0000000..b2344e1 --- /dev/null +++ b/apps/api-go/internal/http/routes/directory.go @@ -0,0 +1,76 @@ +package routes + +import ( + "errors" + "net/http" + "net/url" + "strconv" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/schemas" + "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/directory" +) + +func RegisterDirectoryRoutes(mux *http.ServeMux, deps Dependencies) { + mux.HandleFunc("GET /directory/users", func(w http.ResponseWriter, r *http.Request) { + getDirectory(w, r, deps, domain.DirectoryUsers) + }) + mux.HandleFunc("GET /directory/channels", func(w http.ResponseWriter, r *http.Request) { + getDirectory(w, r, deps, domain.DirectoryChannels) + }) +} + +func getDirectory(w http.ResponseWriter, r *http.Request, deps Dependencies, kind domain.DirectoryKind) { + params, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + for key, values := range params { + if len(values) != 1 || (key != "prefix" && key != "limit" && key != "after" && key != "cursor") { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + } + if params.Has("after") && params.Has("cursor") { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + limit := directory.DefaultLimit + if params.Has("limit") { + limit, err = strconv.Atoi(params.Get("limit")) + if err != nil || limit < 1 || limit > directory.MaxLimit { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + } + after := params.Get("after") + if params.Has("cursor") { + after = params.Get("cursor") + } + if deps.Directory == nil { + writeError(w, http.StatusInternalServerError, "Internal server error.") + return + } + page, err := deps.Directory.Search(r.Context(), kind, params.Get("prefix"), limit, after) + if errors.Is(err, directory.ErrInvalidQuery) { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal server error.") + return + } + response := schemas.DirectoryResponse{Items: make([]schemas.DirectoryIdentityResponse, 0, len(page.Items))} + for _, item := range page.Items { + identity := schemas.DirectoryIdentityResponse{ID: item.ID, Name: item.Name, Slug: item.Slug} + if item.ProfileImageURL != "" { + identity.ProfileImageURL = &item.ProfileImageURL + } + response.Items = append(response.Items, identity) + } + if page.NextCursor != "" { + response.NextCursor = &page.NextCursor + } + writeJSON(w, http.StatusOK, response) +} diff --git a/apps/api-go/internal/http/routes/homepage.go b/apps/api-go/internal/http/routes/homepage.go new file mode 100644 index 0000000..8251b86 --- /dev/null +++ b/apps/api-go/internal/http/routes/homepage.go @@ -0,0 +1,35 @@ +package routes + +import ( + "net/http" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/schemas" + homepageusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/homepage" +) + +func RegisterHomepageRoutes(mux *http.ServeMux, service *homepageusecase.Service) { + mux.HandleFunc("GET /analytics/homepage", func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "no-store") + if service == nil { + writeError(response, http.StatusServiceUnavailable, "Analytics unavailable.") + return + } + snapshot, err := service.Get() + if err != nil { + response.Header().Set("Retry-After", "5") + writeJSON(response, http.StatusAccepted, schemas.HomepagePendingResponse{Status: "initializing", RetryAfterSeconds: 5}) + return + } + writeJSON(response, http.StatusOK, schemas.HomepageResponse{ + Status: "ready", AsOf: snapshot.AsOf.Format(time.RFC3339), + Start: snapshot.Start.Format(time.RFC3339), End: snapshot.End.Format(time.RFC3339), + Timezone: "UTC", Stale: time.Since(snapshot.AsOf) >= homepageusecase.RefreshInterval, + Overview: analyticsOverviewResponse(snapshot.Overview), + MessageVolume: messageVolumeResponse(snapshot.Volume).Items, + TopChannels: topChannelsResponse(snapshot.TopChannels).Items, + TopSenders: topSendersResponse(snapshot.TopSenders).Items, + TopEmotes: topEmotesResponse(snapshot.TopEmotes).Items, + }) + }) +} diff --git a/apps/api-go/internal/http/routes/homepage_test.go b/apps/api-go/internal/http/routes/homepage_test.go new file mode 100644 index 0000000..6a6740b --- /dev/null +++ b/apps/api-go/internal/http/routes/homepage_test.go @@ -0,0 +1,64 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + homepageusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/homepage" +) + +func TestHomepageDoesNotQueryWhileInitializing(t *testing.T) { + service := homepageusecase.NewService(nil, nil, nil) + mux := http.NewServeMux() + RegisterHomepageRoutes(mux, service) + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest("GET", "/analytics/homepage", nil)) + if response.Code != http.StatusAccepted || response.Header().Get("Retry-After") != "5" || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("response: %d %v %s", response.Code, response.Header(), response.Body.String()) + } + if response.Body.String() != "{\"status\":\"initializing\",\"retry_after_seconds\":5}\n" { + t.Fatal(response.Body.String()) + } +} + +func TestHomepageReturnsBoundedSnapshotWithExistingAnalyticsShapes(t *testing.T) { + now := time.Now().UTC().Add(-20 * time.Minute) + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -13) + value := domain.HomepageSnapshot{Version: 1, AsOf: now, Start: start, End: now, Overview: domain.AnalyticsOverview{TotalMessages: 123}} + for i := range 14 { + value.Volume = append(value.Volume, domain.MessageVolumePoint{BucketStart: start.AddDate(0, 0, i)}) + } + service := homepageusecase.NewService(nil, snapshotFixture{value}, nil) + if err := service.Restore(); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + RegisterHomepageRoutes(mux, service) + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest("GET", "/analytics/homepage", nil)) + var body struct { + Status string `json:"status"` + Stale bool `json:"stale"` + Overview struct { + TotalMessages int64 `json:"total_messages"` + } `json:"overview"` + Volume []any `json:"message_volume"` + Channels []any `json:"top_channels"` + Timezone string `json:"timezone"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if response.Code != 200 || body.Status != "ready" || !body.Stale || body.Overview.TotalMessages != 123 || len(body.Volume) != 14 || body.Channels == nil || body.Timezone != "UTC" { + t.Fatalf("response: %s", response.Body.String()) + } +} + +type snapshotFixture struct{ value domain.HomepageSnapshot } + +func (s snapshotFixture) Load() (domain.HomepageSnapshot, error) { return s.value, nil } +func (s snapshotFixture) Save(domain.HomepageSnapshot) error { return nil } diff --git a/apps/api-go/internal/http/routes/requests.go b/apps/api-go/internal/http/routes/requests.go new file mode 100644 index 0000000..a8f11eb --- /dev/null +++ b/apps/api-go/internal/http/routes/requests.go @@ -0,0 +1,296 @@ +package routes + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/middleware" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/schemas" + requestsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/requests" +) + +func RegisterUserRequestRoutes(mux *http.ServeMux, deps Dependencies) { + mux.HandleFunc("POST /requests", func(response http.ResponseWriter, request *http.Request) { + createUserRequest(response, request, deps) + }) + mux.HandleFunc("GET /admin/requests", func(response http.ResponseWriter, request *http.Request) { + listUserRequests(response, request, deps) + }) + mux.HandleFunc("GET /admin/requests/{request_id}", func(response http.ResponseWriter, request *http.Request) { + getUserRequest(response, request, deps) + }) + mux.HandleFunc("POST /admin/requests/{request_id}/status", func(response http.ResponseWriter, request *http.Request) { + updateUserRequestStatus(response, request, deps) + }) + mux.HandleFunc("POST /admin/requests/{request_id}/notes", func(response http.ResponseWriter, request *http.Request) { + addUserRequestNote(response, request, deps) + }) + mux.HandleFunc("POST /admin/requests/{request_id}/archive", func(response http.ResponseWriter, request *http.Request) { + archiveUserRequest(response, request, deps) + }) +} + +func createUserRequest(response http.ResponseWriter, request *http.Request, deps Dependencies) { + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return + } + + var payload schemas.CreateUserRequestRequest + if err := decodeJSON(request, &payload); err != nil { + writeError(response, http.StatusBadRequest, "Invalid request body.") + return + } + if strings.TrimSpace(payload.Website) != "" { + writeError(response, http.StatusBadRequest, "Invalid request body.") + return + } + + clientIP := middleware.ClientIP(request, deps.Config.RateLimitTrustProxy, deps.Config.RateLimitClientIPHeader) + created, err := deps.Requests.Create(request.Context(), requestsusecase.CreateInput{ + Type: payload.Type, + Title: payload.Title, + Message: payload.Message, + ChannelSlug: payload.ChannelSlug, + ChannelDisplayName: payload.ChannelDisplayName, + Contact: payload.Contact, + IPHash: hashRequestMetadata(clientIP, deps.Config.JWTSecretKey), + UserAgentHash: hashRequestMetadata(request.UserAgent(), deps.Config.JWTSecretKey), + }) + if err != nil { + writeUserRequestError(response, err) + return + } + writeJSON(response, http.StatusCreated, schemas.CreateUserRequestResponse{RequestID: created.ID}) +} + +func listUserRequests(response http.ResponseWriter, request *http.Request, deps Dependencies) { + if _, ok := requireAdmin(response, request, deps.Auth, deps.Config); !ok { + return + } + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return + } + + filter, err := parseUserRequestFilter(request) + if err != nil { + writeUserRequestError(response, err) + return + } + items, err := deps.Requests.List(request.Context(), filter) + if err != nil { + writeUserRequestError(response, err) + return + } + writeJSON(response, http.StatusOK, userRequestsResponse(items)) +} + +func getUserRequest(response http.ResponseWriter, request *http.Request, deps Dependencies) { + if _, ok := requireAdmin(response, request, deps.Auth, deps.Config); !ok { + return + } + detail, ok := userRequestDetail(response, request, deps) + if !ok { + return + } + writeJSON(response, http.StatusOK, userRequestDetailResponse(detail)) +} + +func updateUserRequestStatus(response http.ResponseWriter, request *http.Request, deps Dependencies) { + admin, ok := requireAdmin(response, request, deps.Auth, deps.Config) + if !ok { + return + } + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return + } + + var payload schemas.UpdateUserRequestStatusRequest + if err := decodeJSON(request, &payload); err != nil { + writeError(response, http.StatusBadRequest, "Invalid request body.") + return + } + detail, err := deps.Requests.ChangeStatus(request.Context(), request.PathValue("request_id"), payload.Status, admin.ID) + if err != nil { + writeUserRequestError(response, err) + return + } + writeJSON(response, http.StatusOK, userRequestDetailResponse(detail)) +} + +func addUserRequestNote(response http.ResponseWriter, request *http.Request, deps Dependencies) { + admin, ok := requireAdmin(response, request, deps.Auth, deps.Config) + if !ok { + return + } + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return + } + + var payload schemas.AddUserRequestNoteRequest + if err := decodeJSON(request, &payload); err != nil { + writeError(response, http.StatusBadRequest, "Invalid request body.") + return + } + detail, err := deps.Requests.AddNote(request.Context(), request.PathValue("request_id"), payload.Note, admin.ID) + if err != nil { + writeUserRequestError(response, err) + return + } + writeJSON(response, http.StatusOK, userRequestDetailResponse(detail)) +} + +func archiveUserRequest(response http.ResponseWriter, request *http.Request, deps Dependencies) { + admin, ok := requireAdmin(response, request, deps.Auth, deps.Config) + if !ok { + return + } + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return + } + + detail, err := deps.Requests.Archive(request.Context(), request.PathValue("request_id"), admin.ID) + if err != nil { + writeUserRequestError(response, err) + return + } + writeJSON(response, http.StatusOK, userRequestDetailResponse(detail)) +} + +func userRequestDetail( + response http.ResponseWriter, + request *http.Request, + deps Dependencies, +) (domain.UserRequestDetail, bool) { + if deps.Requests == nil { + writeError(response, http.StatusServiceUnavailable, "Request storage unavailable.") + return domain.UserRequestDetail{}, false + } + detail, err := deps.Requests.Detail(request.Context(), request.PathValue("request_id")) + if err != nil { + writeUserRequestError(response, err) + return domain.UserRequestDetail{}, false + } + return detail, true +} + +func parseUserRequestFilter(request *http.Request) (domain.UserRequestListFilter, error) { + query := request.URL.Query() + filter := domain.UserRequestListFilter{ + Type: domain.UserRequestType(strings.TrimSpace(query.Get("type"))), + Status: domain.UserRequestStatus(strings.TrimSpace(query.Get("status"))), + Query: strings.TrimSpace(query.Get("q")), + } + + if rawArchived := strings.TrimSpace(query.Get("archived")); rawArchived != "" { + archived, err := strconv.ParseBool(rawArchived) + if err != nil { + return domain.UserRequestListFilter{}, requestsusecase.ErrValidation + } + filter.Archived = &archived + } + + var err error + if filter.Start, err = optionalTime(query.Get("start")); err != nil { + return domain.UserRequestListFilter{}, requestsusecase.ErrValidation + } + if filter.End, err = optionalTime(query.Get("end")); err != nil { + return domain.UserRequestListFilter{}, requestsusecase.ErrValidation + } + + if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" { + limit, err := strconv.ParseUint(rawLimit, 10, 64) + if err != nil || limit < 1 { + return domain.UserRequestListFilter{}, requestsusecase.ErrValidation + } + filter.Limit = limit + } + if rawOffset := strings.TrimSpace(query.Get("offset")); rawOffset != "" { + offset, err := strconv.ParseUint(rawOffset, 10, 64) + if err != nil { + return domain.UserRequestListFilter{}, requestsusecase.ErrValidation + } + filter.Offset = offset + } + + return filter, nil +} + +func userRequestsResponse(states []domain.UserRequestState) schemas.UserRequestsResponse { + items := make([]schemas.UserRequestResponse, 0, len(states)) + for _, state := range states { + items = append(items, userRequestResponse(state)) + } + return schemas.UserRequestsResponse{Items: items, Count: len(items)} +} + +func userRequestDetailResponse(detail domain.UserRequestDetail) schemas.UserRequestDetailResponse { + events := make([]schemas.UserRequestEventResponse, 0, len(detail.Events)) + for _, event := range detail.Events { + events = append(events, schemas.UserRequestEventResponse{ + EventID: event.ID, + RequestID: event.RequestID, + EventType: string(event.EventType), + Status: string(event.Status), + Note: event.Note, + AdminID: event.AdminID, + CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339), + }) + } + return schemas.UserRequestDetailResponse{ + Request: userRequestResponse(detail.State), + Events: events, + } +} + +func userRequestResponse(state domain.UserRequestState) schemas.UserRequestResponse { + return schemas.UserRequestResponse{ + RequestID: state.Request.ID, + Type: string(state.Request.Type), + Title: state.Request.Title, + Message: state.Request.Message, + ChannelSlug: nullableString(state.Request.ChannelSlug), + ChannelDisplayName: nullableString(state.Request.ChannelDisplayName), + Contact: nullableString(state.Request.Contact), + CurrentStatus: string(state.CurrentStatus), + IsArchived: state.IsArchived, + CreatedAt: state.Request.CreatedAt.UTC().Format(time.RFC3339), + LatestEventAt: state.LatestEventAt.UTC().Format(time.RFC3339), + } +} + +func hashRequestMetadata(value string, secret string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + key := strings.TrimSpace(secret) + if key == "" { + key = "kick-logs-request-metadata" + } + mac := hmac.New(sha256.New, []byte(key)) + _, _ = mac.Write([]byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func writeUserRequestError(response http.ResponseWriter, err error) { + switch { + case errors.Is(err, requestsusecase.ErrValidation): + writeError(response, http.StatusBadRequest, "Invalid request body.") + case errors.Is(err, requestsusecase.ErrNotFound): + writeError(response, http.StatusNotFound, "Request not found.") + default: + writeError(response, http.StatusInternalServerError, "Internal server error.") + } +} diff --git a/apps/api-go/internal/http/routes/webhook_admin.go b/apps/api-go/internal/http/routes/webhook_admin.go index af069e0..4ec5f94 100644 --- a/apps/api-go/internal/http/routes/webhook_admin.go +++ b/apps/api-go/internal/http/routes/webhook_admin.go @@ -2,8 +2,12 @@ package routes import ( "context" + "encoding/csv" + "encoding/json" "errors" "net/http" + "strconv" + "strings" "time" "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" @@ -21,6 +25,12 @@ func RegisterWebhookAdminRoutes(mux *http.ServeMux, deps Dependencies) { mux.HandleFunc("GET /channels/{slug}/subscription-summary", func(w http.ResponseWriter, r *http.Request) { channelSubscriptionSummary(w, r, deps) }) + mux.HandleFunc("GET /channels/{slug}/subscribers/export", func(w http.ResponseWriter, r *http.Request) { + exportChannelSubscribers(w, r, deps) + }) + mux.HandleFunc("GET /channels/{slug}/subscribers", func(w http.ResponseWriter, r *http.Request) { + channelSubscribers(w, r, deps) + }) } func channelSubscriptionSummary(w http.ResponseWriter, r *http.Request, deps Dependencies) { @@ -64,6 +74,297 @@ func channelSubscriptionSummary(w http.ResponseWriter, r *http.Request, deps Dep writeJSON(w, http.StatusOK, resp) } +func channelSubscribers(w http.ResponseWriter, r *http.Request, deps Dependencies) { + ch, ok := resolvePublicChannel(w, r, deps) + if !ok { + return + } + + giftOnly, err := optionalBool(r.URL.Query().Get("gift_only")) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + limit, offset, err := parseSubscriberPagination(r) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + + if deps.SubscriptionPeriods == nil { + writeJSON(w, http.StatusOK, schemas.ChannelSubscribersResponse{ + ChannelSlug: ch.Slug, + GiftOnly: giftOnly, + Limit: int64(limit), + Offset: int64(offset), + Items: []schemas.ChannelSubscriberResponse{}, + }) + return + } + + page, err := deps.SubscriptionPeriods.ListActiveSubscribers(r.Context(), domain.ChannelSubscriberFilter{ + FollowedChannelID: ch.ID, + GiftOnly: giftOnly, + Limit: uint64(limit), + Offset: uint64(offset), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + writeJSON(w, http.StatusOK, channelSubscribersResponse(ch.Slug, giftOnly, page)) +} + +func exportChannelSubscribers(w http.ResponseWriter, r *http.Request, deps Dependencies) { + ch, ok := resolvePublicChannel(w, r, deps) + if !ok { + return + } + + giftOnly, err := optionalBool(r.URL.Query().Get("gift_only")) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "Invalid query parameters.") + return + } + + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = "txt" + } + if format != "json" && format != "csv" && format != "txt" { + writeError(w, http.StatusUnprocessableEntity, "Invalid export format.") + return + } + + items := []domain.ChannelSubscriber{} + if deps.SubscriptionPeriods != nil { + exported, err := deps.SubscriptionPeriods.ExportActiveSubscribers(r.Context(), ch.ID, giftOnly) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal server error.") + return + } + items = exported + } + + generatedAt := time.Now().UTC() + filename := subscriberExportFilename(ch.Slug, format) + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + + switch format { + case "csv": + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(channelSubscribersCSV(ch.Slug, items))) + case "json": + payload := schemas.ChannelSubscribersExportResponse{ + ChannelSlug: ch.Slug, + GiftOnly: giftOnly, + GeneratedAt: generatedAt.Format(time.RFC3339), + Count: int64(len(items)), + Items: channelSubscriberResponses(items), + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(payload) + default: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(channelSubscribersTXT(ch.Slug, giftOnly, generatedAt, items))) + } +} + +func resolvePublicChannel(w http.ResponseWriter, r *http.Request, deps Dependencies) (domain.FollowedChannel, bool) { + slug := r.PathValue("slug") + if slug == "" || deps.Channels == nil { + writeError(w, http.StatusNotFound, "Channel not found.") + return domain.FollowedChannel{}, false + } + + ch, err := deps.Channels.GetBySlug(r.Context(), slug) + if err != nil { + if errors.Is(err, channelsusecase.ErrChannelNotFound) { + writeError(w, http.StatusNotFound, "Channel not found.") + return domain.FollowedChannel{}, false + } + writeError(w, http.StatusInternalServerError, "Internal server error.") + return domain.FollowedChannel{}, false + } + return ch, true +} + +func parseSubscriberPagination(r *http.Request) (int, int, error) { + limit := 50 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 { + return 0, 0, errors.New("invalid limit") + } + limit = parsed + } + if limit > 100 { + limit = 100 + } + + offset := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 0 { + return 0, 0, errors.New("invalid offset") + } + offset = parsed + } + return limit, offset, nil +} + +func channelSubscribersResponse( + channelSlug string, + giftOnly bool, + page domain.ChannelSubscriberPage, +) schemas.ChannelSubscribersResponse { + return schemas.ChannelSubscribersResponse{ + ChannelSlug: channelSlug, + GiftOnly: giftOnly, + Count: page.Count, + Limit: int64(page.Limit), + Offset: int64(page.Offset), + Items: channelSubscriberResponses(page.Items), + } +} + +func channelSubscriberResponses(items []domain.ChannelSubscriber) []schemas.ChannelSubscriberResponse { + resp := make([]schemas.ChannelSubscriberResponse, 0, len(items)) + for _, item := range items { + resp = append(resp, channelSubscriberResponse(item)) + } + return resp +} + +func channelSubscriberResponse(item domain.ChannelSubscriber) schemas.ChannelSubscriberResponse { + return schemas.ChannelSubscriberResponse{ + SubscriberKickUserID: item.SubscriberKickUserID, + Username: item.Username, + Slug: item.Slug, + ProfileImageURL: item.ProfileImageURL, + IsGift: item.IsGift, + GifterKickUserID: nullableInt64(item.GifterKickUserID), + GifterUsername: nullableString(item.GifterUsername), + GifterSlug: nullableString(item.GifterSlug), + GifterProfileImageURL: nullableString(item.GifterProfileImageURL), + StartedAt: item.StartedAt.UTC().Format(time.RFC3339), + ExpiresAt: item.ExpiresAt.UTC().Format(time.RFC3339), + } +} + +func channelSubscribersCSV(channelSlug string, items []domain.ChannelSubscriber) string { + var builder strings.Builder + writer := csv.NewWriter(&builder) + _ = writer.Write([]string{ + "channel_slug", + "subscriber_kick_user_id", + "username", + "slug", + "profile_image_url", + "is_gift", + "gifter_kick_user_id", + "gifter_username", + "gifter_slug", + "started_at", + "expires_at", + }) + for _, item := range items { + gifterID := "" + if item.GifterKickUserID > 0 { + gifterID = strconv.FormatInt(item.GifterKickUserID, 10) + } + _ = writer.Write([]string{ + safeCSVValue(channelSlug), + strconv.FormatInt(item.SubscriberKickUserID, 10), + safeCSVValue(item.Username), + safeCSVValue(item.Slug), + safeCSVValue(item.ProfileImageURL), + strconv.FormatBool(item.IsGift), + gifterID, + safeCSVValue(item.GifterUsername), + safeCSVValue(item.GifterSlug), + item.StartedAt.UTC().Format(time.RFC3339), + item.ExpiresAt.UTC().Format(time.RFC3339), + }) + } + writer.Flush() + return builder.String() +} + +func channelSubscribersTXT( + channelSlug string, + giftOnly bool, + generatedAt time.Time, + items []domain.ChannelSubscriber, +) string { + var builder strings.Builder + builder.WriteString("Kick Logs Aktif Abone Listesi\n") + builder.WriteString("Kanal: #") + builder.WriteString(channelSlug) + builder.WriteString("\n") + builder.WriteString("Filtre: ") + if giftOnly { + builder.WriteString("Hediye aboneler") + } else { + builder.WriteString("Tüm aktif aboneler") + } + builder.WriteString("\n") + builder.WriteString("Olusturulma: ") + builder.WriteString(generatedAt.UTC().Format(time.RFC3339)) + builder.WriteString("\n") + builder.WriteString("Toplam: ") + builder.WriteString(strconv.Itoa(len(items))) + builder.WriteString("\n\n") + + if len(items) == 0 { + builder.WriteString("Bu kanal için henüz aktif abonelik kaydı yok.\n") + return builder.String() + } + + for i, item := range items { + builder.WriteString(strconv.Itoa(i + 1)) + builder.WriteString(". ") + builder.WriteString(item.Username) + builder.WriteString(" (ID: ") + builder.WriteString(strconv.FormatInt(item.SubscriberKickUserID, 10)) + builder.WriteString(")") + if item.Slug != "" { + builder.WriteString(" - kick.com/") + builder.WriteString(item.Slug) + } + builder.WriteString("\n") + if item.IsGift && item.GifterUsername != "" { + builder.WriteString(" Hediye eden: ") + builder.WriteString(item.GifterUsername) + builder.WriteString("\n") + } + builder.WriteString(" Baslangic: ") + builder.WriteString(item.StartedAt.UTC().Format(time.RFC3339)) + builder.WriteString("\n") + builder.WriteString(" Bitis: ") + builder.WriteString(item.ExpiresAt.UTC().Format(time.RFC3339)) + builder.WriteString("\n\n") + } + return builder.String() +} + +func subscriberExportFilename(channelSlug string, format string) string { + cleaned := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '-' + }, channelSlug) + if cleaned == "" { + cleaned = "channel" + } + return "kick-logs-" + cleaned + "-active-subscribers." + format +} + func webhookHealth(w http.ResponseWriter, r *http.Request, deps Dependencies) { if _, ok := requireAdmin(w, r, deps.Auth, deps.Config); !ok { return diff --git a/apps/api-go/internal/http/schemas/directory.go b/apps/api-go/internal/http/schemas/directory.go new file mode 100644 index 0000000..8ebca58 --- /dev/null +++ b/apps/api-go/internal/http/schemas/directory.go @@ -0,0 +1,13 @@ +package schemas + +type DirectoryIdentityResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ProfileImageURL *string `json:"profile_image_url"` +} + +type DirectoryResponse struct { + Items []DirectoryIdentityResponse `json:"items"` + NextCursor *string `json:"next_cursor"` +} diff --git a/apps/api-go/internal/http/schemas/homepage.go b/apps/api-go/internal/http/schemas/homepage.go new file mode 100644 index 0000000..bad00af --- /dev/null +++ b/apps/api-go/internal/http/schemas/homepage.go @@ -0,0 +1,20 @@ +package schemas + +type HomepageResponse struct { + Status string `json:"status"` + AsOf string `json:"as_of"` + Start string `json:"start"` + End string `json:"end"` + Timezone string `json:"timezone"` + Stale bool `json:"stale"` + Overview AnalyticsOverviewResponse `json:"overview"` + MessageVolume []MessageVolumePointResponse `json:"message_volume"` + TopChannels []TopChannelResponse `json:"top_channels"` + TopSenders []TopSenderResponse `json:"top_senders"` + TopEmotes []TopEmoteResponse `json:"top_emotes"` +} + +type HomepagePendingResponse struct { + Status string `json:"status"` + RetryAfterSeconds int `json:"retry_after_seconds"` +} diff --git a/apps/api-go/internal/http/schemas/requests.go b/apps/api-go/internal/http/schemas/requests.go new file mode 100644 index 0000000..dbb5ddd --- /dev/null +++ b/apps/api-go/internal/http/schemas/requests.go @@ -0,0 +1,57 @@ +package schemas + +type CreateUserRequestRequest struct { + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + ChannelSlug string `json:"channel_slug"` + ChannelDisplayName string `json:"channel_display_name"` + Contact string `json:"contact"` + Website string `json:"website"` +} + +type CreateUserRequestResponse struct { + RequestID string `json:"request_id"` +} + +type UserRequestResponse struct { + RequestID string `json:"request_id"` + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + ChannelSlug *string `json:"channel_slug"` + ChannelDisplayName *string `json:"channel_display_name"` + Contact *string `json:"contact"` + CurrentStatus string `json:"current_status"` + IsArchived bool `json:"is_archived"` + CreatedAt string `json:"created_at"` + LatestEventAt string `json:"latest_event_at"` +} + +type UserRequestEventResponse struct { + EventID string `json:"event_id"` + RequestID string `json:"request_id"` + EventType string `json:"event_type"` + Status string `json:"status"` + Note string `json:"note"` + AdminID int64 `json:"admin_id"` + CreatedAt string `json:"created_at"` +} + +type UserRequestsResponse struct { + Items []UserRequestResponse `json:"items"` + Count int `json:"count"` +} + +type UserRequestDetailResponse struct { + Request UserRequestResponse `json:"request"` + Events []UserRequestEventResponse `json:"events"` +} + +type UpdateUserRequestStatusRequest struct { + Status string `json:"status"` +} + +type AddUserRequestNoteRequest struct { + Note string `json:"note"` +} diff --git a/apps/api-go/internal/http/schemas/webhooks.go b/apps/api-go/internal/http/schemas/webhooks.go index 7c24e48..3a22791 100644 --- a/apps/api-go/internal/http/schemas/webhooks.go +++ b/apps/api-go/internal/http/schemas/webhooks.go @@ -7,6 +7,37 @@ type ChannelSubscriptionSummaryResponse struct { LatestEventAt *string `json:"latest_event_at,omitempty"` } +type ChannelSubscriberResponse struct { + SubscriberKickUserID int64 `json:"subscriber_kick_user_id"` + Username string `json:"username"` + Slug string `json:"slug"` + ProfileImageURL string `json:"profile_image_url"` + IsGift bool `json:"is_gift"` + GifterKickUserID *int64 `json:"gifter_kick_user_id,omitempty"` + GifterUsername *string `json:"gifter_username,omitempty"` + GifterSlug *string `json:"gifter_slug,omitempty"` + GifterProfileImageURL *string `json:"gifter_profile_image_url,omitempty"` + StartedAt string `json:"started_at"` + ExpiresAt string `json:"expires_at"` +} + +type ChannelSubscribersResponse struct { + ChannelSlug string `json:"channel_slug"` + GiftOnly bool `json:"gift_only"` + Count int64 `json:"count"` + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` + Items []ChannelSubscriberResponse `json:"items"` +} + +type ChannelSubscribersExportResponse struct { + ChannelSlug string `json:"channel_slug"` + GiftOnly bool `json:"gift_only"` + GeneratedAt string `json:"generated_at"` + Count int64 `json:"count"` + Items []ChannelSubscriberResponse `json:"items"` +} + type WebhookHealthResponse struct { ConfiguredEventTypes []string `json:"configured_event_types"` MissingClientCredentials bool `json:"missing_client_credentials"` diff --git a/apps/api-go/internal/http/server.go b/apps/api-go/internal/http/server.go index a1cc24a..d850dce 100644 --- a/apps/api-go/internal/http/server.go +++ b/apps/api-go/internal/http/server.go @@ -21,7 +21,10 @@ func NewRouter(cfg config.Config, logger *slog.Logger, dependencySets ...routes. routes.RegisterAuthRoutes(mux, deps) routes.RegisterMessageRoutes(mux, deps) routes.RegisterAnalyticsRoutes(mux, deps) + routes.RegisterHomepageRoutes(mux, deps.Homepage) + routes.RegisterDirectoryRoutes(mux, deps) routes.RegisterProfileRoutes(mux, deps) + routes.RegisterUserRequestRoutes(mux, deps) routes.RegisterAdminUserRoutes(mux, deps) routes.RegisterAdminChannelRoutes(mux, deps) routes.RegisterAdminOperationRoutes(mux, deps) diff --git a/apps/api-go/internal/http/user_request_routes_test.go b/apps/api-go/internal/http/user_request_routes_test.go new file mode 100644 index 0000000..86124ee --- /dev/null +++ b/apps/api-go/internal/http/user_request_routes_test.go @@ -0,0 +1,268 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/config" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/http/routes" + authinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/auth" + "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/migrations" + sqliteinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/sqlite" + authusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/auth" + requestsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/requests" +) + +func TestUserRequestRoutesPublicSubmitAndAdminWorkflow(t *testing.T) { + handler, repo := newUserRequestTestRouter(t) + + submitResponse := httptest.NewRecorder() + submitRequest := jsonRequest(t, http.MethodPost, "/requests", `{ + "type":"channel_request", + "title":"Kanal eklensin", + "message":"Bu kanali uygulamaya ekleyebilir miyiz?", + "channel_slug":"https://kick.com/NuriBen", + "contact":"mod@example.com" + }`) + submitRequest.RemoteAddr = "10.20.30.40:1234" + submitRequest.Header.Set("User-Agent", "route-test-agent") + handler.ServeHTTP(submitResponse, submitRequest) + if submitResponse.Code != http.StatusCreated { + t.Fatalf("submit status = %d body = %s", submitResponse.Code, submitResponse.Body.String()) + } + + var createPayload struct { + RequestID string `json:"request_id"` + } + decodeResponse(t, submitResponse, &createPayload) + if createPayload.RequestID == "" { + t.Fatal("request_id is empty") + } + stored := repo.requests[createPayload.RequestID] + if stored.ChannelSlug != "nuriben" { + t.Fatalf("stored channel slug = %q", stored.ChannelSlug) + } + if stored.IPHash == "" || stored.IPHash == "10.20.30.40" { + t.Fatalf("IPHash was not hashed = %q", stored.IPHash) + } + + adminCookie := loginAsAdmin(t, handler) + + listResponse := httptest.NewRecorder() + listRequest := httptest.NewRequest(http.MethodGet, "/admin/requests?type=channel_request&archived=false", nil) + listRequest.AddCookie(adminCookie) + handler.ServeHTTP(listResponse, listRequest) + if listResponse.Code != http.StatusOK { + t.Fatalf("list status = %d body = %s", listResponse.Code, listResponse.Body.String()) + } + if !strings.Contains(listResponse.Body.String(), `"current_status":"new"`) || + !strings.Contains(listResponse.Body.String(), `"channel_slug":"nuriben"`) { + t.Fatalf("list body = %s", listResponse.Body.String()) + } + + statusResponse := httptest.NewRecorder() + statusRequest := jsonRequest( + t, + http.MethodPost, + "/admin/requests/"+createPayload.RequestID+"/status", + `{"status":"reviewing"}`, + ) + statusRequest.AddCookie(adminCookie) + handler.ServeHTTP(statusResponse, statusRequest) + if statusResponse.Code != http.StatusOK { + t.Fatalf("status update = %d body = %s", statusResponse.Code, statusResponse.Body.String()) + } + if !strings.Contains(statusResponse.Body.String(), `"current_status":"reviewing"`) { + t.Fatalf("status body = %s", statusResponse.Body.String()) + } + + noteResponse := httptest.NewRecorder() + noteRequest := jsonRequest(t, http.MethodPost, "/admin/requests/"+createPayload.RequestID+"/notes", `{"note":"Kontrol edilecek."}`) + noteRequest.AddCookie(adminCookie) + handler.ServeHTTP(noteResponse, noteRequest) + if noteResponse.Code != http.StatusOK { + t.Fatalf("note status = %d body = %s", noteResponse.Code, noteResponse.Body.String()) + } + if !strings.Contains(noteResponse.Body.String(), "Kontrol edilecek.") { + t.Fatalf("note body = %s", noteResponse.Body.String()) + } + + archiveResponse := httptest.NewRecorder() + archiveRequest := httptest.NewRequest(http.MethodPost, "/admin/requests/"+createPayload.RequestID+"/archive", nil) + archiveRequest.AddCookie(adminCookie) + handler.ServeHTTP(archiveResponse, archiveRequest) + if archiveResponse.Code != http.StatusOK { + t.Fatalf("archive status = %d body = %s", archiveResponse.Code, archiveResponse.Body.String()) + } + if !strings.Contains(archiveResponse.Body.String(), `"is_archived":true`) { + t.Fatalf("archive body = %s", archiveResponse.Body.String()) + } +} + +func TestUserRequestRoutesRejectHoneypotAndRequireAdmin(t *testing.T) { + handler, _ := newUserRequestTestRouter(t) + + honeypotResponse := httptest.NewRecorder() + handler.ServeHTTP( + honeypotResponse, + jsonRequest(t, http.MethodPost, "/requests", `{"type":"feedback","title":"Feedback","message":"Guzel uygulama.","website":"bot"}`), + ) + if honeypotResponse.Code != http.StatusBadRequest { + t.Fatalf("honeypot status = %d body = %s", honeypotResponse.Code, honeypotResponse.Body.String()) + } + + adminResponse := httptest.NewRecorder() + handler.ServeHTTP(adminResponse, httptest.NewRequest(http.MethodGet, "/admin/requests", nil)) + if adminResponse.Code != http.StatusUnauthorized { + t.Fatalf("admin status = %d body = %s", adminResponse.Code, adminResponse.Body.String()) + } +} + +func newUserRequestTestRouter(t *testing.T) (http.Handler, *routeMemoryRequestRepo) { + t.Helper() + + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "kick-logs-requests.sqlite3") + db, err := sqliteinfra.Open(ctx, dbPath) + if err != nil { + t.Fatalf("sqlite open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := migrations.ApplySQLite(ctx, db); err != nil { + t.Fatalf("sqlite migrate: %v", err) + } + + cfg := config.Config{ + BackendCORSOrigins: []string{"http://localhost:3000"}, + JWTSecretKey: "test-secret-key", + JWTAlgorithm: "HS256", + JWTExpiresMinutes: 60, + JWTCookieName: "kick_logs_session", + JWTCookieSameSite: "lax", + RateLimitTrustProxy: false, + RateLimitClientIPHeader: "CF-Connecting-IP", + RateLimitStoreMaxKeys: 1000, + MessageExportMaxRows: 1000, + ListenerStaleAfter: 45, + ClickHouseDatabase: "kick_logs", + DefaultAdminEmail: "admin@kicklogs.local", + DefaultAdminPassword: "admin123", + SeedSuperAdmin: true, + KickWebhookProcessBatchSize: 50, + KickWebhookProcessMaxAttempts: 5, + } + + adminRepo := sqliteinfra.NewAdminUserRepository(db) + if _, err := sqliteinfra.SeedSuperAdmin(ctx, adminRepo, "admin@kicklogs.local", "admin123"); err != nil { + t.Fatalf("seed admin: %v", err) + } + tokenService := authinfra.NewJWTTokenService(cfg) + authService := authusecase.NewService(adminRepo, authinfra.NewBcryptPasswordHasher(), tokenService) + requestRepo := newRouteMemoryRequestRepo() + + handler := NewRouter(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Config: cfg, + Auth: authService, + Requests: requestsusecase.NewService(requestRepo), + TokenService: tokenService, + }) + return handler, requestRepo +} + +type routeMemoryRequestRepo struct { + requests map[string]domain.UserRequest + events map[string][]domain.UserRequestEvent +} + +func newRouteMemoryRequestRepo() *routeMemoryRequestRepo { + return &routeMemoryRequestRepo{ + requests: map[string]domain.UserRequest{}, + events: map[string][]domain.UserRequestEvent{}, + } +} + +func (repo *routeMemoryRequestRepo) Create(_ context.Context, request domain.UserRequest) error { + repo.requests[request.ID] = request + return nil +} + +func (repo *routeMemoryRequestRepo) List( + _ context.Context, + filter domain.UserRequestListFilter, +) ([]domain.UserRequestState, error) { + states := make([]domain.UserRequestState, 0, len(repo.requests)) + for requestID := range repo.requests { + state := repo.state(requestID) + if filter.Type != "" && state.Request.Type != filter.Type { + continue + } + if filter.Status != "" && state.CurrentStatus != filter.Status { + continue + } + if filter.Archived != nil && state.IsArchived != *filter.Archived { + continue + } + states = append(states, state) + } + return states, nil +} + +func (repo *routeMemoryRequestRepo) Get(_ context.Context, requestID string) (domain.UserRequestState, error) { + if _, ok := repo.requests[requestID]; !ok { + return domain.UserRequestState{}, sql.ErrNoRows + } + return repo.state(requestID), nil +} + +func (repo *routeMemoryRequestRepo) ListEvents(_ context.Context, requestID string) ([]domain.UserRequestEvent, error) { + return append([]domain.UserRequestEvent(nil), repo.events[requestID]...), nil +} + +func (repo *routeMemoryRequestRepo) AppendEvent(_ context.Context, event domain.UserRequestEvent) error { + if _, ok := repo.requests[event.RequestID]; !ok { + return sql.ErrNoRows + } + repo.events[event.RequestID] = append(repo.events[event.RequestID], event) + return nil +} + +func (repo *routeMemoryRequestRepo) state(requestID string) domain.UserRequestState { + request := repo.requests[requestID] + state := domain.UserRequestState{ + Request: request, + CurrentStatus: domain.UserRequestStatusNew, + LatestEventAt: request.CreatedAt, + } + for _, event := range repo.events[requestID] { + if event.EventType == domain.UserRequestEventStatusChanged && event.Status != "" { + state.CurrentStatus = event.Status + } + if event.EventType == domain.UserRequestEventArchived { + state.IsArchived = true + } + if event.CreatedAt.After(state.LatestEventAt) { + state.LatestEventAt = event.CreatedAt + } + } + if state.LatestEventAt.IsZero() { + state.LatestEventAt = time.Now().UTC() + } + return state +} + +func decodeJSONBody(t *testing.T, body string, target any) { + t.Helper() + if err := json.NewDecoder(strings.NewReader(body)).Decode(target); err != nil { + t.Fatalf("decode json body: %v body = %s", err, body) + } +} diff --git a/apps/api-go/internal/http/webhook_admin_routes_test.go b/apps/api-go/internal/http/webhook_admin_routes_test.go index af977ba..11b9155 100644 --- a/apps/api-go/internal/http/webhook_admin_routes_test.go +++ b/apps/api-go/internal/http/webhook_admin_routes_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -98,6 +99,197 @@ func TestChannelSubscriptionSummary(t *testing.T) { }) } +func TestChannelSubscribers(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + periodRepo := &fakeSubPeriodRepo{ + page: domain.ChannelSubscriberPage{ + Items: []domain.ChannelSubscriber{ + { + SubscriberKickUserID: 1001, + Username: "SubUser", + Slug: "subuser", + ProfileImageURL: "https://example.test/avatar.png", + IsGift: true, + GifterKickUserID: 2001, + GifterUsername: "Gifter", + GifterSlug: "gifter", + GifterProfileImageURL: "https://example.test/gifter.png", + StartedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), + }, + }, + Count: 1, + Limit: 2, + Offset: 1, + }, + } + + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: periodRepo, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/hype/subscribers?limit=2&offset=1&gift_only=true", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + + var resp schemas.ChannelSubscribersResponse + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp.ChannelSlug != "hype" || !resp.GiftOnly || resp.Count != 1 || len(resp.Items) != 1 { + t.Fatalf("resp = %+v", resp) + } + if resp.Items[0].Username != "SubUser" || resp.Items[0].GifterUsername == nil { + t.Fatalf("subscriber = %+v", resp.Items[0]) + } + if periodRepo.lastFilter.FollowedChannelID != 1 || !periodRepo.lastFilter.GiftOnly || periodRepo.lastFilter.Limit != 2 || periodRepo.lastFilter.Offset != 1 { + t.Fatalf("filter = %+v", periodRepo.lastFilter) + } +} + +func TestChannelSubscribersExportTXT(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + periodRepo := &fakeSubPeriodRepo{ + exportItems: []domain.ChannelSubscriber{ + { + SubscriberKickUserID: 1001, + Username: "SubUser", + Slug: "subuser", + StartedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), + }, + }, + } + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: periodRepo, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/hype/subscribers/export?format=txt", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") { + t.Fatalf("content-type = %q", got) + } + if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "kick-logs-hype-active-subscribers.txt") { + t.Fatalf("content-disposition = %q", got) + } + body := w.Body.String() + for _, want := range []string{"Kick Logs Aktif Abone Listesi", "Kanal: #hype", "SubUser", "Toplam: 1"} { + if !strings.Contains(body, want) { + t.Fatalf("body does not contain %q: %s", want, body) + } + } + if strings.Contains(body, "streak") || strings.Contains(body, "month") { + t.Fatalf("txt export should not include unavailable streak/month fields: %s", body) + } +} + +func TestChannelSubscribersExportJSON(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + periodRepo := &fakeSubPeriodRepo{ + exportItems: []domain.ChannelSubscriber{ + { + SubscriberKickUserID: 1001, + Username: "SubUser", + Slug: "subuser", + IsGift: true, + StartedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), + }, + }, + } + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: periodRepo, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/hype/subscribers/export?format=json&gift_only=true", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "kick-logs-hype-active-subscribers.json") { + t.Fatalf("content-disposition = %q", got) + } + var resp schemas.ChannelSubscribersExportResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ChannelSlug != "hype" || !resp.GiftOnly || resp.Count != 1 || len(resp.Items) != 1 { + t.Fatalf("resp = %+v", resp) + } +} + +func TestChannelSubscribersExportCSV(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + periodRepo := &fakeSubPeriodRepo{ + exportItems: []domain.ChannelSubscriber{ + { + SubscriberKickUserID: 1001, + Username: "SubUser", + Slug: "subuser", + StartedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), + }, + }, + } + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: periodRepo, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/hype/subscribers/export?format=csv", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/csv") { + t.Fatalf("content-type = %q", got) + } + body := w.Body.String() + if !strings.Contains(body, "channel_slug,subscriber_kick_user_id,username") || !strings.Contains(body, "hype,1001,SubUser") { + t.Fatalf("csv body = %s", body) + } +} + +func TestChannelSubscribersExportRejectsInvalidFormat(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: &fakeSubPeriodRepo{}, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/hype/subscribers/export?format=xml", nil)) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d", w.Code) + } +} + +func TestChannelSubscribersUnknownChannelReturns404(t *testing.T) { + ch := domain.FollowedChannel{ID: 1, Slug: "hype", DisplayName: "Hype", BroadcasterUserID: 9000, IsEnabled: true, RawPayloadJSON: "{}"} + channelSvc := channelsusecase.NewService(newAdminFakeChannelRepo(ch), &nopResolver{}) + router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{ + Channels: channelSvc, + SubscriptionPeriods: &fakeSubPeriodRepo{}, + }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels/nope/subscribers", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d", w.Code) + } +} + func TestWebhookHealthRequiresAuth(t *testing.T) { router := httpapi.NewRouter(config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)), routes.Dependencies{}) w := httptest.NewRecorder() @@ -154,6 +346,9 @@ func TestWebhookSyncNoSyncServiceReturns503(t *testing.T) { type fakeSubPeriodRepo struct { summary domain.ChannelSubscriptionSummary + page domain.ChannelSubscriberPage + exportItems []domain.ChannelSubscriber + lastFilter domain.ChannelSubscriberFilter activeSummaryCalls int } @@ -166,6 +361,22 @@ func (r *fakeSubPeriodRepo) ActiveSummary(_ context.Context, _ int64) (domain.Ch return r.summary, nil } +func (r *fakeSubPeriodRepo) ListActiveSubscribers( + _ context.Context, + filter domain.ChannelSubscriberFilter, +) (domain.ChannelSubscriberPage, error) { + r.lastFilter = filter + return r.page, nil +} + +func (r *fakeSubPeriodRepo) ExportActiveSubscribers( + _ context.Context, + _ int64, + _ bool, +) ([]domain.ChannelSubscriber, error) { + return r.exportItems, nil +} + // --- fakeInboxRepo --- type fakeInboxRepo struct { diff --git a/apps/api-go/internal/infra/clickhouse/homepage.go b/apps/api-go/internal/infra/clickhouse/homepage.go new file mode 100644 index 0000000..3dfc5d9 --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/homepage.go @@ -0,0 +1,32 @@ +package clickhouse + +import ( + "context" + + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +) + +// Limit only the periodic homepage work; existing API/profile contracts keep +// their current behavior. Sequential refreshes leave capacity for ingestion. +func NewHomepageAnalyticsRepository(conn driver.Conn) *AnalyticsRepository { + return NewAnalyticsRepository(homepageConnection{conn}) +} + +type homepageConnection struct{ driver.Conn } + +func (conn homepageConnection) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) { + return conn.Conn.Query(homepageContext(ctx), query, args...) +} + +func (conn homepageConnection) QueryRow(ctx context.Context, query string, args ...any) driver.Row { + return conn.Conn.QueryRow(homepageContext(ctx), query, args...) +} + +func homepageContext(ctx context.Context) context.Context { + return clickhousedriver.Context(ctx, clickhousedriver.WithSettings(clickhousedriver.Settings{ + "max_threads": 2, + "max_memory_usage": 384 * 1024 * 1024, + "max_execution_time": 15, + })) +} diff --git a/apps/api-go/internal/infra/clickhouse/homepage_integration_test.go b/apps/api-go/internal/infra/clickhouse/homepage_integration_test.go new file mode 100644 index 0000000..23345ef --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/homepage_integration_test.go @@ -0,0 +1,61 @@ +package clickhouse_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + clickhouseinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/clickhouse" +) + +// Called by the existing opt-in CI integration suite against its test database. +func testHomepageAnalytics(t *testing.T, conn driver.Conn) { + t.Helper() + ctx := context.Background() + asOf := time.Now().UTC().Truncate(time.Second) + start := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -13) + id := time.Now().UnixNano() + slug := fmt.Sprintf("homepage-%d", id) + messages := make([]domain.ChatMessage, 0, 5) + for i, created := range []time.Time{start.Add(-time.Millisecond), start, asOf, asOf.Add(time.Millisecond)} { + messages = append(messages, domain.ChatMessage{ + ID: id + int64(i), KickMessageID: fmt.Sprintf("%s-%d", slug, i), + ChannelID: id, ChannelKickID: id, ChannelSlug: slug, ChannelDisplayName: slug, + SenderID: id, SenderKickID: id, SenderSlug: slug, SenderUsername: slug, + MessageType: "message", Content: "bounded homepage fixture", + MessageCreatedAt: created, IngestedAt: asOf, + Emotes: []domain.ChatEmote{{ID: slug, Name: slug, Token: "[emote]", ImageURL: "/emote.png"}}, + }) + } + duplicate := messages[1] + duplicate.IngestedAt = asOf.Add(time.Second) + messages = append(messages, duplicate) + if err := clickhouseinfra.NewMessageRepository(conn).InsertMessagesBatch(ctx, messages); err != nil { + t.Fatal(err) + } + repo := clickhouseinfra.NewHomepageAnalyticsRepository(conn) + filter := domain.AnalyticsFilter{Start: start, End: asOf, Channel: slug} + overview, err := repo.Overview(ctx, filter) + if err != nil || overview.TotalMessages != 2 || overview.TotalSenders != 1 || overview.TotalChannels != 1 || overview.TotalEmoteUsages != 2 { + t.Fatalf("overview=%+v err=%v", overview, err) + } + volume, err := repo.MessageVolume(ctx, filter, domain.AnalyticsBucketDay) + if err != nil || len(volume) != 2 || volume[0].MessageCount != 1 || volume[1].MessageCount != 1 { + t.Fatalf("volume=%+v err=%v", volume, err) + } + channels, err := repo.TopChannels(ctx, filter, 5) + if err != nil || len(channels) != 1 || channels[0].MessageCount != 2 { + t.Fatalf("channels=%+v err=%v", channels, err) + } + senders, err := repo.TopSenders(ctx, filter, 5) + if err != nil || len(senders) != 1 || senders[0].MessageCount != 2 { + t.Fatalf("senders=%+v err=%v", senders, err) + } + emotes, err := repo.TopEmotes(ctx, filter, 5) + if err != nil || len(emotes) != 1 || emotes[0].UsageCount != 2 { + t.Fatalf("emotes=%+v err=%v", emotes, err) + } +} diff --git a/apps/api-go/internal/infra/clickhouse/profile_analytics.go b/apps/api-go/internal/infra/clickhouse/profile_analytics.go new file mode 100644 index 0000000..1df6e50 --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/profile_analytics.go @@ -0,0 +1,163 @@ +package clickhouse + +import ( + "context" + "fmt" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" +) + +var _ ports.ProfileSummaryRepository = (*AnalyticsRepository)(nil) + +// WITH TOTALS merges exact aggregate states across every group, before LIMIT. +// Keep raw nullable channel IDs separate from the ranking's null-to-zero identity. +const profileAggregateSQL = `count() AS message_count, + min(message_created_at) AS first_message_at, + max(message_created_at) AS latest_message_at, + uniqExactIf(sender_identity, sender_present) AS total_senders, + uniqExactIf(source_channel_id, isNotNull(source_channel_id)) AS total_channels, + sum(emote_count) AS total_emote_usages` + +func (repo *AnalyticsRepository) OverviewAndTopSenders( + ctx context.Context, + filter domain.AnalyticsFilter, + limit uint64, +) (domain.AnalyticsOverview, []domain.TopSenderAnalytics, error) { + where, args := analyticsWhere(filter) + query := fmt.Sprintf(`WITH argMax( + tuple(ifNull(sender_id, 0), ifNull(sender_kick_id, 0), sender_username, + sender_slug, ifNull(sender_profile_image_url, '')), + tuple(message_created_at, ingested_at, id) + ) AS profile_metadata + SELECT profile_metadata.1 AS profile_sender_id, + profile_metadata.2 AS profile_kick_user_id, + profile_metadata.3 AS profile_username, + profile_metadata.4 AS profile_slug, + profile_metadata.5 AS profile_image, + %s + FROM ( + SELECT %s AS sender_identity, + (ifNull(sender_kick_id, 0) > 0 OR sender_slug_lower != '' OR sender_username_lower != '') AS sender_present, + channel_id AS source_channel_id, emote_count, + id, sender_id, sender_kick_id, sender_username, sender_slug, + sender_profile_image_url, message_created_at, ingested_at + FROM chat_messages FINAL WHERE %s + ) + GROUP BY sender_identity WITH TOTALS + ORDER BY message_count DESC, latest_message_at DESC, profile_slug ASC + LIMIT ?`, profileAggregateSQL, senderIdentitySQL(), where) + args = append(args, limitOrDefault(limit)) + rows, err := repo.conn.Query(ctx, query, args...) + if err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("query profile overview and senders: %w", err) + } + defer rows.Close() + + senders := make([]domain.TopSenderAnalytics, 0) + var sender domain.TopSenderAnalytics + var aggregate profileAggregate + dest := aggregate.destinations(&sender.SenderID, &sender.KickUserID, &sender.Username, &sender.Slug, &sender.ProfileImageURL) + for rows.Next() { + if err := rows.Scan(dest...); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("scan profile sender: %w", err) + } + if sender.KickUserID > 0 { + sender.SenderID = sender.KickUserID + } + sender.MessageCount = int64(aggregate.messages) + sender.FirstMessageAt = aggregate.first.UTC() + sender.LatestMessageAt = aggregate.latest.UTC() + senders = append(senders, sender) + } + if err := rows.Err(); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("iterate profile senders: %w", err) + } + if err := rows.Totals(dest...); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("read profile sender totals: %w", err) + } + return aggregate.overview(), senders, nil +} + +func (repo *AnalyticsRepository) OverviewAndTopChannels( + ctx context.Context, + filter domain.AnalyticsFilter, + limit uint64, +) (domain.AnalyticsOverview, []domain.TopChannelAnalytics, error) { + where, args := analyticsWhere(filter) + query := fmt.Sprintf(`WITH argMax( + tuple(channel_slug, channel_display_name, ifNull(channel_profile_image_url, ''), + ifNull(channel_banner_image_url, '')), + tuple(message_created_at, ingested_at, id) + ) AS profile_metadata + SELECT group_channel_id, + profile_metadata.1 AS profile_slug, + profile_metadata.2 AS profile_display_name, + profile_metadata.3 AS profile_image, + profile_metadata.4 AS profile_banner, + %s + FROM ( + SELECT %s AS sender_identity, + (ifNull(sender_kick_id, 0) > 0 OR sender_slug_lower != '' OR sender_username_lower != '') AS sender_present, + channel_id AS source_channel_id, ifNull(channel_id, 0) AS group_channel_id, + emote_count, id, channel_slug, channel_display_name, + channel_profile_image_url, channel_banner_image_url, message_created_at, ingested_at + FROM chat_messages FINAL WHERE %s + ) + GROUP BY group_channel_id WITH TOTALS + ORDER BY message_count DESC, latest_message_at DESC, profile_slug ASC + LIMIT ?`, profileAggregateSQL, senderIdentitySQL(), where) + args = append(args, limitOrDefault(limit)) + rows, err := repo.conn.Query(ctx, query, args...) + if err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("query profile overview and channels: %w", err) + } + defer rows.Close() + + channels := make([]domain.TopChannelAnalytics, 0) + var channel domain.TopChannelAnalytics + var aggregate profileAggregate + dest := aggregate.destinations(&channel.ChannelID, &channel.Slug, &channel.DisplayName, &channel.ProfileImageURL, &channel.BannerImageURL) + for rows.Next() { + if err := rows.Scan(dest...); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("scan profile channel: %w", err) + } + channel.MessageCount = int64(aggregate.messages) + channel.FirstMessageAt = aggregate.first.UTC() + channel.LatestMessageAt = aggregate.latest.UTC() + channels = append(channels, channel) + } + if err := rows.Err(); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("iterate profile channels: %w", err) + } + if err := rows.Totals(dest...); err != nil { + return domain.AnalyticsOverview{}, nil, fmt.Errorf("read profile channel totals: %w", err) + } + return aggregate.overview(), channels, nil +} + +type profileAggregate struct { + messages uint64 + first time.Time + latest time.Time + senders uint64 + channels uint64 + emotes uint64 +} + +func (aggregate *profileAggregate) destinations(metadata ...any) []any { + return append(metadata, &aggregate.messages, &aggregate.first, &aggregate.latest, + &aggregate.senders, &aggregate.channels, &aggregate.emotes) +} + +func (aggregate profileAggregate) overview() domain.AnalyticsOverview { + result := domain.AnalyticsOverview{ + TotalMessages: int64(aggregate.messages), TotalSenders: int64(aggregate.senders), + TotalChannels: int64(aggregate.channels), TotalEmoteUsages: int64(aggregate.emotes), + } + if aggregate.messages > 0 { + result.FirstMessageAt, result.LatestMessageAt = aggregate.first.UTC(), aggregate.latest.UTC() + } + return result +} diff --git a/apps/api-go/internal/infra/clickhouse/profile_analytics_benchmark_test.go b/apps/api-go/internal/infra/clickhouse/profile_analytics_benchmark_test.go new file mode 100644 index 0000000..235e880 --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/profile_analytics_benchmark_test.go @@ -0,0 +1,115 @@ +package clickhouse_test + +import ( + "context" + "fmt" + "os" + "reflect" + "sync/atomic" + "testing" + "time" + + ch "github.com/ClickHouse/clickhouse-go/v2" + "github.com/YSelim0/kick-logs/apps/api-go/internal/config" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + clickhouseinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/clickhouse" +) + +// Explicitly opt in against existing data. This benchmark never migrates or inserts. +func BenchmarkProfileSummaries(b *testing.B) { + channel := os.Getenv("KICK_LOGS_PROFILE_BENCHMARK_CHANNEL") + sender := os.Getenv("KICK_LOGS_PROFILE_BENCHMARK_SENDER") + if channel == "" && sender == "" { + b.Skip("set KICK_LOGS_PROFILE_BENCHMARK_CHANNEL or KICK_LOGS_PROFILE_BENCHMARK_SENDER") + } + end, err := time.Parse(time.RFC3339, os.Getenv("KICK_LOGS_PROFILE_BENCHMARK_END")) + if err != nil { + b.Fatal("set KICK_LOGS_PROFILE_BENCHMARK_END to a fixed historical RFC3339 instant") + } + cfg, err := config.Load() + if err != nil { + b.Fatal(err) + } + conn, err := clickhouseinfra.Open(context.Background(), cfg) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = conn.Close() }) + repo := clickhouseinfra.NewAnalyticsRepository(conn) + for _, target := range []struct{ kind, slug string }{{"channel", channel}, {"user", sender}} { + if target.slug == "" { + continue + } + b.Run(target.kind, func(b *testing.B) { + filter := domain.AnalyticsFilter{End: end} + if target.kind == "channel" { + filter.Channel = target.slug + } else { + filter.Sender = target.slug + } + fetch := func(ctx context.Context, combined bool) (profileBenchmarkResult, error) { + var result profileBenchmarkResult + var err error + if combined { + if target.kind == "channel" { + result.overview, result.senders, err = repo.OverviewAndTopSenders(ctx, filter, 5) + } else { + result.overview, result.channels, err = repo.OverviewAndTopChannels(ctx, filter, 5) + } + return result, err + } + result.overview, err = repo.Overview(ctx, filter) + if err != nil { + return result, err + } + if target.kind == "channel" { + result.senders, err = repo.TopSenders(ctx, filter, 5) + } else { + result.channels, err = repo.TopChannels(ctx, filter, 5) + } + return result, err + } + var rows, bytes atomic.Uint64 + ctx := ch.Context(context.Background(), ch.WithSettings(ch.Settings{ + "max_threads": 1, "max_memory_usage": 256 * 1024 * 1024, + "max_execution_time": 10, "use_query_cache": 0, + }), ch.WithProgress(func(progress *ch.Progress) { + rows.Add(progress.Rows) + bytes.Add(progress.Bytes) + })) + before, err := fetch(ctx, false) + if err != nil { + b.Fatal(err) + } + after, err := fetch(ctx, true) + if err != nil || !reflect.DeepEqual(before, after) { + b.Fatalf("results differ (or source data changed during comparison): err=%v", err) + } + if before.overview.TotalMessages == 0 { + b.Fatal("selected profile has no matching messages; choose a representative profile") + } + b.Logf("equal results: messages=%d", before.overview.TotalMessages) + for _, combined := range []bool{false, true} { + b.Run(fmt.Sprintf("combined=%t", combined), func(b *testing.B) { + rows.Store(0) + bytes.Store(0) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := fetch(ctx, combined); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + b.ReportMetric(float64(rows.Load())/float64(b.N), "read-rows/op") + b.ReportMetric(float64(bytes.Load())/float64(b.N), "read-bytes/op") + }) + } + }) + } +} + +type profileBenchmarkResult struct { + overview domain.AnalyticsOverview + senders []domain.TopSenderAnalytics + channels []domain.TopChannelAnalytics +} diff --git a/apps/api-go/internal/infra/clickhouse/profile_analytics_integration_test.go b/apps/api-go/internal/infra/clickhouse/profile_analytics_integration_test.go new file mode 100644 index 0000000..6948100 --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/profile_analytics_integration_test.go @@ -0,0 +1,149 @@ +package clickhouse_test + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + clickhouseinfra "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/clickhouse" +) + +type profileSummaryReader interface { + OverviewAndTopSenders(context.Context, domain.AnalyticsFilter, uint64) (domain.AnalyticsOverview, []domain.TopSenderAnalytics, error) + OverviewAndTopChannels(context.Context, domain.AnalyticsFilter, uint64) (domain.AnalyticsOverview, []domain.TopChannelAnalytics, error) +} + +func testProfileCombinedAnalytics(t *testing.T, conn driver.Conn) { + ctx := context.Background() + counted := &profileCountingConnection{Conn: conn} + optimized, ok := any(clickhouseinfra.NewAnalyticsRepository(counted)).(profileSummaryReader) + if !ok { + t.Fatal("ClickHouse repository must provide single-query profile summaries") + } + id := time.Now().UnixNano() + prefix := fmt.Sprintf("profile-summary-%d", id) + created := time.Date(2020, 1, 2, 3, 0, 0, 0, time.UTC) + ingested := time.Now().UTC().Truncate(time.Millisecond) + messages := make([]domain.ChatMessage, 0, 34) + for mode := range 2 { + for i := range 8 { + for j := range 2 { + message := domain.ChatMessage{ + ID: id + int64(len(messages)), KickMessageID: fmt.Sprintf("%s-%d", prefix, len(messages)), + ChannelID: id + int64(i), ChannelSlug: fmt.Sprintf("%s-channel-%d", prefix, i), + ChannelDisplayName: fmt.Sprintf("Channel %d", i), ChannelProfileImageURL: "/channel.png", + ChannelBannerImageURL: "/banner.png", SenderID: id + int64(i) + 100, + SenderKickID: id + int64(i) + 200, SenderSlug: fmt.Sprintf("%s-sender-%d", prefix, i), + SenderUsername: fmt.Sprintf("Sender %d", i), SenderProfileImageURL: "/sender.png", + MessageCreatedAt: created.Add(time.Duration(j) * time.Hour), IngestedAt: ingested, + Content: "historical profile fixture", + Emotes: []domain.ChatEmote{{ID: prefix, Name: "wave"}, {ID: prefix, Name: "wave"}}, + } + if mode == 0 { + message.ChannelID, message.ChannelSlug = id, prefix + if i >= 5 { + message.SenderKickID = 0 + message.SenderProfileImageURL = "" + } + if i >= 6 { + message.SenderSlug = "" + } + if i == 7 { + message.SenderUsername = "" + } + } else { + message.SenderKickID, message.SenderSlug = id, strings.ReplaceAll(prefix, "-", "_")+"_user" + message.SenderUsername = strings.ToUpper(message.SenderSlug) + if i == 5 || i == 6 { + message.ChannelID = 0 + message.ChannelProfileImageURL, message.ChannelBannerImageURL = "", "" + } + } + messages = append(messages, message) + } + } + } + for _, index := range []int{1, 17} { + duplicate := messages[index] + duplicate.IngestedAt = ingested.Add(time.Second) + duplicate.ChannelDisplayName = "Updated channel" + if index == 1 { + duplicate.SenderSlug = prefix + "-renamed" + duplicate.SenderUsername = "Renamed user" + } + messages = append(messages, duplicate) + } + if err := clickhouseinfra.NewMessageRepository(conn).InsertMessagesBatch(ctx, messages); err != nil { + t.Fatal(err) + } + // Add newer tombstones without mutating shared fixtures or deleting history. + for _, index := range []int{3, 19} { + if err := conn.Exec(ctx, `INSERT INTO chat_messages SELECT * REPLACE ( + ingested_at + INTERVAL 2 SECOND AS ingested_at, toUInt8(1) AS is_deleted + ) FROM chat_messages FINAL WHERE id = ?`, messages[index].ID); err != nil { + t.Fatal(err) + } + } + legacy := clickhouseinfra.NewAnalyticsRepository(conn) + for _, mode := range []string{"channel", "user", "empty", "empty-user"} { + for _, limit := range []uint64{1, 5, 0} { + t.Run(fmt.Sprintf("%s/limit-%d", mode, limit), func(t *testing.T) { + filter := domain.AnalyticsFilter{Channel: prefix} + if mode == "user" { + filter = domain.AnalyticsFilter{Sender: strings.ToUpper(prefix + "-user")} + } else if mode == "empty" { + filter.Channel += "-missing" + } else if mode == "empty-user" { + filter = domain.AnalyticsFilter{Sender: prefix + "-missing-user"} + } + want, err := legacy.Overview(ctx, filter) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(mode, "empty") && (want.TotalMessages != 15 || want.TotalEmoteUsages != 30 || !want.FirstMessageAt.Equal(created)) { + t.Fatalf("fixture overview=%+v; want 15 historical messages and 30 emote usages", want) + } + counted.queries = 0 + var got domain.AnalyticsOverview + if mode == "user" || mode == "empty-user" { + var top []domain.TopChannelAnalytics + got, top, err = optimized.OverviewAndTopChannels(ctx, filter, limit) + oldTop, oldErr := legacy.TopChannels(ctx, filter, limit) + if oldErr != nil || !reflect.DeepEqual(top, oldTop) { + t.Fatalf("channel ranking changed: got=%+v want=%+v err=%v", top, oldTop, oldErr) + } + } else { + var top []domain.TopSenderAnalytics + got, top, err = optimized.OverviewAndTopSenders(ctx, filter, limit) + oldTop, oldErr := legacy.TopSenders(ctx, filter, limit) + if oldErr != nil || !reflect.DeepEqual(top, oldTop) { + t.Fatalf("sender ranking changed: got=%+v want=%+v err=%v", top, oldTop, oldErr) + } + } + if err != nil || !reflect.DeepEqual(got, want) || counted.queries != 1 { + t.Fatalf("got=%+v want=%+v queries=%d err=%v", got, want, counted.queries, err) + } + }) + } + } +} + +type profileCountingConnection struct { + driver.Conn + queries int +} + +func (conn *profileCountingConnection) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) { + conn.queries++ + return conn.Conn.Query(ctx, query, args...) +} + +func (conn *profileCountingConnection) QueryRow(ctx context.Context, query string, args ...any) driver.Row { + conn.queries++ + return conn.Conn.QueryRow(ctx, query, args...) +} diff --git a/apps/api-go/internal/infra/clickhouse/repositories_test.go b/apps/api-go/internal/infra/clickhouse/repositories_test.go index 0fa3612..8134719 100644 --- a/apps/api-go/internal/infra/clickhouse/repositories_test.go +++ b/apps/api-go/internal/infra/clickhouse/repositories_test.go @@ -35,6 +35,8 @@ func TestClickHouseMigrationsAndRepositories(t *testing.T) { if err := migrations.ApplyClickHouse(ctx, conn); err != nil { t.Fatalf("ApplyClickHouse() second run error = %v", err) } + t.Run("homepage bounded analytics", func(t *testing.T) { testHomepageAnalytics(t, conn) }) + t.Run("profile combined analytics", func(t *testing.T) { testProfileCombinedAnalytics(t, conn) }) baseID := time.Now().UnixNano() baseTime := time.Now().UTC().Truncate(time.Millisecond) @@ -607,4 +609,130 @@ func TestClickHouseMigrationsAndRepositories(t *testing.T) { if summary.ActiveGiftedCount != 1 { t.Fatalf("ActiveGiftedCount = %d, want 1", summary.ActiveGiftedCount) } + + activeSubscribers, err := subPeriodRepo.ListActiveSubscribers(ctx, domain.ChannelSubscriberFilter{ + FollowedChannelID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("subPeriodRepo.ListActiveSubscribers() error = %v", err) + } + if activeSubscribers.Count != 2 || len(activeSubscribers.Items) != 2 { + t.Fatalf("active subscribers = %#v", activeSubscribers) + } + for _, subscriber := range activeSubscribers.Items { + if strings.HasPrefix(subscriber.Username, "expired_") { + t.Fatalf("expired subscriber returned = %#v", activeSubscribers.Items) + } + } + + giftSubscribers, err := subPeriodRepo.ListActiveSubscribers(ctx, domain.ChannelSubscriberFilter{ + FollowedChannelID: 1, + GiftOnly: true, + Limit: 10, + }) + if err != nil { + t.Fatalf("subPeriodRepo.ListActiveSubscribers(gift) error = %v", err) + } + if giftSubscribers.Count != 1 || len(giftSubscribers.Items) != 1 || !giftSubscribers.Items[0].IsGift { + t.Fatalf("gift subscribers = %#v", giftSubscribers) + } + if giftSubscribers.Items[0].GifterUsername == "" { + t.Fatalf("gift subscriber missing gifter = %#v", giftSubscribers.Items[0]) + } + + exportedSubscribers, err := subPeriodRepo.ExportActiveSubscribers(ctx, 1, false) + if err != nil { + t.Fatalf("subPeriodRepo.ExportActiveSubscribers() error = %v", err) + } + if len(exportedSubscribers) != 2 { + t.Fatalf("exported subscribers = %#v", exportedSubscribers) + } + + userRequestRepo := clickhouseinfra.NewUserRequestRepository(conn) + userRequest := domain.UserRequest{ + ID: "request-" + suffix, + Type: domain.UserRequestTypeChannelRequest, + Title: "Channel request " + suffix, + Message: "Please track this channel " + suffix, + ChannelSlug: "hype-" + suffix, + ChannelDisplayName: "Hype " + suffix, + Contact: "mod-" + suffix + "@example.com", + IPHash: "iphash-" + suffix, + UserAgentHash: "uahash-" + suffix, + CreatedAt: now, + } + if err := userRequestRepo.Create(ctx, userRequest); err != nil { + t.Fatalf("userRequestRepo.Create() error = %v", err) + } + if err := userRequestRepo.AppendEvent(ctx, domain.UserRequestEvent{ + ID: "event-status-" + suffix, + RequestID: userRequest.ID, + EventType: domain.UserRequestEventStatusChanged, + Status: domain.UserRequestStatusReviewing, + AdminID: 1, + CreatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("userRequestRepo.AppendEvent(status) error = %v", err) + } + if err := userRequestRepo.AppendEvent(ctx, domain.UserRequestEvent{ + ID: "event-note-" + suffix, + RequestID: userRequest.ID, + EventType: domain.UserRequestEventNoteAdded, + Note: "review note " + suffix, + AdminID: 1, + CreatedAt: now.Add(2 * time.Minute), + }); err != nil { + t.Fatalf("userRequestRepo.AppendEvent(note) error = %v", err) + } + if err := userRequestRepo.AppendEvent(ctx, domain.UserRequestEvent{ + ID: "event-archive-" + suffix, + RequestID: userRequest.ID, + EventType: domain.UserRequestEventArchived, + AdminID: 1, + CreatedAt: now.Add(3 * time.Minute), + }); err != nil { + t.Fatalf("userRequestRepo.AppendEvent(archive) error = %v", err) + } + + requestState, err := userRequestRepo.Get(ctx, userRequest.ID) + if err != nil { + t.Fatalf("userRequestRepo.Get() error = %v", err) + } + if requestState.CurrentStatus != domain.UserRequestStatusReviewing || !requestState.IsArchived { + t.Fatalf("request state = %#v", requestState) + } + if requestState.Request.ChannelSlug != userRequest.ChannelSlug || requestState.Request.Contact != userRequest.Contact { + t.Fatalf("request payload = %#v", requestState.Request) + } + + archived := true + requests, err := userRequestRepo.List(ctx, domain.UserRequestListFilter{ + Type: domain.UserRequestTypeChannelRequest, + Status: domain.UserRequestStatusReviewing, + Archived: &archived, + Query: suffix, + Limit: 10, + }) + if err != nil { + t.Fatalf("userRequestRepo.List() error = %v", err) + } + foundRequest := false + for _, item := range requests { + if item.Request.ID == userRequest.ID { + foundRequest = true + break + } + } + if !foundRequest { + t.Fatalf("created request not found in list = %#v", requests) + } + + events, err := userRequestRepo.ListEvents(ctx, userRequest.ID) + if err != nil { + t.Fatalf("userRequestRepo.ListEvents() error = %v", err) + } + if len(events) != 3 || events[0].Status != domain.UserRequestStatusReviewing || events[1].Note == "" { + t.Fatalf("request events = %#v", events) + } } diff --git a/apps/api-go/internal/infra/clickhouse/subscription_periods.go b/apps/api-go/internal/infra/clickhouse/subscription_periods.go index 4e8c6a8..7e89138 100644 --- a/apps/api-go/internal/infra/clickhouse/subscription_periods.go +++ b/apps/api-go/internal/infra/clickhouse/subscription_periods.go @@ -95,6 +95,157 @@ func (r *SubscriptionPeriodRepository) ActiveSummary(ctx context.Context, follow return summary, nil } +func (r *SubscriptionPeriodRepository) ListActiveSubscribers( + ctx context.Context, + filter domain.ChannelSubscriberFilter, +) (domain.ChannelSubscriberPage, error) { + if filter.Limit == 0 { + filter.Limit = 50 + } + + query := activeSubscribersQuery(filter.GiftOnly, true) + rows, err := r.conn.Query(ctx, query, filter.FollowedChannelID, filter.Limit, filter.Offset) + if err != nil { + return domain.ChannelSubscriberPage{}, fmt.Errorf("query active subscribers: %w", err) + } + defer rows.Close() + + items := make([]domain.ChannelSubscriber, 0, int(filter.Limit)) + var total uint64 + for rows.Next() { + item, rowTotal, err := scanChannelSubscriber(rows, true) + if err != nil { + return domain.ChannelSubscriberPage{}, err + } + total = rowTotal + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.ChannelSubscriberPage{}, fmt.Errorf("iterate active subscribers: %w", err) + } + + return domain.ChannelSubscriberPage{ + Items: items, + Count: uint64ToInt64(total), + Limit: filter.Limit, + Offset: filter.Offset, + }, nil +} + +func (r *SubscriptionPeriodRepository) ExportActiveSubscribers( + ctx context.Context, + followedChannelID int64, + giftOnly bool, +) ([]domain.ChannelSubscriber, error) { + query := activeSubscribersQuery(giftOnly, false) + rows, err := r.conn.Query(ctx, query, followedChannelID) + if err != nil { + return nil, fmt.Errorf("query active subscriber export: %w", err) + } + defer rows.Close() + + items := make([]domain.ChannelSubscriber, 0) + for rows.Next() { + item, _, err := scanChannelSubscriber(rows, false) + if err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate active subscriber export: %w", err) + } + return items, nil +} + +func activeSubscribersQuery(giftOnly bool, paginated bool) string { + giftClause := "" + if giftOnly { + giftClause = " AND is_gift = 1" + } + totalColumn := "" + if paginated { + totalColumn = ", count() OVER() AS total_count" + } + limitClause := "" + if paginated { + limitClause = " LIMIT ? OFFSET ?" + } + return fmt.Sprintf(` + SELECT + subscriber_kick_user_id, + subscriber_username, + subscriber_slug, + subscriber_profile_image_url, + is_gift, + gifter_kick_user_id, + gifter_username, + gifter_slug, + gifter_profile_image_url, + started_at, + expires_at%s + FROM ( + SELECT + subscriber_kick_user_id, + subscriber_username, + subscriber_slug, + subscriber_profile_image_url, + toUInt8(is_gift) AS is_gift, + ifNull(gifter_kick_user_id, 0) AS gifter_kick_user_id, + ifNull(gifter_username, '') AS gifter_username, + ifNull(gifter_slug, '') AS gifter_slug, + ifNull(gifter_profile_image_url, '') AS gifter_profile_image_url, + started_at, + expires_at + FROM channel_subscription_periods FINAL + WHERE followed_channel_id = ? AND expires_at > now()%s + ORDER BY + subscriber_kick_user_id ASC, + expires_at DESC, + started_at DESC, + ingested_at DESC + LIMIT 1 BY subscriber_kick_user_id + ) + ORDER BY started_at DESC, subscriber_username ASC%s`, + totalColumn, + giftClause, + limitClause, + ) +} + +type channelSubscriberScanner interface { + Scan(dest ...any) error +} + +func scanChannelSubscriber(row channelSubscriberScanner, withTotal bool) (domain.ChannelSubscriber, uint64, error) { + var item domain.ChannelSubscriber + var isGift uint8 + var total uint64 + dest := []any{ + &item.SubscriberKickUserID, + &item.Username, + &item.Slug, + &item.ProfileImageURL, + &isGift, + &item.GifterKickUserID, + &item.GifterUsername, + &item.GifterSlug, + &item.GifterProfileImageURL, + &item.StartedAt, + &item.ExpiresAt, + } + if withTotal { + dest = append(dest, &total) + } + if err := row.Scan(dest...); err != nil { + return domain.ChannelSubscriber{}, 0, fmt.Errorf("scan active subscriber: %w", err) + } + item.IsGift = isGift == 1 + item.StartedAt = item.StartedAt.UTC() + item.ExpiresAt = item.ExpiresAt.UTC() + return item, total, nil +} + func uint64ToInt64(value uint64) int64 { if value > math.MaxInt64 { return math.MaxInt64 diff --git a/apps/api-go/internal/infra/clickhouse/user_requests.go b/apps/api-go/internal/infra/clickhouse/user_requests.go new file mode 100644 index 0000000..b0a3f74 --- /dev/null +++ b/apps/api-go/internal/infra/clickhouse/user_requests.go @@ -0,0 +1,291 @@ +package clickhouse + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +type UserRequestRepository struct { + conn driver.Conn +} + +func NewUserRequestRepository(conn driver.Conn) *UserRequestRepository { + return &UserRequestRepository{conn: conn} +} + +func (repo *UserRequestRepository) Create(ctx context.Context, request domain.UserRequest) error { + batch, err := repo.conn.PrepareBatch(ctx, `INSERT INTO user_requests ( + request_id, type, title, title_lower, message, message_lower, + channel_slug, channel_slug_lower, channel_display_name, channel_display_name_lower, + contact, contact_lower, ip_hash, user_agent_hash, created_at + )`) + if err != nil { + return fmt.Errorf("prepare user request insert: %w", err) + } + + if err := batch.Append( + request.ID, + string(request.Type), + request.Title, + strings.ToLower(request.Title), + request.Message, + strings.ToLower(request.Message), + nullableString(request.ChannelSlug), + strings.ToLower(request.ChannelSlug), + nullableString(request.ChannelDisplayName), + strings.ToLower(request.ChannelDisplayName), + nullableString(request.Contact), + strings.ToLower(request.Contact), + nullableString(request.IPHash), + nullableString(request.UserAgentHash), + request.CreatedAt.UTC(), + ); err != nil { + return fmt.Errorf("append user request: %w", err) + } + + if err := batch.Send(); err != nil { + return fmt.Errorf("send user request insert: %w", err) + } + return nil +} + +func (repo *UserRequestRepository) List( + ctx context.Context, + filter domain.UserRequestListFilter, +) ([]domain.UserRequestState, error) { + limit := filter.Limit + if limit == 0 { + limit = 50 + } + + where, args := userRequestWhere(filter) + query := userRequestProjectionSQL(where + ` + ORDER BY created_at DESC, request_id DESC + LIMIT ? OFFSET ?`) + args = append(args, limit, filter.Offset) + + rows, err := repo.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list user requests: %w", err) + } + defer rows.Close() + + states := make([]domain.UserRequestState, 0, limit) + for rows.Next() { + state, err := scanUserRequestState(rows) + if err != nil { + return nil, err + } + states = append(states, state) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate user requests: %w", err) + } + return states, nil +} + +func (repo *UserRequestRepository) Get(ctx context.Context, requestID string) (domain.UserRequestState, error) { + query := userRequestProjectionSQL("AND request_id = ?") + state, err := scanUserRequestState(repo.conn.QueryRow(ctx, query, requestID)) + if err != nil { + if err == sql.ErrNoRows { + return domain.UserRequestState{}, err + } + return domain.UserRequestState{}, fmt.Errorf("get user request: %w", err) + } + return state, nil +} + +func (repo *UserRequestRepository) ListEvents(ctx context.Context, requestID string) ([]domain.UserRequestEvent, error) { + rows, err := repo.conn.Query(ctx, ` + SELECT event_id, request_id, event_type, status, note, ifNull(admin_id, 0), created_at + FROM user_request_events + WHERE request_id = ? + ORDER BY created_at ASC, event_id ASC`, + requestID, + ) + if err != nil { + return nil, fmt.Errorf("list user request events: %w", err) + } + defer rows.Close() + + events := make([]domain.UserRequestEvent, 0) + for rows.Next() { + event, err := scanUserRequestEvent(rows) + if err != nil { + return nil, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate user request events: %w", err) + } + return events, nil +} + +func (repo *UserRequestRepository) AppendEvent(ctx context.Context, event domain.UserRequestEvent) error { + batch, err := repo.conn.PrepareBatch(ctx, `INSERT INTO user_request_events ( + event_id, request_id, event_type, status, note, admin_id, created_at + )`) + if err != nil { + return fmt.Errorf("prepare user request event insert: %w", err) + } + + if err := batch.Append( + event.ID, + event.RequestID, + string(event.EventType), + string(event.Status), + event.Note, + nullableInt64(event.AdminID), + event.CreatedAt.UTC(), + ); err != nil { + return fmt.Errorf("append user request event: %w", err) + } + + if err := batch.Send(); err != nil { + return fmt.Errorf("send user request event insert: %w", err) + } + return nil +} + +func userRequestProjectionSQL(suffix string) string { + return fmt.Sprintf(` + SELECT + request_id, type, title, message, channel_slug, channel_display_name, + contact, ip_hash, user_agent_hash, created_at, + current_status, is_archived, latest_event_at + FROM ( + SELECT + r.request_id AS request_id, + r.type AS type, + r.title AS title, + r.message AS message, + ifNull(r.channel_slug, '') AS channel_slug, + ifNull(r.channel_display_name, '') AS channel_display_name, + ifNull(r.contact, '') AS contact, + ifNull(r.ip_hash, '') AS ip_hash, + ifNull(r.user_agent_hash, '') AS user_agent_hash, + r.created_at AS created_at, + if(ifNull(e.latest_status, '') = '', 'new', ifNull(e.latest_status, '')) AS current_status, + toUInt8(ifNull(e.is_archived, 0)) AS is_archived, + ifNull(e.latest_event_at, r.created_at) AS latest_event_at + FROM user_requests AS r + LEFT JOIN ( + SELECT + request_id, + argMaxIf(status, created_at, event_type = 'status_changed' AND status != '') AS latest_status, + countIf(event_type = 'archived') > 0 AS is_archived, + max(created_at) AS latest_event_at + FROM user_request_events + GROUP BY request_id + ) AS e ON e.request_id = r.request_id + ) + WHERE 1 = 1 + %s`, suffix) +} + +func userRequestWhere(filter domain.UserRequestListFilter) (string, []any) { + where := make([]string, 0, 6) + args := make([]any, 0, 8) + + if filter.Type != "" { + where = append(where, "type = ?") + args = append(args, string(filter.Type)) + } + if filter.Status != "" { + where = append(where, "current_status = ?") + args = append(args, string(filter.Status)) + } + if filter.Archived != nil { + archived := uint8(0) + if *filter.Archived { + archived = 1 + } + where = append(where, "is_archived = ?") + args = append(args, archived) + } + if filter.Query != "" { + query := strings.TrimSpace(filter.Query) + where = append(where, `(positionCaseInsensitive(title, ?) > 0 + OR positionCaseInsensitive(message, ?) > 0 + OR positionCaseInsensitive(channel_slug, ?) > 0 + OR positionCaseInsensitive(channel_display_name, ?) > 0 + OR positionCaseInsensitive(contact, ?) > 0)`) + args = append(args, query, query, query, query, query) + } + if !filter.Start.IsZero() { + where = append(where, "created_at >= ?") + args = append(args, filter.Start.UTC()) + } + if !filter.End.IsZero() { + where = append(where, "created_at <= ?") + args = append(args, filter.End.UTC()) + } + + if len(where) == 0 { + return "", args + } + return "AND " + strings.Join(where, " AND "), args +} + +type userRequestStateScanner interface { + Scan(dest ...any) error +} + +func scanUserRequestState(scanner userRequestStateScanner) (domain.UserRequestState, error) { + var state domain.UserRequestState + var requestType string + var currentStatus string + var isArchived uint8 + if err := scanner.Scan( + &state.Request.ID, + &requestType, + &state.Request.Title, + &state.Request.Message, + &state.Request.ChannelSlug, + &state.Request.ChannelDisplayName, + &state.Request.Contact, + &state.Request.IPHash, + &state.Request.UserAgentHash, + &state.Request.CreatedAt, + ¤tStatus, + &isArchived, + &state.LatestEventAt, + ); err != nil { + return domain.UserRequestState{}, err + } + state.Request.Type = domain.UserRequestType(requestType) + state.CurrentStatus = domain.UserRequestStatus(currentStatus) + state.IsArchived = isArchived == 1 + return state, nil +} + +type userRequestEventScanner interface { + Scan(dest ...any) error +} + +func scanUserRequestEvent(scanner userRequestEventScanner) (domain.UserRequestEvent, error) { + var event domain.UserRequestEvent + var eventType string + var status string + if err := scanner.Scan( + &event.ID, + &event.RequestID, + &eventType, + &status, + &event.Note, + &event.AdminID, + &event.CreatedAt, + ); err != nil { + return domain.UserRequestEvent{}, fmt.Errorf("scan user request event: %w", err) + } + event.EventType = domain.UserRequestEventType(eventType) + event.Status = domain.UserRequestStatus(status) + return event, nil +} diff --git a/apps/api-go/internal/infra/migrations/clickhouse.go b/apps/api-go/internal/infra/migrations/clickhouse.go index 900dafd..aada3df 100644 --- a/apps/api-go/internal/infra/migrations/clickhouse.go +++ b/apps/api-go/internal/infra/migrations/clickhouse.go @@ -146,5 +146,43 @@ func ClickHouseMigrations() []ClickHouseMigration { ORDER BY id;`, }, }, + { + Version: 6, + Name: "create_user_request_tables", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS user_requests ( + request_id String, + type LowCardinality(String), + title String, + title_lower String, + message String, + message_lower String, + channel_slug Nullable(String), + channel_slug_lower String DEFAULT '', + channel_display_name Nullable(String), + channel_display_name_lower String DEFAULT '', + contact Nullable(String), + contact_lower String DEFAULT '', + ip_hash Nullable(String), + user_agent_hash Nullable(String), + created_at DateTime64(3, 'UTC') + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY (created_at, request_id);`, + `CREATE TABLE IF NOT EXISTS user_request_events ( + event_id String, + request_id String, + event_type LowCardinality(String), + status String DEFAULT '', + note String DEFAULT '', + admin_id Nullable(Int64), + created_at DateTime64(3, 'UTC') + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY (request_id, created_at, event_id);`, + }, + }, } } diff --git a/apps/api-go/internal/infra/migrations/sqlite.go b/apps/api-go/internal/infra/migrations/sqlite.go index 3de4377..e9b5a76 100644 --- a/apps/api-go/internal/infra/migrations/sqlite.go +++ b/apps/api-go/internal/infra/migrations/sqlite.go @@ -188,5 +188,6 @@ func SQLiteMigrations() []SQLiteMigration { `DELETE FROM raw_event_queue WHERE status = 'processed';`, }, }, + directorySQLiteMigration(), } } diff --git a/apps/api-go/internal/infra/migrations/sqlite_directory.go b/apps/api-go/internal/infra/migrations/sqlite_directory.go new file mode 100644 index 0000000..778c1d6 --- /dev/null +++ b/apps/api-go/internal/infra/migrations/sqlite_directory.go @@ -0,0 +1,14 @@ +package migrations + +func directorySQLiteMigration() SQLiteMigration { + return SQLiteMigration{ + Version: 9, + Name: "index_identity_directory_prefixes", + Statements: []string{ + `CREATE INDEX idx_sender_profiles_directory_name ON sender_profiles (lower(replace(trim(username), '_', '-')), id);`, + `CREATE INDEX idx_sender_profiles_directory_slug ON sender_profiles (lower(replace(trim(slug), '_', '-')), id);`, + `CREATE INDEX idx_followed_channels_directory_name ON followed_channels (lower(replace(trim(display_name), '_', '-')), id);`, + `CREATE INDEX idx_followed_channels_directory_slug ON followed_channels (lower(replace(trim(slug), '_', '-')), id);`, + }, + } +} diff --git a/apps/api-go/internal/infra/snapshots/homepage.go b/apps/api-go/internal/infra/snapshots/homepage.go new file mode 100644 index 0000000..f3b86ce --- /dev/null +++ b/apps/api-go/internal/infra/snapshots/homepage.go @@ -0,0 +1,68 @@ +package snapshots + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +const maxSnapshotBytes = 1024 * 1024 + +type HomepageStore struct{ path string } + +func NewHomepageStore(path string) *HomepageStore { return &HomepageStore{path: path} } + +func (store *HomepageStore) Load() (domain.HomepageSnapshot, error) { + file, err := os.Open(store.path) + if err != nil { + return domain.HomepageSnapshot{}, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxSnapshotBytes+1)) + if err != nil { + return domain.HomepageSnapshot{}, err + } + if len(data) > maxSnapshotBytes { + return domain.HomepageSnapshot{}, errors.New("homepage snapshot exceeds size limit") + } + var snapshot domain.HomepageSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return domain.HomepageSnapshot{}, err + } + return snapshot, nil +} + +func (store *HomepageStore) Save(snapshot domain.HomepageSnapshot) error { + data, err := json.Marshal(snapshot) + if err != nil { + return err + } + if len(data) > maxSnapshotBytes { + return errors.New("homepage snapshot exceeds size limit") + } + directory := filepath.Dir(store.path) + if err := os.MkdirAll(directory, 0700); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".homepage-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + // Same-directory rename never exposes a partially written snapshot to startup. + return os.Rename(file.Name(), store.path) +} diff --git a/apps/api-go/internal/infra/snapshots/homepage_test.go b/apps/api-go/internal/infra/snapshots/homepage_test.go new file mode 100644 index 0000000..c76462a --- /dev/null +++ b/apps/api-go/internal/infra/snapshots/homepage_test.go @@ -0,0 +1,42 @@ +package snapshots + +import ( + "os" + "path/filepath" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +func TestHomepageStoreRoundTripAndReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache", "homepage.json") + store := NewHomepageStore(path) + if _, err := store.Load(); !os.IsNotExist(err) { + t.Fatalf("missing cache: %v", err) + } + for _, value := range []int64{12, 25} { + if err := store.Save(domain.HomepageSnapshot{Version: 1, Overview: domain.AnalyticsOverview{TotalMessages: value}}); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || loaded.Overview.TotalMessages != value { + t.Fatalf("round trip = %+v, %v", loaded, err) + } + } + files, err := os.ReadDir(filepath.Dir(path)) + if err != nil || len(files) != 1 { + t.Fatalf("temporary files leaked: %v %v", files, err) + } +} + +func TestHomepageStoreRejectsCorruptAndOversizedFiles(t *testing.T) { + for _, data := range [][]byte{[]byte("not json"), make([]byte, 1024*1024+1)} { + path := filepath.Join(t.TempDir(), "homepage.json") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := NewHomepageStore(path).Load(); err == nil { + t.Fatal("accepted invalid cache") + } + } +} diff --git a/apps/api-go/internal/infra/sqlite/directory.go b/apps/api-go/internal/infra/sqlite/directory.go new file mode 100644 index 0000000..4a0c309 --- /dev/null +++ b/apps/api-go/internal/infra/sqlite/directory.go @@ -0,0 +1,85 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" +) + +type DirectoryRepository struct{ db *sql.DB } + +var _ ports.DirectoryRepository = (*DirectoryRepository)(nil) + +func NewDirectoryRepository(db *sql.DB) *DirectoryRepository { + return &DirectoryRepository{db: db} +} + +func (repo *DirectoryRepository) Search(ctx context.Context, kind domain.DirectoryKind, query domain.DirectoryQuery) ([]domain.DirectoryIdentity, error) { + statement, args, err := directoryStatement(kind, query) + if err != nil { + return nil, err + } + rows, err := repo.db.QueryContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("search directory: %w", err) + } + defer rows.Close() + items := make([]domain.DirectoryIdentity, 0, query.Limit) + for rows.Next() { + var item domain.DirectoryIdentity + if err := rows.Scan(&item.ID, &item.Name, &item.Slug, &item.ProfileImageURL, &item.SortSlug); err != nil { + return nil, fmt.Errorf("scan directory identity: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate directory: %w", err) + } + return items, nil +} + +func directoryStatement(kind domain.DirectoryKind, query domain.DirectoryQuery) (string, []any, error) { + var table, name string + switch kind { + case domain.DirectoryUsers: + table, name = "sender_profiles", "username" + case domain.DirectoryChannels: + table, name = "followed_channels", "display_name" + default: + return "", nil, fmt.Errorf("invalid directory kind") + } + if query.Limit < 1 || query.Limit > 101 || query.Prefix == "" { + return "", nil, fmt.Errorf("invalid directory page") + } + normalizedName := "lower(replace(trim(" + name + "), '_', '-'))" + normalizedSlug := "lower(replace(trim(slug), '_', '-'))" + pattern := strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(query.Prefix) + "%" + upper := directoryPrefixUpperBound(query.Prefix) + // Separate indexed ranges prevent ORDER BY from choosing a full metadata-table + // scan. UNION deduplicates identities matching both their name and their slug. + statement := fmt.Sprintf(`WITH matches AS ( + SELECT id FROM %s WHERE %s >= ? AND %s < ? AND %s LIKE ? ESCAPE '\' + UNION + SELECT id FROM %s WHERE %s >= ? AND %s < ? AND %s LIKE ? ESCAPE '\' + ) + SELECT identity.id, %s, slug, profile_image_url, %s AS sort_slug + FROM matches JOIN %s AS identity ON identity.id = matches.id + WHERE (%s, identity.id) > (?, ?) + ORDER BY sort_slug ASC, identity.id ASC LIMIT ?`, + table, normalizedName, normalizedName, normalizedName, + table, normalizedSlug, normalizedSlug, normalizedSlug, + name, normalizedSlug, table, normalizedSlug) + return statement, []any{query.Prefix, upper, pattern, query.Prefix, upper, pattern, query.After.Slug, query.After.ID, query.Limit}, nil +} + +// SQLite BINARY order compares UTF-8 bytes; the exclusive successor bounds all +// strings beginning with prefix, including literal percent and underscore input. +func directoryPrefixUpperBound(prefix string) string { + upper := []byte(prefix) + upper[len(upper)-1]++ + return string(upper) +} diff --git a/apps/api-go/internal/infra/sqlite/directory_test.go b/apps/api-go/internal/infra/sqlite/directory_test.go new file mode 100644 index 0000000..f83c998 --- /dev/null +++ b/apps/api-go/internal/infra/sqlite/directory_test.go @@ -0,0 +1,126 @@ +package sqlite + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/infra/migrations" +) + +func directoryTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(context.Background(), filepath.Join(t.TempDir(), "directory.sqlite")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestDirectoryMigrationIndexesExistingMetadata(t *testing.T) { + db := directoryTestDB(t) + ctx := context.Background() + // Simulate an upgrade with historical identities already present. + for _, migration := range migrations.SQLiteMigrations() { + if migration.Version >= 9 { + break + } + for _, statement := range migration.Statements { + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + } + } + _, err := NewSenderProfileRepository(db).Upsert(ctx, domain.SenderProfile{KickUserID: 1, Username: "Old_User", Slug: "old_user"}) + if err != nil { + t.Fatal(err) + } + _, err = NewFollowedChannelRepository(db).Upsert(ctx, domain.FollowedChannel{Slug: "old_channel", DisplayName: "Old_Channel", IsEnabled: false}) + if err != nil { + t.Fatal(err) + } + for _, migration := range migrations.SQLiteMigrations() { + if migration.Version != 9 { + continue + } + for _, statement := range migration.Statements { + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + } + } + for _, kind := range []domain.DirectoryKind{domain.DirectoryUsers, domain.DirectoryChannels} { + items, err := NewDirectoryRepository(db).Search(ctx, kind, domain.DirectoryQuery{Prefix: "old-", Limit: 51}) + if err != nil || len(items) != 1 { + t.Fatalf("upgraded %s: %v, %v", kind, items, err) + } + } +} + +func TestDirectoryQueriesUseBothPrefixIndexes(t *testing.T) { + db := directoryTestDB(t) + if err := migrations.ApplySQLite(context.Background(), db); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + kind domain.DirectoryKind + table string + }{ + {domain.DirectoryUsers, "sender_profiles"}, {domain.DirectoryChannels, "followed_channels"}, + } { + for _, after := range []domain.DirectoryPosition{{}, {Slug: "yavu-a", ID: 20}} { + statement, args, err := directoryStatement(tc.kind, domain.DirectoryQuery{Prefix: "yavu", Limit: 51, After: after}) + if err != nil { + t.Fatal(err) + } + rows, err := db.Query("EXPLAIN QUERY PLAN "+statement, args...) + if err != nil { + t.Fatal(err) + } + var plan strings.Builder + for rows.Next() { + var id, parent, unused int + var detail string + if err := rows.Scan(&id, &parent, &unused, &detail); err != nil { + t.Fatal(err) + } + plan.WriteString(detail + "\n") + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + rows.Close() + for _, column := range []string{"name", "slug"} { + want := "SEARCH " + tc.table + " USING INDEX idx_" + tc.table + "_directory_" + column + if !strings.Contains(strings.ReplaceAll(plan.String(), "COVERING INDEX", "INDEX"), want) { + t.Fatalf("missing indexed prefix lookup %q:\n%s", want, plan.String()) + } + } + if strings.Contains(plan.String(), "SCAN "+tc.table) || strings.Contains(plan.String(), "SCAN identity") { + t.Fatalf("full identity-table scan:\n%s", plan.String()) + } + } + } +} + +func TestDirectoryRejectsUnsafeRepositoryQueries(t *testing.T) { + db := directoryTestDB(t) + repo := NewDirectoryRepository(db) + for _, tc := range []struct { + kind domain.DirectoryKind + query domain.DirectoryQuery + }{ + {"users; DROP TABLE sender_profiles", domain.DirectoryQuery{Prefix: "ab", Limit: 50}}, + {domain.DirectoryUsers, domain.DirectoryQuery{Prefix: "ab", Limit: 0}}, + {domain.DirectoryUsers, domain.DirectoryQuery{Prefix: "ab", Limit: 102}}, + {domain.DirectoryUsers, domain.DirectoryQuery{Limit: 50}}, + } { + if _, err := repo.Search(context.Background(), tc.kind, tc.query); err == nil { + t.Fatal("invalid query accepted") + } + } +} diff --git a/apps/api-go/internal/ports/directory.go b/apps/api-go/internal/ports/directory.go new file mode 100644 index 0000000..945a9c6 --- /dev/null +++ b/apps/api-go/internal/ports/directory.go @@ -0,0 +1,12 @@ +package ports + +import ( + "context" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +// DirectoryRepository reads stored identities without consulting message history. +type DirectoryRepository interface { + Search(context.Context, domain.DirectoryKind, domain.DirectoryQuery) ([]domain.DirectoryIdentity, error) +} diff --git a/apps/api-go/internal/ports/homepage.go b/apps/api-go/internal/ports/homepage.go new file mode 100644 index 0000000..6ce3dad --- /dev/null +++ b/apps/api-go/internal/ports/homepage.go @@ -0,0 +1,8 @@ +package ports + +import "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + +type HomepageSnapshotStore interface { + Load() (domain.HomepageSnapshot, error) + Save(snapshot domain.HomepageSnapshot) error +} diff --git a/apps/api-go/internal/ports/profile_analytics.go b/apps/api-go/internal/ports/profile_analytics.go new file mode 100644 index 0000000..4f3dd1d --- /dev/null +++ b/apps/api-go/internal/ports/profile_analytics.go @@ -0,0 +1,14 @@ +package ports + +import ( + "context" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +// ProfileSummaryRepository optionally combines the all-time overview and ranking. +// Profile filters use Sender/Channel, not the standalone rankings' free-text Query. +type ProfileSummaryRepository interface { + OverviewAndTopSenders(context.Context, domain.AnalyticsFilter, uint64) (domain.AnalyticsOverview, []domain.TopSenderAnalytics, error) + OverviewAndTopChannels(context.Context, domain.AnalyticsFilter, uint64) (domain.AnalyticsOverview, []domain.TopChannelAnalytics, error) +} diff --git a/apps/api-go/internal/ports/storage.go b/apps/api-go/internal/ports/storage.go index 907afd8..4ca8bb5 100644 --- a/apps/api-go/internal/ports/storage.go +++ b/apps/api-go/internal/ports/storage.go @@ -138,6 +138,16 @@ type KickEventSubscriptionRepository interface { type SubscriptionPeriodRepository interface { InsertBatch(ctx context.Context, periods []domain.ChannelSubscriptionPeriod) error ActiveSummary(ctx context.Context, followedChannelID int64) (domain.ChannelSubscriptionSummary, error) + ListActiveSubscribers(ctx context.Context, filter domain.ChannelSubscriberFilter) (domain.ChannelSubscriberPage, error) + ExportActiveSubscribers(ctx context.Context, followedChannelID int64, giftOnly bool) ([]domain.ChannelSubscriber, error) +} + +type UserRequestRepository interface { + Create(ctx context.Context, request domain.UserRequest) error + List(ctx context.Context, filter domain.UserRequestListFilter) ([]domain.UserRequestState, error) + Get(ctx context.Context, requestID string) (domain.UserRequestState, error) + ListEvents(ctx context.Context, requestID string) ([]domain.UserRequestEvent, error) + AppendEvent(ctx context.Context, event domain.UserRequestEvent) error } type DataManagementRepository interface { diff --git a/apps/api-go/internal/usecase/analytics/service.go b/apps/api-go/internal/usecase/analytics/service.go index 129e966..cd6f8ca 100644 --- a/apps/api-go/internal/usecase/analytics/service.go +++ b/apps/api-go/internal/usecase/analytics/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strconv" + "time" "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" @@ -11,6 +12,8 @@ import ( var ErrInvalidRange = errors.New("invalid analytics date range") +const maxFilledMessageVolumeBuckets = 1000 + type Service struct { repository ports.AnalyticsRepository cache *analyticsCache @@ -54,14 +57,77 @@ func (service *Service) MessageVolume( return nil, errors.New("invalid analytics bucket") } if !globalAnalyticsCacheable(filter) { - return service.repository.MessageVolume(ctx, filter, bucket) + points, err := service.repository.MessageVolume(ctx, filter, bucket) + if err != nil { + return nil, err + } + return FillMessageVolumeRange(points, filter, bucket), nil } key := "message-volume:" + string(bucket) + ":" + analyticsFilterCacheKey(filter) return cachedAnalyticsValue(ctx, service.cache, key, func(ctx context.Context) ([]domain.MessageVolumePoint, error) { - return service.repository.MessageVolume(ctx, filter, bucket) + points, err := service.repository.MessageVolume(ctx, filter, bucket) + if err != nil { + return nil, err + } + return FillMessageVolumeRange(points, filter, bucket), nil }) } +func FillMessageVolumeRange( + points []domain.MessageVolumePoint, + filter domain.AnalyticsFilter, + bucket domain.AnalyticsBucket, +) []domain.MessageVolumePoint { + if filter.Start.IsZero() || filter.End.IsZero() { + return points + } + + step := 24 * time.Hour + normalize := startOfUTCDay + if bucket == domain.AnalyticsBucketHour { + step = time.Hour + normalize = startOfUTCHour + } + + start := normalize(filter.Start) + end := normalize(filter.End) + if start.After(end) { + return []domain.MessageVolumePoint{} + } + bucketCount := int(end.Sub(start)/step) + 1 + if bucketCount > maxFilledMessageVolumeBuckets { + return points + } + + byBucket := make(map[time.Time]int64, len(points)) + for _, point := range points { + bucketStart := normalize(point.BucketStart) + if bucketStart.Before(start) || bucketStart.After(end) { + continue + } + byBucket[bucketStart] += point.MessageCount + } + + filled := make([]domain.MessageVolumePoint, 0, bucketCount) + for current := start; !current.After(end); current = current.Add(step) { + filled = append(filled, domain.MessageVolumePoint{ + BucketStart: current, + MessageCount: byBucket[current], + }) + } + return filled +} + +func startOfUTCDay(value time.Time) time.Time { + utc := value.UTC() + return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) +} + +func startOfUTCHour(value time.Time) time.Time { + utc := value.UTC() + return time.Date(utc.Year(), utc.Month(), utc.Day(), utc.Hour(), 0, 0, 0, time.UTC) +} + func (service *Service) TopSenders( ctx context.Context, filter domain.AnalyticsFilter, diff --git a/apps/api-go/internal/usecase/analytics/service_test.go b/apps/api-go/internal/usecase/analytics/service_test.go index ad6d91b..6e9504e 100644 --- a/apps/api-go/internal/usecase/analytics/service_test.go +++ b/apps/api-go/internal/usecase/analytics/service_test.go @@ -54,12 +54,12 @@ func TestServiceDoesNotCacheScopedTopSenderSearch(t *testing.T) { } func TestServiceCachesRepeatedGlobalVolumeRange(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 6, 2, 8, 0, 0, 0, time.UTC) repo := &countingAnalyticsRepository{ - volume: []domain.MessageVolumePoint{{MessageCount: 12}}, + volume: []domain.MessageVolumePoint{{BucketStart: start, MessageCount: 12}}, } service := NewService(repo) - start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2026, 6, 2, 8, 0, 0, 0, time.UTC) for range 2 { points, err := service.MessageVolume( @@ -70,9 +70,15 @@ func TestServiceCachesRepeatedGlobalVolumeRange(t *testing.T) { if err != nil { t.Fatalf("MessageVolume() error = %v", err) } - if len(points) != 1 { + if len(points) != 2 { t.Fatalf("points = %#v", points) } + if points[0].BucketStart != start || points[0].MessageCount != 12 { + t.Fatalf("first point = %#v", points[0]) + } + if points[1].BucketStart != start.AddDate(0, 0, 1) || points[1].MessageCount != 0 { + t.Fatalf("second point = %#v", points[1]) + } } if repo.volumeCalls != 1 { @@ -80,6 +86,36 @@ func TestServiceCachesRepeatedGlobalVolumeRange(t *testing.T) { } } +func TestServiceFillsMissingHourlyVolumeBuckets(t *testing.T) { + start := time.Date(2026, 6, 1, 10, 15, 0, 0, time.UTC) + end := time.Date(2026, 6, 1, 12, 45, 0, 0, time.UTC) + repo := &countingAnalyticsRepository{ + volume: []domain.MessageVolumePoint{{ + BucketStart: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + MessageCount: 7, + }}, + } + service := NewService(repo) + + points, err := service.MessageVolume( + context.Background(), + domain.AnalyticsFilter{Start: start, End: end, Channel: "hype"}, + domain.AnalyticsBucketHour, + ) + if err != nil { + t.Fatalf("MessageVolume() error = %v", err) + } + if len(points) != 3 { + t.Fatalf("points = %#v", points) + } + expected := []int64{0, 0, 7} + for index, wantCount := range expected { + if points[index].MessageCount != wantCount { + t.Fatalf("points[%d] = %#v, want count %d", index, points[index], wantCount) + } + } +} + func TestServiceReturnsStaleGlobalAnalyticsOnRefreshFailure(t *testing.T) { now := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC) repo := &countingAnalyticsRepository{ diff --git a/apps/api-go/internal/usecase/directory/service.go b/apps/api-go/internal/usecase/directory/service.go new file mode 100644 index 0000000..93ccb26 --- /dev/null +++ b/apps/api-go/internal/usecase/directory/service.go @@ -0,0 +1,123 @@ +package directory + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "strings" + "unicode" + "unicode/utf8" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" +) + +var ErrInvalidQuery = errors.New("invalid directory query") + +const ( + DefaultLimit = 50 + MaxLimit = 100 + MaxPrefixLength = 160 +) + +type Service struct{ repository ports.DirectoryRepository } + +func NewService(repository ports.DirectoryRepository) *Service { + return &Service{repository: repository} +} + +type cursor struct { + Version int `json:"v"` + Kind domain.DirectoryKind `json:"kind"` + Prefix string `json:"prefix"` + Slug string `json:"slug"` + ID int64 `json:"id"` +} + +func (service *Service) Search(ctx context.Context, kind domain.DirectoryKind, prefix string, limit int, after string) (domain.DirectoryPage, error) { + if kind != domain.DirectoryUsers && kind != domain.DirectoryChannels { + return domain.DirectoryPage{}, ErrInvalidQuery + } + if !validText(prefix) { + return domain.DirectoryPage{}, ErrInvalidQuery + } + prefix = normalize(prefix) + if length := utf8.RuneCountInString(prefix); length < 2 || length > MaxPrefixLength { + return domain.DirectoryPage{}, ErrInvalidQuery + } + if limit == 0 { + limit = DefaultLimit + } + if limit < 1 || limit > MaxLimit { + return domain.DirectoryPage{}, ErrInvalidQuery + } + query := domain.DirectoryQuery{Prefix: prefix, Limit: limit + 1} + if after != "" { + position, err := decodeCursor(after, kind, prefix) + if err != nil { + return domain.DirectoryPage{}, err + } + query.After = position + } + items, err := service.repository.Search(ctx, kind, query) + if err != nil { + return domain.DirectoryPage{}, err + } + if items == nil { + items = []domain.DirectoryIdentity{} + } + page := domain.DirectoryPage{Items: items} + if len(items) > limit { + page.Items = items[:limit] + last := page.Items[limit-1] + encoded, err := json.Marshal(cursor{Version: 1, Kind: kind, Prefix: prefix, Slug: last.SortSlug, ID: last.ID}) + if err != nil { + return domain.DirectoryPage{}, err + } + page.NextCursor = base64.RawURLEncoding.EncodeToString(encoded) + } + return page, nil +} + +func decodeCursor(raw string, kind domain.DirectoryKind, prefix string) (domain.DirectoryPosition, error) { + if len(raw) > 4096 { + return domain.DirectoryPosition{}, ErrInvalidQuery + } + data, err := base64.RawURLEncoding.Strict().DecodeString(raw) + if err != nil { + return domain.DirectoryPosition{}, ErrInvalidQuery + } + var value cursor + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return domain.DirectoryPosition{}, ErrInvalidQuery + } + if err := decoder.Decode(new(any)); err != io.EOF { + return domain.DirectoryPosition{}, ErrInvalidQuery + } + if value.Version != 1 || value.Kind != kind || value.Prefix != prefix || value.ID < 1 || + value.Slug == "" || utf8.RuneCountInString(value.Slug) > MaxPrefixLength || !validText(value.Slug) || normalize(value.Slug) != value.Slug { + return domain.DirectoryPosition{}, ErrInvalidQuery + } + return domain.DirectoryPosition{Slug: value.Slug, ID: value.ID}, nil +} + +func normalize(value string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "_", "-") +} + +func validText(value string) bool { + if !utf8.ValidString(value) { + return false + } + for _, r := range value { + if unicode.IsControl(r) { + return false + } + } + return true +} diff --git a/apps/api-go/internal/usecase/directory/service_test.go b/apps/api-go/internal/usecase/directory/service_test.go new file mode 100644 index 0000000..4ae5a3c --- /dev/null +++ b/apps/api-go/internal/usecase/directory/service_test.go @@ -0,0 +1,80 @@ +package directory + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +type recordingRepository struct { + query domain.DirectoryQuery + calls int + items []domain.DirectoryIdentity + err error +} + +func (r *recordingRepository) Search(_ context.Context, _ domain.DirectoryKind, query domain.DirectoryQuery) ([]domain.DirectoryIdentity, error) { + r.calls++ + r.query = query + return r.items, r.err +} + +func TestSearchBoundsLookaheadAndNormalizesPrefix(t *testing.T) { + repo := &recordingRepository{} + page, err := NewService(repo).Search(context.Background(), domain.DirectoryUsers, " Te_ST ", 0, "") + if err != nil { + t.Fatal(err) + } + if repo.query.Prefix != "te-st" || repo.query.Limit != 51 || page.Items == nil || page.NextCursor != "" { + t.Fatalf("query: %+v, page: %+v", repo.query, page) + } + _, err = NewService(repo).Search(context.Background(), domain.DirectoryChannels, "test", 100, "") + if err != nil || repo.query.Limit != 101 { + t.Fatalf("lookahead: %+v, %v", repo.query, err) + } +} + +func TestSearchRejectsInvalidInputBeforeRepository(t *testing.T) { + repo := &recordingRepository{} + service := NewService(repo) + for _, prefix := range []string{"", " ", "a", "ab\x00", "ab\n", "\xff\xff", strings.Repeat("x", 161)} { + if _, err := service.Search(context.Background(), domain.DirectoryUsers, prefix, 50, ""); !errors.Is(err, ErrInvalidQuery) { + t.Fatalf("prefix %q: %v", prefix, err) + } + } + for _, limit := range []int{-1, 101} { + if _, err := service.Search(context.Background(), domain.DirectoryUsers, "ab", limit, ""); !errors.Is(err, ErrInvalidQuery) { + t.Fatalf("limit %d: %v", limit, err) + } + } + for _, raw := range []string{ + `null`, `{}`, `{"v":2,"kind":"users","prefix":"ab","slug":"ab","id":1}`, + `{"v":1,"kind":"users","prefix":"ab","slug":"ab","id":-1}`, + `{"v":1,"kind":"users","prefix":"ab","slug":"AB","id":1}`, + `{"v":1,"kind":"users","prefix":"ab","slug":"ab","id":1,"extra":1}`, + `{"v":1,"kind":"users","prefix":"ab","slug":"ab","id":1}{}`, + } { + after := base64.RawURLEncoding.EncodeToString([]byte(raw)) + if _, err := service.Search(context.Background(), domain.DirectoryUsers, "ab", 50, after); !errors.Is(err, ErrInvalidQuery) { + t.Fatalf("cursor %q: %v", raw, err) + } + } + if _, err := service.Search(context.Background(), "unknown", "ab", 50, ""); !errors.Is(err, ErrInvalidQuery) { + t.Fatal(err) + } + if repo.calls != 0 { + t.Fatalf("invalid input reached repository %d times", repo.calls) + } +} + +func TestSearchPropagatesRepositoryErrors(t *testing.T) { + failure := errors.New("unavailable") + _, err := NewService(&recordingRepository{err: failure}).Search(context.Background(), domain.DirectoryUsers, "ab", 50, "") + if !errors.Is(err, failure) { + t.Fatal(err) + } +} diff --git a/apps/api-go/internal/usecase/homepage/service.go b/apps/api-go/internal/usecase/homepage/service.go new file mode 100644 index 0000000..b8e16ff --- /dev/null +++ b/apps/api-go/internal/usecase/homepage/service.go @@ -0,0 +1,177 @@ +package homepage + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" + analyticsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/analytics" +) + +const ( + RefreshInterval = 15 * time.Minute + maxSnapshotAge = 24 * time.Hour + refreshTimeout = 90 * time.Second + snapshotVersion = 1 +) + +var ErrNotReady = errors.New("homepage analytics are not ready") + +type Service struct { + repository ports.AnalyticsRepository + store ports.HomepageSnapshotStore + logger *slog.Logger + now func() time.Time + mu sync.RWMutex + refreshMu sync.Mutex + snapshot domain.HomepageSnapshot +} + +func NewService(repository ports.AnalyticsRepository, store ports.HomepageSnapshotStore, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() + } + return &Service{repository: repository, store: store, logger: logger, now: time.Now} +} + +// Get never performs I/O or waits for a refresh. The reporting interval belongs to +// the returned snapshot, including when it is stale across a UTC day boundary. +func (service *Service) Get() (domain.HomepageSnapshot, error) { + service.mu.RLock() + defer service.mu.RUnlock() + if !validSnapshot(service.snapshot, service.now()) { + return domain.HomepageSnapshot{}, ErrNotReady + } + snapshot := service.snapshot + snapshot.Volume = append([]domain.MessageVolumePoint(nil), snapshot.Volume...) + snapshot.TopChannels = append([]domain.TopChannelAnalytics(nil), snapshot.TopChannels...) + snapshot.TopSenders = append([]domain.TopSenderAnalytics(nil), snapshot.TopSenders...) + snapshot.TopEmotes = append([]domain.TopEmoteAnalytics(nil), snapshot.TopEmotes...) + return snapshot, nil +} + +func (service *Service) Restore() error { + if service.store == nil { + return ErrNotReady + } + snapshot, err := service.store.Load() + if err != nil { + return err + } + if !validSnapshot(snapshot, service.now()) { + return ErrNotReady + } + service.mu.Lock() + service.snapshot = snapshot + service.mu.Unlock() + return nil +} + +// Run is started once by the API lifetime, not once per visitor. Queries execute +// sequentially; a failing database receives exponential retry delays up to 15m. +func (service *Service) Run(ctx context.Context) { + if err := service.Restore(); err != nil { + service.logger.Debug("homepage snapshot unavailable at startup", "error", err) + } + if snapshot, err := service.Get(); err == nil { + if delay := RefreshInterval - service.now().Sub(snapshot.AsOf); delay > 0 && !wait(ctx, delay) { + return + } + } + backoff := time.Minute + for ctx.Err() == nil { + delay := RefreshInterval + if err := service.Refresh(ctx); err != nil { + if ctx.Err() != nil { + return + } + service.logger.Warn("homepage analytics refresh failed", "error", err, "retry_in", backoff) + delay = backoff + backoff = min(backoff*2, RefreshInterval) + } else { + backoff = time.Minute + } + if !wait(ctx, delay) { + return + } + } +} + +func (service *Service) Refresh(ctx context.Context) error { + if service.repository == nil { + return errors.New("homepage analytics database unavailable") + } + if !service.refreshMu.TryLock() { + return nil + } + defer service.refreshMu.Unlock() + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + defer cancel() + // Match the second precision used by the existing analytics query bindings. + asOf := service.now().UTC().Truncate(time.Second) + filter := domain.AnalyticsFilter{Start: startOfWindow(asOf), End: asOf} + snapshot := domain.HomepageSnapshot{Version: snapshotVersion, AsOf: asOf, Start: filter.Start, End: filter.End} + var err error + if snapshot.Overview, err = service.repository.Overview(ctx, filter); err != nil { + return err + } + if snapshot.Volume, err = service.repository.MessageVolume(ctx, filter, domain.AnalyticsBucketDay); err != nil { + return err + } + snapshot.Volume = analyticsusecase.FillMessageVolumeRange(snapshot.Volume, filter, domain.AnalyticsBucketDay) + if snapshot.TopChannels, err = service.repository.TopChannels(ctx, filter, 5); err != nil { + return err + } + if snapshot.TopSenders, err = service.repository.TopSenders(ctx, filter, 5); err != nil { + return err + } + if snapshot.TopEmotes, err = service.repository.TopEmotes(ctx, filter, 5); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + service.mu.Lock() + service.snapshot = snapshot + service.mu.Unlock() + if service.store != nil { + if err := service.store.Save(snapshot); err != nil { + service.logger.Warn("homepage snapshot persistence failed; serving memory snapshot", "error", err) + } + } + return nil +} + +func startOfWindow(now time.Time) time.Time { + utc := now.UTC() + return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -13) +} + +func validSnapshot(snapshot domain.HomepageSnapshot, now time.Time) bool { + age := now.Sub(snapshot.AsOf) + if snapshot.Version != snapshotVersion || snapshot.AsOf.IsZero() || age < 0 || age > maxSnapshotAge || + !snapshot.Start.Equal(startOfWindow(snapshot.AsOf)) || !snapshot.End.Equal(snapshot.AsOf) || len(snapshot.Volume) != 14 { + return false + } + for i, point := range snapshot.Volume { + if point.MessageCount < 0 || !point.BucketStart.Equal(snapshot.Start.AddDate(0, 0, i)) { + return false + } + } + return true +} + +func wait(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/apps/api-go/internal/usecase/homepage/service_test.go b/apps/api-go/internal/usecase/homepage/service_test.go new file mode 100644 index 0000000..d58229e --- /dev/null +++ b/apps/api-go/internal/usecase/homepage/service_test.go @@ -0,0 +1,274 @@ +package homepage + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" +) + +func TestSnapshotUsesOneFourteenDayWindowAndFillsEmptyDays(t *testing.T) { + repo := &testAnalytics{} + store := &testStore{} + service := newTestService(repo, store) + if _, err := service.Get(); !errors.Is(err, ErrNotReady) { + t.Fatalf("uninitialized Get = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + snapshot, err := service.Get() + if err != nil { + t.Fatal(err) + } + start := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) + if snapshot.Start != start || snapshot.End != service.now() || snapshot.Overview.TotalMessages != 42 { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } + if len(snapshot.Volume) != 14 || snapshot.Volume[0].MessageCount != 0 || snapshot.Volume[13].MessageCount != 42 { + t.Fatalf("unexpected volume: %+v", snapshot.Volume) + } + if len(repo.filters) != 5 { + t.Fatalf("query count = %d", len(repo.filters)) + } + for _, filter := range repo.filters { + if filter.Start != start || filter.End != snapshot.End || filter.Sender != "" || filter.Channel != "" { + t.Fatalf("inconsistent scope: %+v", filter) + } + } + for range 100 { + if _, err := service.Get(); err != nil { + t.Fatal(err) + } + } + if len(repo.filters) != 5 { + t.Fatal("visitors triggered database reads") + } + if store.saved.Overview.TotalMessages != 42 { + t.Fatal("snapshot not persisted") + } +} + +func TestRefreshFailureKeepsLastGoodSnapshot(t *testing.T) { + repo := &testAnalytics{} + service := newTestService(repo, &testStore{}) + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + repo.err = errors.New("database unavailable") + if err := service.Refresh(context.Background()); err == nil { + t.Fatal("expected refresh failure") + } + snapshot, err := service.Get() + if err != nil || snapshot.Overview.TotalMessages != 42 { + t.Fatalf("lost last good snapshot: %v", err) + } +} + +func TestGetDoesNotWaitForBackgroundRefresh(t *testing.T) { + repo := &testAnalytics{} + service := newTestService(repo, &testStore{}) + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + repo.started, repo.release = make(chan struct{}), make(chan struct{}) + done := make(chan error, 1) + go func() { done <- service.Refresh(context.Background()) }() + <-repo.started + if _, err := service.Get(); err != nil { + t.Fatal(err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + close(repo.release) + if err := <-done; err != nil { + t.Fatal(err) + } + if len(repo.filters) != 10 { + t.Fatalf("overlapping refreshes: %d reads", len(repo.filters)) + } +} + +func TestRestoreSurvivesRestartAndRejectsExpiredOrFutureSnapshot(t *testing.T) { + store := &testStore{} + service := newTestService(&testAnalytics{}, store) + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + restarted := newTestService(&testAnalytics{err: errors.New("offline")}, store) + if err := restarted.Restore(); err != nil { + t.Fatal(err) + } + if snapshot, err := restarted.Get(); err != nil || snapshot.Overview.TotalMessages != 42 { + t.Fatal("restore failed", err) + } + for _, delta := range []time.Duration{25 * time.Hour, -time.Hour} { + now := service.now().Add(delta) + restarted.now = func() time.Time { return now } + if _, err := restarted.Get(); !errors.Is(err, ErrNotReady) { + t.Fatalf("invalid snapshot age: %v", err) + } + } + store.saved.Version++ + if err := restarted.Restore(); err == nil { + t.Fatal("accepted incompatible cache version") + } +} + +func TestPersistFailureDoesNotHideFreshSnapshot(t *testing.T) { + service := newTestService(&testAnalytics{}, &testStore{saveErr: errors.New("read-only filesystem")}) + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := service.Get(); err != nil { + t.Fatal(err) + } +} + +func TestLateRefreshFailureDoesNotPublishMixedSnapshot(t *testing.T) { + repo := &testAnalytics{} + service := newTestService(repo, &testStore{}) + if err := service.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + original, _ := service.Get() + service.now = func() time.Time { return original.AsOf.Add(time.Hour) } + repo.emoteErr = errors.New("last panel failed") + if err := service.Refresh(context.Background()); err == nil { + t.Fatal("expected last query failure") + } + actual, err := service.Get() + if err != nil || !actual.AsOf.Equal(original.AsOf) { + t.Fatalf("published partial snapshot: %+v, %v", actual, err) + } +} + +func TestRunRestoresFreshSnapshotWithoutQueryingDatabase(t *testing.T) { + store := &testStore{} + seed := newTestService(&testAnalytics{}, store) + if err := seed.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + restarted := newTestService(nil, store) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { restarted.Run(ctx); close(done) }() + deadline := time.After(time.Second) + for { + if _, err := restarted.Get(); err == nil { + break + } + select { + case <-deadline: + cancel() + t.Fatal("snapshot not restored") + case <-time.After(time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("fresh snapshot wait ignored cancellation") + } +} + +func TestMissingDatabaseStillServesRestoredSnapshot(t *testing.T) { + store := &testStore{} + seed := newTestService(&testAnalytics{}, store) + if err := seed.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + service := newTestService(nil, store) + if err := service.Restore(); err != nil { + t.Fatal(err) + } + if err := service.Refresh(context.Background()); err == nil { + t.Fatal("missing database must report an error") + } + if _, err := service.Get(); err != nil { + t.Fatal("missing database discarded restored snapshot", err) + } +} + +func TestRunCancelsBlockedRefresh(t *testing.T) { + repo := &testAnalytics{started: make(chan struct{}), release: make(chan struct{})} + service := newTestService(repo, &testStore{}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { service.Run(ctx); close(done) }() + <-repo.started + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("refresh ignored shutdown") + } +} + +func newTestService(repo ports.AnalyticsRepository, store ports.HomepageSnapshotStore) *Service { + service := NewService(repo, store, slog.New(slog.NewTextHandler(io.Discard, nil))) + service.now = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) } + return service +} + +type testStore struct { + saved domain.HomepageSnapshot + saveErr error +} + +func (store *testStore) Load() (domain.HomepageSnapshot, error) { return store.saved, nil } +func (store *testStore) Save(snapshot domain.HomepageSnapshot) error { + store.saved = snapshot + return store.saveErr +} + +type testAnalytics struct { + ports.AnalyticsRepository + mu sync.Mutex + filters []domain.AnalyticsFilter + err error + emoteErr error + started, release chan struct{} +} + +func (repo *testAnalytics) record(filter domain.AnalyticsFilter) { + repo.mu.Lock() + defer repo.mu.Unlock() + repo.filters = append(repo.filters, filter) +} +func (repo *testAnalytics) Overview(ctx context.Context, filter domain.AnalyticsFilter) (domain.AnalyticsOverview, error) { + repo.record(filter) + if repo.started != nil { + close(repo.started) + select { + case <-repo.release: + case <-ctx.Done(): + return domain.AnalyticsOverview{}, ctx.Err() + } + } + return domain.AnalyticsOverview{TotalMessages: 42}, repo.err +} +func (repo *testAnalytics) MessageVolume(_ context.Context, filter domain.AnalyticsFilter, _ domain.AnalyticsBucket) ([]domain.MessageVolumePoint, error) { + repo.record(filter) + return []domain.MessageVolumePoint{{BucketStart: filter.End, MessageCount: 42}}, nil +} +func (repo *testAnalytics) TopSenders(_ context.Context, filter domain.AnalyticsFilter, limit uint64) ([]domain.TopSenderAnalytics, error) { + repo.record(filter) + return []domain.TopSenderAnalytics{{Username: "tester", MessageCount: int64(limit)}}, nil +} +func (repo *testAnalytics) TopChannels(_ context.Context, filter domain.AnalyticsFilter, _ uint64) ([]domain.TopChannelAnalytics, error) { + repo.record(filter) + return nil, nil +} +func (repo *testAnalytics) TopEmotes(_ context.Context, filter domain.AnalyticsFilter, _ uint64) ([]domain.TopEmoteAnalytics, error) { + repo.record(filter) + return nil, repo.emoteErr +} diff --git a/apps/api-go/internal/usecase/profiles/service.go b/apps/api-go/internal/usecase/profiles/service.go index e4fe2ec..59d812d 100644 --- a/apps/api-go/internal/usecase/profiles/service.go +++ b/apps/api-go/internal/usecase/profiles/service.go @@ -5,9 +5,11 @@ import ( "database/sql" "errors" "strings" + "time" "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" + analyticsusecase "github.com/YSelim0/kick-logs/apps/api-go/internal/usecase/analytics" ) var ( @@ -20,6 +22,7 @@ type Service struct { channels ports.FollowedChannelRepository senders ports.SenderProfileRepository profileCache *profileCache + now func() time.Time } func NewService( @@ -32,6 +35,7 @@ func NewService( channels: channels, senders: senders, profileCache: newProfileCache(), + now: time.Now, } } @@ -55,9 +59,10 @@ func (service *Service) buildUserProfile( sender domain.SenderProfile, ) (domain.UserProfile, error) { filter := domain.AnalyticsFilter{Sender: sender.Slug} - overview := valueOrZero(service.analytics.Overview(ctx, filter)) - volume := valueOrZero(service.analytics.MessageVolume(ctx, filter, domain.AnalyticsBucketDay)) - topChannels := valueOrZero(service.analytics.TopChannels(ctx, filter, 5)) + volumeFilter := service.recentVolumeFilter(filter) + overview, topChannels := service.userSummary(ctx, filter) + volumePoints, volumeErr := service.analytics.MessageVolume(ctx, volumeFilter, domain.AnalyticsBucketDay) + volume := volumeOrEmpty(volumePoints, volumeErr, volumeFilter) topEmotes := valueOrZero(service.analytics.TopEmotes(ctx, filter, 5)) latestMessages := valueOrZero(service.analytics.LatestMessages(ctx, filter, 20)) return domain.UserProfile{ @@ -90,9 +95,10 @@ func (service *Service) buildChannelProfile( channel domain.FollowedChannel, ) (domain.ChannelProfile, error) { filter := domain.AnalyticsFilter{Channel: channel.Slug} - overview := valueOrZero(service.analytics.Overview(ctx, filter)) - volume := valueOrZero(service.analytics.MessageVolume(ctx, filter, domain.AnalyticsBucketDay)) - topSenders := valueOrZero(service.analytics.TopSenders(ctx, filter, 5)) + volumeFilter := service.recentVolumeFilter(filter) + overview, topSenders := service.channelSummary(ctx, filter) + volumePoints, volumeErr := service.analytics.MessageVolume(ctx, volumeFilter, domain.AnalyticsBucketDay) + volume := volumeOrEmpty(volumePoints, volumeErr, volumeFilter) topEmotes := valueOrZero(service.analytics.TopEmotes(ctx, filter, 5)) latestMessages := valueOrZero(service.analytics.LatestMessages(ctx, filter, 10)) return domain.ChannelProfile{ @@ -105,6 +111,33 @@ func (service *Service) buildChannelProfile( }, nil } +func (service *Service) userSummary(ctx context.Context, filter domain.AnalyticsFilter) (domain.AnalyticsOverview, []domain.TopChannelAnalytics) { + if repo, ok := service.analytics.(ports.ProfileSummaryRepository); ok { + if overview, channels, err := repo.OverviewAndTopChannels(ctx, filter, 5); err == nil { + return overview, channels + } + } + // Preserve the existing independent/partial results if the combined query fails. + return valueOrZero(service.analytics.Overview(ctx, filter)), valueOrZero(service.analytics.TopChannels(ctx, filter, 5)) +} + +func (service *Service) channelSummary(ctx context.Context, filter domain.AnalyticsFilter) (domain.AnalyticsOverview, []domain.TopSenderAnalytics) { + if repo, ok := service.analytics.(ports.ProfileSummaryRepository); ok { + if overview, senders, err := repo.OverviewAndTopSenders(ctx, filter, 5); err == nil { + return overview, senders + } + } + return valueOrZero(service.analytics.Overview(ctx, filter)), valueOrZero(service.analytics.TopSenders(ctx, filter, 5)) +} + +func (service *Service) recentVolumeFilter(filter domain.AnalyticsFilter) domain.AnalyticsFilter { + now := service.now().UTC() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -13) + filter.Start = start + filter.End = now + return filter +} + func valueOrZero[T any](value T, err error) T { if err != nil { var zero T @@ -113,6 +146,17 @@ func valueOrZero[T any](value T, err error) T { return value } +func volumeOrEmpty( + value []domain.MessageVolumePoint, + err error, + filter domain.AnalyticsFilter, +) []domain.MessageVolumePoint { + if err != nil { + return nil + } + return analyticsusecase.FillMessageVolumeRange(value, filter, domain.AnalyticsBucketDay) +} + func (service *Service) findSender(ctx context.Context, slug string) (domain.SenderProfile, error) { for _, term := range senderLookupTerms(slug) { sender, err := service.senders.GetBySlug(ctx, term) diff --git a/apps/api-go/internal/usecase/requests/service.go b/apps/api-go/internal/usecase/requests/service.go new file mode 100644 index 0000000..e694ecf --- /dev/null +++ b/apps/api-go/internal/usecase/requests/service.go @@ -0,0 +1,327 @@ +package requests + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" + "github.com/YSelim0/kick-logs/apps/api-go/internal/ports" +) + +var ( + ErrValidation = errors.New("validation failed") + ErrNotFound = errors.New("request not found") +) + +type Service struct { + repository ports.UserRequestRepository +} + +func NewService(repository ports.UserRequestRepository) *Service { + return &Service{repository: repository} +} + +type CreateInput struct { + Type string + Title string + Message string + ChannelSlug string + ChannelDisplayName string + Contact string + IPHash string + UserAgentHash string +} + +func (service *Service) Create(ctx context.Context, input CreateInput) (domain.UserRequest, error) { + if service.repository == nil { + return domain.UserRequest{}, errors.New("user request repository is not configured") + } + + requestType, err := parseRequestType(input.Type) + if err != nil { + return domain.UserRequest{}, err + } + + title, err := requiredText(input.Title, 3, 120, "title") + if err != nil { + return domain.UserRequest{}, err + } + message, err := requiredText(input.Message, 5, 2000, "message") + if err != nil { + return domain.UserRequest{}, err + } + contact, err := optionalText(input.Contact, 120, "contact") + if err != nil { + return domain.UserRequest{}, err + } + displayName, err := optionalText(input.ChannelDisplayName, 120, "channel display name") + if err != nil { + return domain.UserRequest{}, err + } + + channelSlug := "" + if requestType == domain.UserRequestTypeChannelRequest { + channelSlug, err = normalizeChannelSlug(input.ChannelSlug) + if err != nil { + return domain.UserRequest{}, err + } + } else if strings.TrimSpace(input.ChannelSlug) != "" { + channelSlug, err = normalizeChannelSlug(input.ChannelSlug) + if err != nil { + return domain.UserRequest{}, err + } + } + + requestID, err := newID("req") + if err != nil { + return domain.UserRequest{}, err + } + + request := domain.UserRequest{ + ID: requestID, + Type: requestType, + Title: title, + Message: message, + ChannelSlug: channelSlug, + ChannelDisplayName: displayName, + Contact: contact, + IPHash: strings.TrimSpace(input.IPHash), + UserAgentHash: strings.TrimSpace(input.UserAgentHash), + CreatedAt: time.Now().UTC(), + } + if err := service.repository.Create(ctx, request); err != nil { + return domain.UserRequest{}, err + } + return request, nil +} + +func (service *Service) List( + ctx context.Context, + filter domain.UserRequestListFilter, +) ([]domain.UserRequestState, error) { + if service.repository == nil { + return nil, errors.New("user request repository is not configured") + } + normalized, err := normalizeListFilter(filter) + if err != nil { + return nil, err + } + return service.repository.List(ctx, normalized) +} + +func (service *Service) Detail(ctx context.Context, requestID string) (domain.UserRequestDetail, error) { + if service.repository == nil { + return domain.UserRequestDetail{}, errors.New("user request repository is not configured") + } + requestID = strings.TrimSpace(requestID) + if requestID == "" || len(requestID) > 80 { + return domain.UserRequestDetail{}, fmt.Errorf("%w: invalid request id", ErrValidation) + } + + state, err := service.repository.Get(ctx, requestID) + if errors.Is(err, sql.ErrNoRows) { + return domain.UserRequestDetail{}, ErrNotFound + } + if err != nil { + return domain.UserRequestDetail{}, err + } + events, err := service.repository.ListEvents(ctx, requestID) + if err != nil { + return domain.UserRequestDetail{}, err + } + return domain.UserRequestDetail{State: state, Events: events}, nil +} + +func (service *Service) ChangeStatus( + ctx context.Context, + requestID string, + status string, + adminID int64, +) (domain.UserRequestDetail, error) { + parsedStatus, err := parseStatus(status) + if err != nil { + return domain.UserRequestDetail{}, err + } + return service.appendAdminEvent(ctx, requestID, domain.UserRequestEvent{ + EventType: domain.UserRequestEventStatusChanged, + Status: parsedStatus, + AdminID: adminID, + }) +} + +func (service *Service) AddNote( + ctx context.Context, + requestID string, + note string, + adminID int64, +) (domain.UserRequestDetail, error) { + trimmedNote, err := requiredText(note, 2, 1000, "note") + if err != nil { + return domain.UserRequestDetail{}, err + } + return service.appendAdminEvent(ctx, requestID, domain.UserRequestEvent{ + EventType: domain.UserRequestEventNoteAdded, + Note: trimmedNote, + AdminID: adminID, + }) +} + +func (service *Service) Archive( + ctx context.Context, + requestID string, + adminID int64, +) (domain.UserRequestDetail, error) { + return service.appendAdminEvent(ctx, requestID, domain.UserRequestEvent{ + EventType: domain.UserRequestEventArchived, + AdminID: adminID, + }) +} + +func (service *Service) appendAdminEvent( + ctx context.Context, + requestID string, + event domain.UserRequestEvent, +) (domain.UserRequestDetail, error) { + if service.repository == nil { + return domain.UserRequestDetail{}, errors.New("user request repository is not configured") + } + requestID = strings.TrimSpace(requestID) + if requestID == "" || len(requestID) > 80 { + return domain.UserRequestDetail{}, fmt.Errorf("%w: invalid request id", ErrValidation) + } + + if _, err := service.repository.Get(ctx, requestID); errors.Is(err, sql.ErrNoRows) { + return domain.UserRequestDetail{}, ErrNotFound + } else if err != nil { + return domain.UserRequestDetail{}, err + } + + eventID, err := newID("evt") + if err != nil { + return domain.UserRequestDetail{}, err + } + event.ID = eventID + event.RequestID = requestID + event.CreatedAt = time.Now().UTC() + if err := service.repository.AppendEvent(ctx, event); err != nil { + return domain.UserRequestDetail{}, err + } + return service.Detail(ctx, requestID) +} + +func normalizeListFilter(filter domain.UserRequestListFilter) (domain.UserRequestListFilter, error) { + var err error + if filter.Type != "" { + filter.Type, err = parseRequestType(string(filter.Type)) + if err != nil { + return domain.UserRequestListFilter{}, err + } + } + if filter.Status != "" { + filter.Status, err = parseStatus(string(filter.Status)) + if err != nil { + return domain.UserRequestListFilter{}, err + } + } + filter.Query, err = optionalText(filter.Query, 200, "query") + if err != nil { + return domain.UserRequestListFilter{}, err + } + if !filter.Start.IsZero() && !filter.End.IsZero() && filter.Start.After(filter.End) { + return domain.UserRequestListFilter{}, fmt.Errorf("%w: start must be before end", ErrValidation) + } + if filter.Limit == 0 { + filter.Limit = 50 + } + if filter.Limit > 200 { + filter.Limit = 200 + } + return filter, nil +} + +func parseRequestType(value string) (domain.UserRequestType, error) { + switch domain.UserRequestType(strings.TrimSpace(value)) { + case domain.UserRequestTypeChannelRequest: + return domain.UserRequestTypeChannelRequest, nil + case domain.UserRequestTypeFeedback: + return domain.UserRequestTypeFeedback, nil + default: + return "", fmt.Errorf("%w: unsupported request type", ErrValidation) + } +} + +func parseStatus(value string) (domain.UserRequestStatus, error) { + switch domain.UserRequestStatus(strings.TrimSpace(value)) { + case domain.UserRequestStatusNew: + return domain.UserRequestStatusNew, nil + case domain.UserRequestStatusReviewing: + return domain.UserRequestStatusReviewing, nil + case domain.UserRequestStatusApproved: + return domain.UserRequestStatusApproved, nil + case domain.UserRequestStatusRejected: + return domain.UserRequestStatusRejected, nil + case domain.UserRequestStatusDone: + return domain.UserRequestStatusDone, nil + case domain.UserRequestStatusDuplicate: + return domain.UserRequestStatusDuplicate, nil + default: + return "", fmt.Errorf("%w: unsupported request status", ErrValidation) + } +} + +func requiredText(value string, minLength int, maxLength int, fieldName string) (string, error) { + trimmed := strings.TrimSpace(value) + if len(trimmed) < minLength || len(trimmed) > maxLength { + return "", fmt.Errorf("%w: invalid %s", ErrValidation, fieldName) + } + return trimmed, nil +} + +func optionalText(value string, maxLength int, fieldName string) (string, error) { + trimmed := strings.TrimSpace(value) + if len(trimmed) > maxLength { + return "", fmt.Errorf("%w: invalid %s", ErrValidation, fieldName) + } + return trimmed, nil +} + +func normalizeChannelSlug(value string) (string, error) { + slug := strings.TrimSpace(strings.ToLower(value)) + slug = strings.TrimPrefix(slug, "@") + slug = strings.TrimPrefix(slug, "https://") + slug = strings.TrimPrefix(slug, "http://") + slug = strings.TrimPrefix(slug, "www.") + slug = strings.TrimPrefix(slug, "kick.com/") + if index := strings.IndexAny(slug, "?#"); index >= 0 { + slug = slug[:index] + } + slug = strings.Trim(slug, "/") + if index := strings.IndexByte(slug, '/'); index >= 0 { + slug = slug[:index] + } + + if len(slug) < 2 || len(slug) > 80 { + return "", fmt.Errorf("%w: invalid channel slug", ErrValidation) + } + for _, r := range slug { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + continue + } + return "", fmt.Errorf("%w: invalid channel slug", ErrValidation) + } + return slug, nil +} + +func newID(prefix string) (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate id: %w", err) + } + return prefix + "_" + hex.EncodeToString(raw[:]), nil +} diff --git a/apps/api-go/internal/usecase/requests/service_test.go b/apps/api-go/internal/usecase/requests/service_test.go new file mode 100644 index 0000000..5ab7c63 --- /dev/null +++ b/apps/api-go/internal/usecase/requests/service_test.go @@ -0,0 +1,186 @@ +package requests + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/YSelim0/kick-logs/apps/api-go/internal/domain" +) + +func TestCreateChannelRequestNormalizesSlug(t *testing.T) { + repo := newMemoryRequestRepo() + service := NewService(repo) + + created, err := service.Create(context.Background(), CreateInput{ + Type: string(domain.UserRequestTypeChannelRequest), + Title: "Kanal eklensin", + Message: "Bu kanali takip listesine ekleyelim.", + ChannelSlug: "https://kick.com/NuriBen?ref=test", + Contact: "mod@example.com", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if created.ID == "" { + t.Fatal("request id is empty") + } + if created.ChannelSlug != "nuriben" { + t.Fatalf("ChannelSlug = %q", created.ChannelSlug) + } + if created.Type != domain.UserRequestTypeChannelRequest { + t.Fatalf("Type = %q", created.Type) + } +} + +func TestCreateRejectsInvalidPayloads(t *testing.T) { + service := NewService(newMemoryRequestRepo()) + + tests := []CreateInput{ + {Type: "unknown", Title: "Valid title", Message: "valid message"}, + {Type: string(domain.UserRequestTypeFeedback), Title: "x", Message: "valid message"}, + {Type: string(domain.UserRequestTypeFeedback), Title: "Valid title", Message: "bad"}, + {Type: string(domain.UserRequestTypeChannelRequest), Title: "Valid title", Message: "valid message"}, + {Type: string(domain.UserRequestTypeChannelRequest), Title: "Valid title", Message: "valid message", ChannelSlug: "bad slug"}, + } + + for index, input := range tests { + if _, err := service.Create(context.Background(), input); !errors.Is(err, ErrValidation) { + t.Fatalf("case %d error = %v, want ErrValidation", index, err) + } + } +} + +func TestAdminWorkflowAppendsEvents(t *testing.T) { + repo := newMemoryRequestRepo() + service := NewService(repo) + ctx := context.Background() + + created, err := service.Create(ctx, CreateInput{ + Type: string(domain.UserRequestTypeFeedback), + Title: "Feature request", + Message: "Lutfen yeni bir rapor ekleyin.", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + detail, err := service.ChangeStatus(ctx, created.ID, string(domain.UserRequestStatusReviewing), 10) + if err != nil { + t.Fatalf("ChangeStatus() error = %v", err) + } + if detail.State.CurrentStatus != domain.UserRequestStatusReviewing { + t.Fatalf("CurrentStatus = %q", detail.State.CurrentStatus) + } + + detail, err = service.AddNote(ctx, created.ID, "Kontrol edilecek.", 10) + if err != nil { + t.Fatalf("AddNote() error = %v", err) + } + if len(detail.Events) != 2 || detail.Events[1].Note != "Kontrol edilecek." { + t.Fatalf("events after note = %#v", detail.Events) + } + + detail, err = service.Archive(ctx, created.ID, 10) + if err != nil { + t.Fatalf("Archive() error = %v", err) + } + if !detail.State.IsArchived { + t.Fatal("request should be archived") + } + if len(detail.Events) != 3 || detail.Events[2].EventType != domain.UserRequestEventArchived { + t.Fatalf("events after archive = %#v", detail.Events) + } +} + +func TestDetailReturnsNotFound(t *testing.T) { + service := NewService(newMemoryRequestRepo()) + + if _, err := service.Detail(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Detail() error = %v, want ErrNotFound", err) + } +} + +type memoryRequestRepo struct { + requests map[string]domain.UserRequest + events map[string][]domain.UserRequestEvent +} + +func newMemoryRequestRepo() *memoryRequestRepo { + return &memoryRequestRepo{ + requests: map[string]domain.UserRequest{}, + events: map[string][]domain.UserRequestEvent{}, + } +} + +func (repo *memoryRequestRepo) Create(_ context.Context, request domain.UserRequest) error { + repo.requests[request.ID] = request + return nil +} + +func (repo *memoryRequestRepo) List( + _ context.Context, + filter domain.UserRequestListFilter, +) ([]domain.UserRequestState, error) { + states := make([]domain.UserRequestState, 0, len(repo.requests)) + for _, request := range repo.requests { + state := repo.state(request.ID) + if filter.Type != "" && state.Request.Type != filter.Type { + continue + } + if filter.Status != "" && state.CurrentStatus != filter.Status { + continue + } + if filter.Archived != nil && state.IsArchived != *filter.Archived { + continue + } + states = append(states, state) + } + return states, nil +} + +func (repo *memoryRequestRepo) Get(_ context.Context, requestID string) (domain.UserRequestState, error) { + if _, ok := repo.requests[requestID]; !ok { + return domain.UserRequestState{}, sql.ErrNoRows + } + return repo.state(requestID), nil +} + +func (repo *memoryRequestRepo) ListEvents(_ context.Context, requestID string) ([]domain.UserRequestEvent, error) { + events := append([]domain.UserRequestEvent(nil), repo.events[requestID]...) + return events, nil +} + +func (repo *memoryRequestRepo) AppendEvent(_ context.Context, event domain.UserRequestEvent) error { + if _, ok := repo.requests[event.RequestID]; !ok { + return sql.ErrNoRows + } + repo.events[event.RequestID] = append(repo.events[event.RequestID], event) + return nil +} + +func (repo *memoryRequestRepo) state(requestID string) domain.UserRequestState { + request := repo.requests[requestID] + state := domain.UserRequestState{ + Request: request, + CurrentStatus: domain.UserRequestStatusNew, + LatestEventAt: request.CreatedAt, + } + for _, event := range repo.events[requestID] { + if event.EventType == domain.UserRequestEventStatusChanged && event.Status != "" { + state.CurrentStatus = event.Status + } + if event.EventType == domain.UserRequestEventArchived { + state.IsArchived = true + } + if event.CreatedAt.After(state.LatestEventAt) || state.LatestEventAt.IsZero() { + state.LatestEventAt = event.CreatedAt + } + } + if state.LatestEventAt.IsZero() { + state.LatestEventAt = time.Now().UTC() + } + return state +} diff --git a/apps/api-go/internal/usecase/webhookprocessor/normalizer.go b/apps/api-go/internal/usecase/webhookprocessor/normalizer.go index 4707fe3..5225cb5 100644 --- a/apps/api-go/internal/usecase/webhookprocessor/normalizer.go +++ b/apps/api-go/internal/usecase/webhookprocessor/normalizer.go @@ -13,6 +13,8 @@ const ( EventTypeNew = "channel.subscription.new" EventTypeRenewal = "channel.subscription.renewal" EventTypeGifts = "channel.subscription.gifts" + + defaultSubscriptionExpiryDuration = 31 * 24 * time.Hour ) // ExtractBroadcasterUserID pulls broadcaster.user_id from any subscription payload. @@ -162,5 +164,5 @@ func resolveExpiry(createdAt time.Time, expiresAtStr string) time.Time { } } } - return createdAt.Add(30 * 24 * time.Hour) + return createdAt.Add(defaultSubscriptionExpiryDuration) } diff --git a/apps/api-go/internal/usecase/webhookprocessor/normalizer_test.go b/apps/api-go/internal/usecase/webhookprocessor/normalizer_test.go index c843bda..a8a375f 100644 --- a/apps/api-go/internal/usecase/webhookprocessor/normalizer_test.go +++ b/apps/api-go/internal/usecase/webhookprocessor/normalizer_test.go @@ -69,7 +69,7 @@ func TestNormalizeRenewal(t *testing.T) { } } -func TestNormalizeMissingExpiresAtFallsBackTo30Days(t *testing.T) { +func TestNormalizeMissingExpiresAtFallsBackTo31Days(t *testing.T) { payload := map[string]any{ "broadcaster": map[string]any{"user_id": 9000}, "subscriber": map[string]any{"user_id": 3003}, @@ -82,7 +82,7 @@ func TestNormalizeMissingExpiresAtFallsBackTo30Days(t *testing.T) { t.Fatalf("NormalizeEvent: %v", err) } p := periods[0] - wantExpiry := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + wantExpiry := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) if !p.ExpiresAt.Equal(wantExpiry) { t.Errorf("ExpiresAt = %v, want %v", p.ExpiresAt, wantExpiry) } @@ -161,6 +161,10 @@ func TestNormalizeGiftUsesGifteesFieldFallback(t *testing.T) { if len(periods) != 1 || periods[0].SubscriberKickUserID != 8001 { t.Fatalf("periods = %+v", periods) } + wantExpiry := time.Date(2026, 7, 2, 10, 0, 0, 0, time.UTC) + if !periods[0].ExpiresAt.Equal(wantExpiry) { + t.Errorf("gift ExpiresAt = %v, want %v", periods[0].ExpiresAt, wantExpiry) + } } func TestNormalizeUnsupportedEventTypeReturnsIgnored(t *testing.T) { diff --git a/apps/api-go/internal/usecase/webhookprocessor/service_test.go b/apps/api-go/internal/usecase/webhookprocessor/service_test.go index ffd09e7..ff422a1 100644 --- a/apps/api-go/internal/usecase/webhookprocessor/service_test.go +++ b/apps/api-go/internal/usecase/webhookprocessor/service_test.go @@ -437,6 +437,21 @@ func (r *fakePeriodRepo) ActiveSummary(_ context.Context, _ int64) (domain.Chann return domain.ChannelSubscriptionSummary{}, nil } +func (r *fakePeriodRepo) ListActiveSubscribers( + _ context.Context, + _ domain.ChannelSubscriberFilter, +) (domain.ChannelSubscriberPage, error) { + return domain.ChannelSubscriberPage{}, nil +} + +func (r *fakePeriodRepo) ExportActiveSubscribers( + _ context.Context, + _ int64, + _ bool, +) ([]domain.ChannelSubscriber, error) { + return nil, nil +} + var errFakeInsert = fmt.Errorf("fake insert error") func discardLogger() *slog.Logger { diff --git a/apps/web/public/app-logo.png b/apps/web/public/app-logo.png index 8e1b550..cd26f73 100644 Binary files a/apps/web/public/app-logo.png and b/apps/web/public/app-logo.png differ diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000..d9fa6c8 Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-96x96.png b/apps/web/public/favicon-96x96.png new file mode 100644 index 0000000..1751b4f Binary files /dev/null and b/apps/web/public/favicon-96x96.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000..900bad9 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..c8266b1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest new file mode 100644 index 0000000..5eea2df --- /dev/null +++ b/apps/web/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Kick Logs", + "short_name": "KickLogs", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone" +} diff --git a/apps/web/public/web-app-manifest-192x192.png b/apps/web/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..42a9e3d Binary files /dev/null and b/apps/web/public/web-app-manifest-192x192.png differ diff --git a/apps/web/public/web-app-manifest-512x512.png b/apps/web/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..cd26f73 Binary files /dev/null and b/apps/web/public/web-app-manifest-512x512.png differ diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 2929d98..3cbd086 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { useState } from "react"; -import { Activity, Database, LogOut, Menu, Radio, Settings, Users, X } from "lucide-react"; +import { Activity, Database, Inbox, LogOut, Menu, Radio, Settings, Users, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { logout } from "@/features/auth/api"; @@ -14,6 +14,7 @@ import { useCurrentUser } from "@/features/auth/use-auth"; const NAV_ITEMS = [ { label: "Operations", href: "/admin/operations", icon: Activity, superAdminOnly: false }, { label: "Channels", href: "/admin/channels", icon: Radio, superAdminOnly: false }, + { label: "Requests", href: "/admin/requests", icon: Inbox, superAdminOnly: false }, { label: "Users", href: "/admin/users", icon: Users, superAdminOnly: true }, { label: "Data", href: "/admin/data", icon: Database, superAdminOnly: false }, { label: "Settings", href: null, icon: Settings, superAdminOnly: false } diff --git a/apps/web/src/app/admin/requests/page.tsx b/apps/web/src/app/admin/requests/page.tsx new file mode 100644 index 0000000..1abd831 --- /dev/null +++ b/apps/web/src/app/admin/requests/page.tsx @@ -0,0 +1,5 @@ +import { RequestAdmin } from "@/features/requests/request-admin"; + +export default function AdminRequestsPage() { + return ; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3b23fbb..9bd7eea 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -9,10 +9,15 @@ import { NavigationProgress } from "@/components/navigation-progress"; export const metadata: Metadata = { title: "Kick Logs", description: "Self-hosted Kick chat log search", + manifest: "/site.webmanifest", icons: { - icon: "/app-logo.png", - shortcut: "/app-logo.png", - apple: "/app-logo.png" + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/favicon.svg", type: "image/svg+xml" }, + { url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" } + ], + shortcut: "/favicon.ico", + apple: "/apple-touch-icon.png" } }; diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx new file mode 100644 index 0000000..e6e5adf --- /dev/null +++ b/apps/web/src/app/not-found.tsx @@ -0,0 +1,94 @@ +import { ArrowLeft, Search } from "lucide-react"; +import Link from "next/link"; + +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+ + +
+
+
+

404

+

+ Aradığın sayfa bulunamadı. +

+

+ Bu bağlantı taşınmış, silinmiş veya hiç oluşmamış olabilir. Log aramasına dönebilir ya + da kayıtlı kanal ve kullanıcı sayfalarını inceleyebilirsin. +

+ +
+ + +
+
+ +
+
+ + Hızlı bağlantılar + + +
+ +
+
+
+
+ ); +} + +function QuickLink({ + description, + href, + label +}: { + description: string; + href: string; + label: string; +}) { + return ( + + {label} + {description} + + ); +} diff --git a/apps/web/src/app/request/page.tsx b/apps/web/src/app/request/page.tsx new file mode 100644 index 0000000..9be7aee --- /dev/null +++ b/apps/web/src/app/request/page.tsx @@ -0,0 +1,5 @@ +import { RequestPage } from "@/features/requests/request-page"; + +export default function PublicRequestPage() { + return ; +} diff --git a/apps/web/src/components/profile-loading.test.tsx b/apps/web/src/components/profile-loading.test.tsx new file mode 100644 index 0000000..00723ed --- /dev/null +++ b/apps/web/src/components/profile-loading.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ProfileLoading } from "@/components/profile-loading"; + +describe("ProfileLoading", () => { + it.each([ + { kind: "channel" as const, label: "Kanal profili", cells: 6, shape: "rounded-md" }, + { kind: "user" as const, label: "Kullan\u0131c\u0131 profili", cells: 4, shape: "rounded-full" } + ])("renders an accessible, non-interactive $kind skeleton", ({ kind, label, cells, shape }) => { + render(); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent(`${label} y\u00fckleniyor...`); + expect(status).toHaveAttribute("aria-live", "polite"); + const region = screen.getByRole("region", { name: label }); + expect(region).toHaveAttribute("aria-busy", "true"); + expect(region).not.toContainElement(status); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + + const placeholders = within(region).getByTestId("profile-placeholders"); + expect(placeholders).toHaveAttribute("aria-hidden", "true"); + expect(within(placeholders).getByTestId("profile-avatar")).toHaveClass( + "h-[72px]", + "w-[72px]", + "shrink-0", + shape + ); + expect(within(placeholders).getByTestId("profile-metrics").children).toHaveLength(cells); + expect(within(placeholders).getByTestId("profile-analytics").children).toHaveLength(3); + expect(within(placeholders).getByTestId("profile-messages").children).toHaveLength(4); + }); + + it("only animates when motion is safe and retains static placeholders for reduced motion", () => { + const { container } = render(); + const animated = Array.from(container.querySelectorAll("[class]")).filter((element) => + element.getAttribute("class")?.includes("animate-") + ); + + expect(animated.length).toBeGreaterThan(1); + for (const element of animated) { + expect(element.getAttribute("class")).toMatch(/motion-safe:animate-(pulse|spin)/); + expect(element).toHaveClass("motion-reduce:animate-none"); + expect(element).not.toHaveClass("animate-pulse", "animate-spin"); + } + expect(screen.getByTestId("profile-avatar")).toHaveClass("bg-elevated"); + }); +}); diff --git a/apps/web/src/components/profile-loading.tsx b/apps/web/src/components/profile-loading.tsx new file mode 100644 index 0000000..fa06d0d --- /dev/null +++ b/apps/web/src/components/profile-loading.tsx @@ -0,0 +1,147 @@ +import { LoaderCircle } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +const BAR_HEIGHTS = [34, 54, 42, 68, 48, 74, 38, 58, 46, 84, 52, 70, 44, 62]; + +export function ProfileLoading({ kind }: { kind: "channel" | "user" }) { + const isChannel = kind === "channel"; + const label = isChannel ? "Kanal profili" : "Kullan\u0131c\u0131 profili"; + + return ( +
+

+

+ + {/* Keep the live announcement outside the busy region so it is not deferred. */} +
+ +
+
+ ); +} + +function SkeletonHeading() { + return ( +
+ + +
+ ); +} + +function SkeletonBlock({ className, ...props }: ComponentProps<"span">) { + return ( + + ); +} diff --git a/apps/web/src/components/site-header.tsx b/apps/web/src/components/site-header.tsx index 3bd661a..6a33651 100644 --- a/apps/web/src/components/site-header.tsx +++ b/apps/web/src/components/site-header.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -type ActiveRoute = "search" | "channels" | "users" | "prediction" | "admin" | null; +type ActiveRoute = "search" | "channels" | "users" | "prediction" | "request" | "admin" | null; type SiteHeaderProps = { activeRoute?: ActiveRoute; @@ -19,7 +19,7 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { return ( <> -
+
@@ -48,6 +48,17 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { > + @@ -87,6 +98,18 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { ))}
+ setIsMenuOpen(false)} + className={cn( + "flex h-9 items-center rounded-md px-3 text-[13px] font-medium transition-colors", + activeRoute === "request" + ? "border border-border bg-panel text-foreground" + : "text-muted-foreground hover:bg-elevated hover:text-foreground" + )} + > + Talep + setIsMenuOpen(false)} diff --git a/apps/web/src/features/channel-profile/api.ts b/apps/web/src/features/channel-profile/api.ts index c7eef08..801bd58 100644 --- a/apps/web/src/features/channel-profile/api.ts +++ b/apps/web/src/features/channel-profile/api.ts @@ -1,5 +1,17 @@ import { apiClient, type ApiClient } from "@/lib/api-client"; -import type { ChannelProfile, ChannelSubscriptionSummary } from "@/types/api"; +import { API_BASE_URL } from "@/lib/constants"; +import type { + ChannelProfile, + ChannelSubscriberExportFormat, + ChannelSubscribersResponse, + ChannelSubscriptionSummary +} from "@/types/api"; + +export type ChannelSubscribersParams = { + limit?: number; + offset?: number; + gift_only?: boolean; +}; export function getChannelProfile(slug: string, client: ApiClient = apiClient) { return client.get(`/channels/${encodeURIComponent(slug)}/analytics`); @@ -10,3 +22,34 @@ export function getChannelSubscriptionSummary(slug: string, client: ApiClient = `/channels/${encodeURIComponent(slug)}/subscription-summary` ); } + +export function getChannelSubscribers( + slug: string, + params: ChannelSubscribersParams = {}, + client: ApiClient = apiClient +) { + return client.get( + `/channels/${encodeURIComponent(slug)}/subscribers`, + { + limit: params.limit, + offset: params.offset, + gift_only: params.gift_only + } + ); +} + +export function buildChannelSubscribersExportUrl( + slug: string, + giftOnly: boolean, + format: ChannelSubscriberExportFormat, + baseUrl = API_BASE_URL +) { + const url = new URL( + `${baseUrl.replace(/\/+$/, "")}/channels/${encodeURIComponent(slug)}/subscribers/export` + ); + url.searchParams.set("format", format); + if (giftOnly) { + url.searchParams.set("gift_only", "true"); + } + return url.toString(); +} diff --git a/apps/web/src/features/channel-profile/channel-profile-page.test.tsx b/apps/web/src/features/channel-profile/channel-profile-page.test.tsx index bda0ac4..0bcd0bc 100644 --- a/apps/web/src/features/channel-profile/channel-profile-page.test.tsx +++ b/apps/web/src/features/channel-profile/channel-profile-page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChannelProfilePage } from "@/features/channel-profile/channel-profile-page"; @@ -6,7 +6,9 @@ import { ApiClientError } from "@/lib/api-client"; import type { ChannelProfile } from "@/types/api"; const profileMocks = vi.hoisted(() => ({ + buildChannelSubscribersExportUrl: vi.fn(), getChannelProfile: vi.fn(), + getChannelSubscribers: vi.fn(), getChannelSubscriptionSummary: vi.fn() })); @@ -19,14 +21,38 @@ vi.mock("@/features/channel-profile/api", () => profileMocks); describe("ChannelProfilePage", () => { beforeEach(() => { profileMocks.getChannelProfile.mockReset(); + profileMocks.getChannelSubscribers.mockReset(); profileMocks.getChannelSubscriptionSummary.mockReset(); + profileMocks.buildChannelSubscribersExportUrl.mockReset(); profileMocks.getChannelProfile.mockResolvedValue(profileFixture()); + profileMocks.getChannelSubscribers.mockResolvedValue({ + channel_slug: "hype", + gift_only: false, + count: 1, + limit: 50, + offset: 0, + items: [ + { + subscriber_kick_user_id: 123, + username: "subscriber_one", + slug: "subscriber-one", + profile_image_url: "", + is_gift: false, + started_at: "2026-06-01T10:00:00Z", + expires_at: "2026-07-01T10:00:00Z" + } + ] + }); profileMocks.getChannelSubscriptionSummary.mockResolvedValue({ channel_slug: "hype", active_count: 42, active_gifted_count: 5, latest_event_at: null }); + profileMocks.buildChannelSubscribersExportUrl.mockImplementation( + (slug: string, giftOnly: boolean, format: string) => + `http://localhost:8000/channels/${slug}/subscribers/export?format=${format}${giftOnly ? "&gift_only=true" : ""}` + ); }); it("renders channel analytics and latest messages", async () => { @@ -54,12 +80,118 @@ describe("ChannelProfilePage", () => { expect(screen.getByRole("link", { name: /@alpha/ })).toHaveAttribute("href", "/users/alpha"); }); - it("renders loading state", () => { - profileMocks.getChannelProfile.mockReturnValue(new Promise(() => undefined)); + it("opens the active subscriber modal from the stat cell", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Aktif aboneleri görüntüle" })); + + await waitFor(() => + expect(profileMocks.getChannelSubscribers).toHaveBeenCalledWith("hype", { + limit: 50, + offset: 0, + gift_only: false + }) + ); + expect(await screen.findByRole("heading", { name: "Aktif aboneler" })).toBeInTheDocument(); + expect(screen.getByText("subscriber_one")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "subscriber_one" })).toHaveAttribute( + "href", + "/users/subscriber-one" + ); + }); + + it("opens the gifted subscriber modal and export menu", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + + render(); + + fireEvent.click( + await screen.findByRole("button", { name: "Hediye aktif aboneleri görüntüle" }) + ); + + await waitFor(() => + expect(profileMocks.getChannelSubscribers).toHaveBeenCalledWith("hype", { + limit: 50, + offset: 0, + gift_only: true + }) + ); + expect( + await screen.findByRole("heading", { name: "Hediye aktif aboneler" }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Abone listesini indir" })); + fireEvent.click(screen.getByRole("button", { name: "TXT indir" })); + + expect(openSpy).toHaveBeenCalledWith( + "http://localhost:8000/channels/hype/subscribers/export?format=txt&gift_only=true", + "_blank", + "noopener,noreferrer" + ); + + openSpy.mockRestore(); + }); + + it("renders subscriber empty state", async () => { + profileMocks.getChannelSubscribers.mockResolvedValue({ + channel_slug: "hype", + gift_only: false, + count: 0, + limit: 50, + offset: 0, + items: [] + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Aktif aboneleri görüntüle" })); + + expect( + await screen.findByText("Bu kanal için henüz aktif abonelik kaydı yok.") + ).toBeInTheDocument(); + }); + + it("keeps the header and breadcrumb available while loading, then replaces the skeleton", async () => { + let resolveProfile!: (profile: ChannelProfile) => void; + profileMocks.getChannelProfile.mockReturnValue( + new Promise((resolve) => { + resolveProfile = resolve; + }) + ); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Kanal profili yükleniyor..."); + expect(screen.getByRole("region", { name: "Kanal profili" })).toHaveAttribute( + "aria-busy", + "true" + ); + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect( + within(screen.getByRole("navigation", { name: "Breadcrumb" })).getByText("hype") + ).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /kanalda ara/i })).not.toBeInTheDocument(); + + await act(async () => resolveProfile(profileFixture())); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Kanal profili" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Hype" })).toBeInTheDocument(); + expect(profileMocks.getChannelProfile).toHaveBeenCalledExactlyOnceWith("hype"); + }); + + it("replaces loading with the existing error and search action", async () => { + profileMocks.getChannelProfile.mockRejectedValue(new ApiClientError(500, { detail: "failed" })); render(); - expect(screen.getByText("Kanal profili yükleniyor...")).toBeInTheDocument(); + expect(await screen.findByText("Kanal profili şu anda alınamadı.")).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Kanal profili" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /search'te ara/i })).toHaveAttribute( + "href", + "/search?channel=hype" + ); }); it("renders empty profile sections", async () => { @@ -95,6 +227,7 @@ describe("ChannelProfilePage", () => { render(); expect(await screen.findByText("Kanal bulunamadı.")).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: /search'e dön/i })).toHaveAttribute("href", "/search"); }); }); diff --git a/apps/web/src/features/channel-profile/channel-profile-page.tsx b/apps/web/src/features/channel-profile/channel-profile-page.tsx index c9502e5..a0196c9 100644 --- a/apps/web/src/features/channel-profile/channel-profile-page.tsx +++ b/apps/web/src/features/channel-profile/channel-profile-page.tsx @@ -8,9 +8,14 @@ import type { CSSProperties } from "react"; import { useEffect, useState } from "react"; import { KickProfileLink } from "@/components/kick-profile-link"; +import { ProfileLoading } from "@/components/profile-loading"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { getChannelProfile, getChannelSubscriptionSummary } from "@/features/channel-profile/api"; +import { + ChannelSubscribersDialog, + type ChannelSubscriberMode +} from "@/features/channel-profile/channel-subscribers-dialog"; import { MessageContent } from "@/features/search/message-content"; import { formatMessageDate } from "@/features/search/search-params"; import { ApiClientError } from "@/lib/api-client"; @@ -74,7 +79,7 @@ export function ChannelProfilePage({ slug }: { slug: string }) {
- {status === "loading" ? : null} + {status === "loading" ? : null} {status === "not-found" ? ( (null); + const canOpenSubscribers = Boolean(subscription); return (
@@ -171,14 +178,25 @@ function ProfileContent({ { label: "İLK LOG", value: formatShortDate(profile.overview.first_message_at) }, { label: "AKTİF ABONE", - value: subscription ? formatCompactNumber(subscription.active_count) : "—" + value: subscription ? formatCompactNumber(subscription.active_count) : "—", + actionLabel: "Aktif aboneleri görüntüle", + onSelect: canOpenSubscribers ? () => setSubscriberMode("all") : undefined }, { label: "HEDİYE ABONE", - value: subscription ? formatCompactNumber(subscription.active_gifted_count) : "—" + value: subscription ? formatCompactNumber(subscription.active_gifted_count) : "—", + actionLabel: "Hediye aktif aboneleri görüntüle", + onSelect: canOpenSubscribers ? () => setSubscriberMode("gifted") : undefined } ]} /> + { + if (!open) setSubscriberMode(null); + }} + /> {/* 3-column analytics grid */}
void; value: string }; function ProfileStatsBar({ cells }: { cells: StatCell[] }) { return ( @@ -295,19 +313,40 @@ function ProfileStatsBar({ cells }: { cells: StatCell[] }) { className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border md:grid-cols-3 xl:grid-cols-6" > {cells.map((cell) => ( -
-
- {cell.label} -
-
- {cell.value} -
-
+ ))}
); } +function StatCellView({ cell }: { cell: StatCell }) { + const content = ( + <> +
+ {cell.label} +
+
+ {cell.value} +
+ + ); + + if (cell.onSelect) { + return ( + + ); + } + + return
{content}
; +} + function AnalyticsPanel({ title, subtitle, @@ -341,7 +380,7 @@ function VolumeChart({ points }: { points: MessageVolumePoint[] }) {
{points.map((point) => { const ratio = max > 0 ? point.message_count / max : 0; - const heightPct = max > 0 ? Math.max(ratio * 100, 4) : 4; + const heightPct = max > 0 && point.message_count > 0 ? Math.max(ratio * 100, 4) : 2; return (
void; +}; + +export function ChannelSubscribersDialog({ + channelSlug, + mode, + onOpenChange +}: ChannelSubscribersDialogProps) { + const open = mode !== null; + const giftOnly = mode === "gifted"; + const [items, setItems] = useState([]); + const [count, setCount] = useState(0); + const [status, setStatus] = useState("idle"); + const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); + const exportMenuRef = useRef(null); + + useEffect(() => { + if (!open || !mode) { + setItems([]); + setCount(0); + setStatus("idle"); + setIsExportMenuOpen(false); + return; + } + + let isMounted = true; + setItems([]); + setCount(0); + setStatus("loading"); + setIsExportMenuOpen(false); + + getChannelSubscribers(channelSlug, { + limit: PAGE_SIZE, + offset: 0, + gift_only: giftOnly + }) + .then((page) => { + if (!isMounted) return; + setItems(page.items); + setCount(page.count); + setStatus("ready"); + }) + .catch(() => { + if (!isMounted) return; + setStatus("error"); + }); + + return () => { + isMounted = false; + }; + }, [channelSlug, giftOnly, mode, open]); + + useEffect(() => { + if (!isExportMenuOpen) { + return; + } + + function closeOnOutsideClick(event: MouseEvent | TouchEvent) { + const target = event.target; + if (target instanceof Node && !exportMenuRef.current?.contains(target)) { + setIsExportMenuOpen(false); + } + } + + document.addEventListener("mousedown", closeOnOutsideClick); + document.addEventListener("touchstart", closeOnOutsideClick); + + return () => { + document.removeEventListener("mousedown", closeOnOutsideClick); + document.removeEventListener("touchstart", closeOnOutsideClick); + }; + }, [isExportMenuOpen]); + + async function loadMore() { + if (!mode || status === "loading-more") { + return; + } + + setStatus("loading-more"); + try { + const page = await getChannelSubscribers(channelSlug, { + limit: PAGE_SIZE, + offset: items.length, + gift_only: giftOnly + }); + setItems((current) => [...current, ...page.items]); + setCount(page.count); + setStatus("ready"); + } catch { + setStatus("error"); + } + } + + function exportSubscribers(format: ChannelSubscriberExportFormat) { + if (!mode) { + return; + } + setIsExportMenuOpen(false); + window.open( + buildChannelSubscribersExportUrl(channelSlug, giftOnly, format), + "_blank", + "noopener,noreferrer" + ); + } + + const hasMore = items.length < count; + const isInitialLoading = status === "loading"; + const title = giftOnly ? "Hediye aktif aboneler" : "Aktif aboneler"; + + return ( + + + onOpenChange(false)} /> +
+
+
+ + {title} + + #{channelSlug} · {formatCompactNumber(count)} kayıt + + + +
+ + + {isExportMenuOpen ? ( +
+ } + label="JSON indir" + onClick={() => exportSubscribers("json")} + /> + } + label="CSV indir" + onClick={() => exportSubscribers("csv")} + /> + } + label="TXT indir" + onClick={() => exportSubscribers("txt")} + /> +
+ ) : null} +
+
+
+ +
+ {isInitialLoading ? ( + } + text="Aboneler yükleniyor..." + /> + ) : null} + + {status === "error" ? ( + + ) : null} + + {status !== "loading" && status !== "error" && items.length === 0 ? ( + + ) : null} + + {items.length > 0 ? ( +
+ {items.map((subscriber) => ( + + ))} +
+ ) : null} +
+ + {items.length > 0 ? ( +
+
+

+ {formatCompactNumber(items.length)} / {formatCompactNumber(count)} +

+ +
+
+ ) : null} +
+
+
+ ); +} + +function SubscriberRow({ subscriber }: { subscriber: ChannelSubscriber }) { + const href = buildUserProfileHref(subscriber.slug); + const gifterHref = subscriber.gifter_slug ? buildUserProfileHref(subscriber.gifter_slug) : null; + const username = + subscriber.username || subscriber.slug || String(subscriber.subscriber_kick_user_id); + + return ( +
+
+ +
+ {href ? ( + + {username} + + ) : ( + {username} + )} +
+ ID {subscriber.subscriber_kick_user_id} + {subscriber.is_gift ? ( + + + Hediye + + ) : null} + {subscriber.is_gift && subscriber.gifter_username ? ( + + veren{" "} + {gifterHref ? ( + + {subscriber.gifter_username} + + ) : ( + subscriber.gifter_username + )} + + ) : null} +
+
+
+ + + +
+ ); +} + +function SubscriberAvatar({ + subscriber, + username +}: { + subscriber: ChannelSubscriber; + username: string; +}) { + const [failed, setFailed] = useState(false); + const imageUrl = subscriber.profile_image_url; + + if (imageUrl && !failed) { + return ( + {`${username} setFailed(true)} + src={imageUrl} + width={36} + /> + ); + } + + return ( +
+ {username.slice(0, 1) || "?"} +
+ ); +} + +function DateBlock({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{formatDateTime(value)}
+
+ ); +} + +function ExportButton({ + icon, + label, + onClick +}: { + icon: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function StateMessage({ + icon, + text, + tone = "default" +}: { + icon?: ReactNode; + text: string; + tone?: "default" | "danger"; +}) { + return ( +
+ {icon} + {text} +
+ ); +} + +const COMPACT_FORMATTER = new Intl.NumberFormat("tr-TR", { + notation: "compact", + maximumFractionDigits: 1 +}); + +function formatCompactNumber(value: number) { + return COMPACT_FORMATTER.format(value); +} + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat("tr-TR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(new Date(value)); +} diff --git a/apps/web/src/features/channel-profile/channels-index-page.test.tsx b/apps/web/src/features/channel-profile/channels-index-page.test.tsx index e56a5bc..6a4eecf 100644 --- a/apps/web/src/features/channel-profile/channels-index-page.test.tsx +++ b/apps/web/src/features/channel-profile/channels-index-page.test.tsx @@ -1,159 +1,66 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChannelsIndexPage } from "@/features/channel-profile/channels-index-page"; -import type { TopChannelsResponse } from "@/types/api"; -const analyticsMocks = vi.hoisted(() => ({ - getTopChannels: vi.fn() +const directoryMocks = vi.hoisted(() => ({ + getDirectoryChannels: vi.fn(), + getDirectoryUsers: vi.fn() })); - +const analyticsMocks = vi.hoisted(() => ({ getTopChannels: vi.fn(), getTopSenders: vi.fn() })); +vi.mock("@/features/directory/api", () => directoryMocks); +vi.mock("@/features/analytics/api", () => analyticsMocks); vi.mock("next/image", () => ({ default: ({ alt }: { alt: string }) => })); -vi.mock("@/features/analytics/api", () => analyticsMocks); - describe("ChannelsIndexPage", () => { beforeEach(() => { - analyticsMocks.getTopChannels.mockReset(); - analyticsMocks.getTopChannels.mockResolvedValue(channelsFixture()); - }); - - it("renders empty idle prompt on initial load without calling the API", () => { - render(); - - expect(screen.getByText("Kanal bulmak için arama yapın")).toBeInTheDocument(); - expect(analyticsMocks.getTopChannels).not.toHaveBeenCalled(); - }); - - it("renders search input and submit button", () => { - render(); - - expect(screen.getByRole("searchbox", { name: /kanal ara/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^ara$/i })).toBeInTheDocument(); - }); - - it("does not call the API while the user is typing", async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "hype"); - - expect(analyticsMocks.getTopChannels).not.toHaveBeenCalled(); - }); - - it("submit button is disabled until query has at least 2 characters", async () => { - const user = userEvent.setup(); - render(); - - const submit = screen.getByRole("button", { name: /^ara$/i }); - expect(submit).toBeDisabled(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "h"); - expect(submit).toBeDisabled(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "y"); - expect(submit).not.toBeDisabled(); - }); - - it("shows channel results after clicking Ara button", async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "hype"); - await user.click(screen.getByRole("button", { name: /^ara$/i })); - - await waitFor(() => - expect(analyticsMocks.getTopChannels).toHaveBeenCalledWith( - expect.objectContaining({ q: "hype", limit: 20 }) - ) - ); - - expect(await screen.findByText("Hype")).toBeInTheDocument(); - expect(screen.getByText("#hype")).toBeInTheDocument(); - expect(screen.getByText("GameZone")).toBeInTheDocument(); + vi.clearAllMocks(); + directoryMocks.getDirectoryChannels.mockResolvedValue({ + items: [ + { + id: 1, + name: "example_user", + slug: "example_user", + profile_image_url: "https://example.com/avatar.png" + } + ], + next_cursor: null + }); }); - it("triggers search on Enter key in the input", async () => { + it("searches the identity directory and preserves avatars and stored profile links without analytics", async () => { const user = userEvent.setup(); render(); - - const input = screen.getByRole("searchbox", { name: /kanal ara/i }); - await user.type(input, "hype{Enter}"); - - await waitFor(() => - expect(analyticsMocks.getTopChannels).toHaveBeenCalledWith( - expect.objectContaining({ q: "hype", limit: 20 }) - ) + await user.type(screen.getByRole("searchbox", { name: "Kanal ara" }), " example {Enter}"); + expect(await screen.findByRole("link", { name: /example_user/ })).toHaveAttribute( + "href", + "/channels/example_user" ); - }); - - it("shows empty state when API returns no results", async () => { - analyticsMocks.getTopChannels.mockResolvedValue({ items: [] }); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "xyz"); - await user.click(screen.getByRole("button", { name: /^ara$/i })); - - await waitFor(() => - expect(screen.getByText(/"xyz" için kanal bulunamadı\./)).toBeInTheDocument() + const avatar = screen.getByRole("img", { name: "example_user" }); + expect(avatar).toHaveAttribute("src", "https://example.com/avatar.png"); + fireEvent.error(avatar); + expect(screen.queryByRole("img", { name: "example_user" })).not.toBeInTheDocument(); + expect(directoryMocks.getDirectoryChannels).toHaveBeenCalledWith( + { prefix: "example", limit: 50 }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) ); + expect(directoryMocks.getDirectoryUsers).not.toHaveBeenCalled(); + expect(analyticsMocks.getTopChannels).not.toHaveBeenCalled(); + expect(screen.queryByText(/mesaj|son aktivite/i)).not.toBeInTheDocument(); }); - it("shows error state when API fails", async () => { - analyticsMocks.getTopChannels.mockRejectedValue(new Error("network error")); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "err"); - await user.click(screen.getByRole("button", { name: /^ara$/i })); - - await waitFor(() => expect(screen.getByText(/sonuçlar alınamadı/i)).toBeInTheDocument()); - }); - - it("each channel row links to the channel profile page", async () => { + it("preserves the fallback row when a stored identity has no link", async () => { + directoryMocks.getDirectoryChannels.mockResolvedValue({ + items: [{ id: 2, name: "nolink", slug: "", profile_image_url: null }], + next_cursor: null + }); const user = userEvent.setup(); render(); - - await user.type(screen.getByRole("searchbox", { name: /kanal ara/i }), "hype"); - await user.click(screen.getByRole("button", { name: /^ara$/i })); - - await waitFor(() => expect(screen.getByText("Hype")).toBeInTheDocument()); - - const links = screen.getAllByRole("link"); - const hypeLink = links.find((l) => l.getAttribute("href") === "/channels/hype"); - expect(hypeLink).toBeDefined(); + await user.type(screen.getByRole("searchbox"), "nolink{Enter}"); + expect(await screen.findByText("nolink")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /nolink/ })).not.toBeInTheDocument(); }); }); - -function channelsFixture(): TopChannelsResponse { - return { - items: [ - { - channel_id: 1, - slug: "hype", - display_name: "Hype", - profile_image_url: null, - banner_image_url: null, - message_count: 1500, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - }, - { - channel_id: 2, - slug: "gamezone", - display_name: "GameZone", - profile_image_url: null, - banner_image_url: null, - message_count: 800, - first_message_at: "2026-05-02T10:00:00Z", - latest_message_at: "2026-05-13T08:00:00Z" - } - ] - }; -} diff --git a/apps/web/src/features/channel-profile/channels-index-page.tsx b/apps/web/src/features/channel-profile/channels-index-page.tsx index fe382e2..826e15a 100644 --- a/apps/web/src/features/channel-profile/channels-index-page.tsx +++ b/apps/web/src/features/channel-profile/channels-index-page.tsx @@ -1,224 +1,7 @@ "use client"; -/* eslint-disable @next/next/no-img-element */ - -import { Hash, Search } from "lucide-react"; -import Link from "next/link"; -import { useCallback, useState } from "react"; - -import { SiteHeader } from "@/components/site-header"; -import { getTopChannels } from "@/features/analytics/api"; -import type { TopChannelAnalytics } from "@/types/api"; - -type SearchState = "idle" | "loading" | "ready" | "empty" | "error"; - -const RESULT_LIMIT = 20; -const MIN_QUERY_LENGTH = 2; +import { DirectoryIndex } from "@/features/directory/directory-index"; export function ChannelsIndexPage() { - const [query, setQuery] = useState(""); - const [submittedQuery, setSubmittedQuery] = useState(""); - const [results, setResults] = useState([]); - const [state, setState] = useState("idle"); - - const search = useCallback(async (q: string) => { - setState("loading"); - try { - const data = await getTopChannels({ q, limit: RESULT_LIMIT }); - const items = data?.items ?? []; - setResults(items); - setState(items.length === 0 ? "empty" : "ready"); - } catch { - setState("error"); - setResults([]); - } - }, []); - - const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = query.trim(); - if (trimmed.length < MIN_QUERY_LENGTH) { - return; - } - setSubmittedQuery(trimmed); - void search(trimmed); - }, - [query, search] - ); - - const canSubmit = query.trim().length >= MIN_QUERY_LENGTH && state !== "loading"; - - return ( -
- - -
- {/* Page title */} -
-

- Channels -

-

- Mesaj sayısına göre sıralı -

-
- - {/* Search form */} -
-
- - setQuery(e.target.value)} - placeholder="Kanal adı veya slug ile ara…" - spellCheck={false} - type="search" - value={query} - /> -
- -
- - {/* Content area */} - {state === "idle" ? : null} - {state === "loading" ? : null} - {state === "error" ? : null} - {state === "empty" ? : null} - {state === "ready" ? : null} -
-
- ); -} - -function IdlePrompt() { - return ( -
-
- -
-

Kanal bulmak için arama yapın

-

- Kanal adı veya slug girin ve Ara butonuna basın. -

-
- ); -} - -function LoadingState() { - return ( -
- - aranıyor… -
- ); -} - -function ErrorState() { - return

Sonuçlar alınamadı. Lütfen tekrar deneyin.

; -} - -function EmptyState({ query }: { query: string }) { - return ( -

- "{query}" için kanal bulunamadı. -

- ); -} - -function ChannelList({ channels }: { channels: TopChannelAnalytics[] }) { - return ( -
-
- {channels.map((channel) => ( - - ))} -
-
- ); -} - -function ChannelRow({ channel }: { channel: TopChannelAnalytics }) { - const [imgFailed, setImgFailed] = useState(false); - const initial = channel.display_name.charAt(0).toUpperCase() || "#"; - - return ( - - {/* Channel image */} - {channel.profile_image_url && !imgFailed ? ( - {channel.display_name} setImgFailed(true)} - src={channel.profile_image_url} - width={40} - /> - ) : ( -
- {initial} -
- )} - - {/* Name & slug */} -
-

{channel.display_name}

-

#{channel.slug}

-
- - {/* Message count */} -
-

- {formatCompactNumber(channel.message_count)} -

-

- mesaj -

-
- - {/* Last activity */} -
-

- {formatShortDate(channel.latest_message_at)} -

-

- son aktivite -

-
- - ); -} - -const COMPACT_FORMATTER = new Intl.NumberFormat("tr-TR", { - notation: "compact", - maximumFractionDigits: 1 -}); - -function formatCompactNumber(value: number) { - return COMPACT_FORMATTER.format(value); -} - -function formatShortDate(value: string | null) { - if (!value) return "—"; - return new Intl.DateTimeFormat("tr-TR", { - day: "2-digit", - month: "short", - year: "numeric" - }).format(new Date(value)); + return ; } diff --git a/apps/web/src/features/directory/api.test.ts b/apps/web/src/features/directory/api.test.ts new file mode 100644 index 0000000..65ec485 --- /dev/null +++ b/apps/web/src/features/directory/api.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getDirectoryChannels, getDirectoryUsers } from "@/features/directory/api"; +import { createApiClient } from "@/lib/api-client"; + +describe.each([ + ["users", getDirectoryUsers], + ["channels", getDirectoryChannels] +] as const)("%s directory API", (kind, search) => { + it("encodes literal prefixes and keyset cursors, forwarding cancellation", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ items: [], next_cursor: null }))); + const client = createApiClient({ baseUrl: "https://api.example.com", fetcher }); + const signal = new AbortController().signal; + expect( + await search( + { prefix: "Yav_%' OR 1=1", limit: 50, after: "opaque+cursor" }, + { signal }, + client + ) + ).toEqual({ items: [], next_cursor: null }); + const [raw, init] = fetcher.mock.calls[0]; + const url = new URL(raw); + expect(url.pathname).toBe(`/directory/${kind}`); + expect(Object.fromEntries(url.searchParams)).toEqual({ + prefix: "Yav_%' OR 1=1", + limit: "50", + after: "opaque+cursor" + }); + expect(init.signal).toBe(signal); + }); + + it("surfaces API errors instead of treating them as empty results", async () => { + const client = createApiClient({ + baseUrl: "https://api.example.com", + fetcher: vi + .fn() + .mockResolvedValue(new Response('{"detail":"Too many requests."}', { status: 429 })) + }); + await expect(search({ prefix: "ya" }, {}, client)).rejects.toMatchObject({ status: 429 }); + }); +}); diff --git a/apps/web/src/features/directory/api.ts b/apps/web/src/features/directory/api.ts new file mode 100644 index 0000000..19f036f --- /dev/null +++ b/apps/web/src/features/directory/api.ts @@ -0,0 +1,18 @@ +import { apiClient, type ApiClient } from "@/lib/api-client"; +import type { DirectoryPage, DirectoryQuery } from "@/types/directory"; + +export function getDirectoryUsers( + query: DirectoryQuery, + options: Pick = {}, + client: ApiClient = apiClient +) { + return client.get("/directory/users", query, options); +} + +export function getDirectoryChannels( + query: DirectoryQuery, + options: Pick = {}, + client: ApiClient = apiClient +) { + return client.get("/directory/channels", query, options); +} diff --git a/apps/web/src/features/directory/directory-index.test.tsx b/apps/web/src/features/directory/directory-index.test.tsx new file mode 100644 index 0000000..9fb56d1 --- /dev/null +++ b/apps/web/src/features/directory/directory-index.test.tsx @@ -0,0 +1,209 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChannelsIndexPage } from "@/features/channel-profile/channels-index-page"; +import { UsersIndexPage } from "@/features/user-profile/users-index-page"; + +const mocks = vi.hoisted(() => ({ getDirectoryUsers: vi.fn(), getDirectoryChannels: vi.fn() })); +vi.mock("@/features/directory/api", () => mocks); +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => +})); + +function page(name = "alpha", next: string | null = null, id = 1) { + return { items: [{ id, name, slug: name, profile_image_url: null }], next_cursor: next }; +} + +function deferred() { + let resolve!: (value: ReturnType) => void; + let reject!: (reason: Error) => void; + const promise = new Promise>((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +describe.each([ + { kind: "users", Component: UsersIndexPage, api: mocks.getDirectoryUsers }, + { kind: "channels", Component: ChannelsIndexPage, api: mocks.getDirectoryChannels } +])("$kind directory interaction", ({ Component, api }) => { + beforeEach(() => { + vi.resetAllMocks(); + api.mockResolvedValue(page()); + }); + + it("makes no idle or typing requests, even after a debounce interval", () => { + vi.useFakeTimers(); + try { + render(); + act(() => vi.advanceTimersByTime(10000)); + expect(api).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "alpha" } }); + act(() => vi.advanceTimersByTime(10000)); + expect(api).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("gates empty, short, overlong and control-character prefixes", () => { + render(); + const input = screen.getByRole("searchbox"); + const submit = screen.getByRole("button", { name: "Ara" }); + for (const value of ["", " ", " a ", "a".repeat(161), "ab\u0000"]) { + fireEvent.change(input, { target: { value } }); + expect(submit).toBeDisabled(); + fireEvent.submit(input.closest("form")!); + expect(api).not.toHaveBeenCalled(); + } + fireEvent.change(input, { target: { value: " ab " } }); + expect(submit).toBeEnabled(); + }); + + it("shows loading and prevents duplicate submissions of the in-flight prefix", async () => { + const pending = deferred(); + api.mockReturnValue(pending.promise); + render(); + const input = screen.getByRole("searchbox"); + fireEvent.change(input, { target: { value: "alpha" } }); + fireEvent.submit(input.closest("form")!); + expect(screen.getByRole("button", { name: /Aranıyor/ })).toBeDisabled(); + expect(screen.getByRole("status")).toBeInTheDocument(); + fireEvent.submit(input.closest("form")!); + expect(api).toHaveBeenCalledTimes(1); + await act(async () => pending.resolve(page())); + expect(screen.getByRole("link", { name: /alpha/ })).toBeInTheDocument(); + }); + + it("quotes the submitted prefix in empty results, not unsent input", async () => { + api.mockResolvedValue({ items: [], next_cursor: null }); + const user = userEvent.setup(); + render(); + const input = screen.getByRole("searchbox"); + await user.type(input, "missing{Enter}"); + expect(await screen.findByText(/"missing".*bulunamadı/)).toBeInTheDocument(); + await user.clear(input); + await user.type(input, "unsent"); + expect(screen.getByText(/"missing".*bulunamadı/)).toBeInTheDocument(); + expect(api).toHaveBeenCalledTimes(1); + }); + + it("shows a recoverable first-page error and clears it after resubmit", async () => { + api.mockRejectedValueOnce(new Error("offline")); + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("searchbox"), "alpha{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent(/Sonuçlar alınamadı/); + await user.click(screen.getByRole("button", { name: "Ara" })); + expect(await screen.findByRole("link", { name: /alpha/ })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("appends cursor pages for the submitted prefix while retaining previous rows on retry", async () => { + api + .mockResolvedValueOnce(page("alpha", "page-two")) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(page("alpha-two", null, 2)); + const user = userEvent.setup(); + render(); + const input = screen.getByRole("searchbox"); + await user.type(input, "alpha{Enter}"); + await screen.findByRole("link", { name: /alpha/ }); + await user.clear(input); + await user.type(input, "unsent"); + await user.click(screen.getByRole("button", { name: "Daha fazla yükle" })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /alpha/ })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Daha fazla yükle" })); + expect(await screen.findByRole("link", { name: /alpha-two/ })).toBeInTheDocument(); + expect(screen.getAllByRole("link", { name: /alpha/ })).toHaveLength(2); + expect(api).toHaveBeenLastCalledWith( + { prefix: "alpha", limit: 50, after: "page-two" }, + expect.anything() + ); + expect(screen.queryByRole("button", { name: "Daha fazla yükle" })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps existing rows when the final page is empty and prevents duplicate append requests", async () => { + const pending = deferred(); + api.mockResolvedValueOnce(page("alpha", "page-two")).mockReturnValueOnce(pending.promise); + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("searchbox"), "alpha{Enter}"); + const more = await screen.findByRole("button", { name: "Daha fazla yükle" }); + await user.click(more); + fireEvent.click(more); + expect(api).toHaveBeenCalledTimes(2); + expect(more).toBeDisabled(); + await act(async () => pending.resolve({ items: [], next_cursor: null })); + expect(screen.getByRole("link", { name: /alpha/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Daha fazla yükle" })).not.toBeInTheDocument(); + }); + + it.each(["resolve", "reject"] as const)( + "ignores stale first-page %s after a newer submitted search", + async (outcome) => { + const old = deferred(); + api.mockReturnValueOnce(old.promise).mockResolvedValueOnce(page("beta")); + render(); + const input = screen.getByRole("searchbox"); + fireEvent.change(input, { target: { value: "alpha" } }); + fireEvent.submit(input.closest("form")!); + const signal = api.mock.calls[0][1].signal as AbortSignal; + fireEvent.change(input, { target: { value: "beta" } }); + fireEvent.submit(input.closest("form")!); + expect(await screen.findByRole("link", { name: /beta/ })).toBeInTheDocument(); + expect(signal.aborted).toBe(true); + await act(async () => { + if (outcome === "resolve") old.resolve(page()); + else old.reject(new Error("old")); + }); + expect(screen.getByRole("link", { name: /beta/ })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /alpha/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } + ); + + it.each(["resolve", "reject"] as const)( + "ignores stale append %s after a replacement search", + async (outcome) => { + const old = deferred(); + api + .mockResolvedValueOnce(page("alpha", "page-two")) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce(page("beta")); + const user = userEvent.setup(); + render(); + const input = screen.getByRole("searchbox"); + await user.type(input, "alpha{Enter}"); + await user.click(await screen.findByRole("button", { name: "Daha fazla yükle" })); + await user.clear(input); + await user.type(input, "beta{Enter}"); + expect(await screen.findByRole("link", { name: /beta/ })).toBeInTheDocument(); + await act(async () => { + if (outcome === "resolve") old.resolve(page("alpha-two", null, 2)); + else old.reject(new Error("old")); + }); + expect(screen.getByRole("link", { name: /beta/ })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /alpha/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } + ); + + it("aborts an in-flight request on unmount", async () => { + const pending = deferred(); + api.mockReturnValue(pending.promise); + const { unmount } = render(); + const input = screen.getByRole("searchbox"); + fireEvent.change(input, { target: { value: "alpha" } }); + fireEvent.submit(input.closest("form")!); + const signal = api.mock.calls[0][1].signal as AbortSignal; + unmount(); + expect(signal.aborted).toBe(true); + await act(async () => pending.resolve(page())); + expect(api).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/features/directory/directory-index.tsx b/apps/web/src/features/directory/directory-index.tsx new file mode 100644 index 0000000..73db8b7 --- /dev/null +++ b/apps/web/src/features/directory/directory-index.tsx @@ -0,0 +1,188 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ + +import { ChevronDown, Hash, Loader2, Search, User } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { SiteHeader } from "@/components/site-header"; +import { + useDirectorySearch, + validDirectoryPrefix +} from "@/features/directory/use-directory-search"; +import { buildChannelProfileHref } from "@/lib/channel-profile-slugs"; +import { buildUserProfileHref } from "@/lib/kick-profile-slugs"; +import type { DirectoryIdentity, DirectoryKind } from "@/types/directory"; + +export function DirectoryIndex({ kind }: { kind: DirectoryKind }) { + const [query, setQuery] = useState(""); + const directory = useDirectorySearch(kind); + const users = kind === "users"; + const Icon = users ? User : Hash; + const loading = directory.state === "loading"; + const label = users ? "Kullanıcı" : "Kanal"; + + return ( +
+ +
+

+ {users ? "Users" : "Channels"} +

+
{ + event.preventDefault(); + void directory.search(query); + }} + > +
+ + setQuery(event.target.value)} + placeholder={`${label} adı veya slug`} + spellCheck={false} + type="search" + value={query} + /> +
+ +
+ + {directory.state === "idle" ? ( +
+ +

+ {label} bulmak için arama yapın +

+
+ ) : null} + {loading ? ( +

+ {" "} + Aranıyor… +

+ ) : null} + {directory.state === "empty" ? ( +

+ "{directory.submittedQuery}" için {users ? "kullanıcı" : "kanal"} bulunamadı. +

+ ) : null} + {directory.items.length > 0 ? ( +
+ {directory.items.map((identity) => ( + + ))} +
+ ) : null} + {directory.error ? ( +

+ Sonuçlar alınamadı. Lütfen tekrar deneyin. +

+ ) : null} + {directory.nextCursor ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function IdentityRow({ identity, kind }: { identity: DirectoryIdentity; kind: DirectoryKind }) { + const [imageFailed, setImageFailed] = useState(false); + const users = kind === "users"; + const href = users ? buildUserProfileHref(identity.slug) : buildChannelProfileHref(identity.slug); + const shape = users ? "rounded-full" : "rounded-md"; + const content = ( + <> + {identity.profile_image_url && !imageFailed ? ( + {identity.name} setImageFailed(true)} + src={identity.profile_image_url} + width={40} + /> + ) : ( +
+ {identity.name.charAt(0).toUpperCase() || (users ? "U" : "#")} +
+ )} +
+

+ {users ? "@" : ""} + {identity.name} +

+ {identity.slug && (!users || identity.slug !== identity.name) ? ( +

+ {users ? "" : "#"} + {identity.slug} +

+ ) : null} +
+ + ); + const className = + "flex items-center gap-3 px-3 py-3 transition-colors hover:bg-elevated sm:gap-4 sm:px-4"; + return href ? ( + + {content} + + ) : ( +
{content}
+ ); +} diff --git a/apps/web/src/features/directory/use-directory-search.ts b/apps/web/src/features/directory/use-directory-search.ts new file mode 100644 index 0000000..db56d4f --- /dev/null +++ b/apps/web/src/features/directory/use-directory-search.ts @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react"; + +import { getDirectoryChannels, getDirectoryUsers } from "@/features/directory/api"; +import type { DirectoryIdentity, DirectoryKind } from "@/types/directory"; + +type SearchState = "idle" | "loading" | "ready" | "empty" | "error"; +type ActiveRequest = { prefix: string; append: boolean; controller: AbortController }; + +export function validDirectoryPrefix(value: string) { + const length = Array.from(value.trim()).length; + return ( + length >= 2 && + length <= 160 && + !Array.from(value).some((character) => { + const code = character.codePointAt(0)!; + return code < 32 || (code >= 127 && code <= 159); + }) + ); +} + +export function useDirectorySearch(kind: DirectoryKind) { + const [submittedQuery, setSubmittedQuery] = useState(""); + const [items, setItems] = useState([]); + const [state, setState] = useState("idle"); + const [nextCursor, setNextCursor] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(false); + const active = useRef(null); + + useEffect( + () => () => { + active.current?.controller.abort(); + active.current = null; + }, + [] + ); + + async function search(value: string, append = false) { + if (!validDirectoryPrefix(value)) return; + const prefix = value.trim(); + if (append && (active.current || !nextCursor)) return; + if (!append && active.current?.prefix === prefix && !active.current.append) return; + + active.current?.controller.abort(); + const request: ActiveRequest = { prefix, append, controller: new AbortController() }; + active.current = request; + setError(false); + setLoadingMore(append); + if (!append) { + setSubmittedQuery(prefix); + setItems([]); + setNextCursor(null); + setState("loading"); + } + + try { + const fetchPage = kind === "users" ? getDirectoryUsers : getDirectoryChannels; + const data = await fetchPage( + { prefix, limit: 50, ...(append && nextCursor ? { after: nextCursor } : {}) }, + { signal: request.controller.signal } + ); + // A cancelled fetch may still settle; only the current submission owns UI state. + if (active.current !== request) return; + setItems((previous) => { + if (!append) return data.items; + const seen = new Set(previous.map((item) => item.id)); + return [...previous, ...data.items.filter((item) => !seen.has(item.id))]; + }); + setNextCursor(data.next_cursor); + setState(append || data.items.length > 0 ? "ready" : "empty"); + } catch { + if (active.current !== request) return; + setError(true); + if (!append) setState("error"); + } finally { + if (active.current === request) { + active.current = null; + setLoadingMore(false); + } + } + } + + return { + submittedQuery, + items, + state, + nextCursor, + loadingMore, + error, + search: (prefix: string) => search(prefix), + loadMore: () => search(submittedQuery, true) + }; +} diff --git a/apps/web/src/features/landing/api.test.ts b/apps/web/src/features/landing/api.test.ts new file mode 100644 index 0000000..d2135d4 --- /dev/null +++ b/apps/web/src/features/landing/api.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getHomepage } from "@/features/landing/api"; +import { ApiClientError, createApiClient } from "@/lib/api-client"; + +describe("getHomepage", () => { + it.each([200, 202])( + "reads a %s response through one uncached request with the abort signal", + async (status) => { + const response = + status === 202 + ? { status: "initializing", retry_after_seconds: 5 } + : { + status: "ready", + as_of: "2026-05-15T00:05:00Z", + start: "2026-05-01T00:00:00Z", + end: "2026-05-15T00:00:00Z", + timezone: "UTC", + stale: false, + overview: { + total_messages: 0, + total_senders: 0, + total_channels: 0, + total_emote_usages: 0, + first_message_at: null, + latest_message_at: null + }, + message_volume: [], + top_channels: [], + top_senders: [], + top_emotes: [] + }; + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), { status })); + const client = createApiClient({ baseUrl: "https://api.example.test", fetcher }); + const controller = new AbortController(); + + await expect(getHomepage(controller.signal, client)).resolves.toEqual(response); + + expect(fetcher).toHaveBeenCalledExactlyOnceWith( + "https://api.example.test/analytics/homepage", + expect.objectContaining({ method: "GET", cache: "no-store", signal: controller.signal }) + ); + } + ); + + it("propagates HTTP errors instead of substituting empty analytics", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response("unavailable", { status: 503 })); + const client = createApiClient({ baseUrl: "https://api.example.test", fetcher }); + + await expect(getHomepage(undefined, client)).rejects.toBeInstanceOf(ApiClientError); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/features/landing/api.ts b/apps/web/src/features/landing/api.ts new file mode 100644 index 0000000..8d1af16 --- /dev/null +++ b/apps/web/src/features/landing/api.ts @@ -0,0 +1,36 @@ +import { apiClient, type ApiClient } from "@/lib/api-client"; +import type { + AnalyticsOverview, + MessageVolumePoint, + TopChannelAnalytics, + TopEmoteAnalytics, + TopSenderAnalytics +} from "@/types/api"; + +export type HomepageSnapshot = { + status: "ready"; + as_of: string; + start: string; + end: string; + timezone: "UTC"; + stale: boolean; + overview: AnalyticsOverview; + message_volume: MessageVolumePoint[]; + top_channels: TopChannelAnalytics[]; + top_senders: TopSenderAnalytics[]; + top_emotes: TopEmoteAnalytics[]; +}; + +export type HomepageResponse = + | HomepageSnapshot + | { status: "initializing"; retry_after_seconds: number }; + +export function getHomepage( + signal?: AbortSignal, + client: ApiClient = apiClient +): Promise { + return client.get("/analytics/homepage", undefined, { + cache: "no-store", + signal + }); +} diff --git a/apps/web/src/features/landing/landing-page.test.tsx b/apps/web/src/features/landing/landing-page.test.tsx index f8f5e9e..9180088 100644 --- a/apps/web/src/features/landing/landing-page.test.tsx +++ b/apps/web/src/features/landing/landing-page.test.tsx @@ -1,199 +1,320 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HomepageResponse, HomepageSnapshot } from "@/features/landing/api"; import { LandingPage } from "@/features/landing/landing-page"; -const analyticsMocks = vi.hoisted(() => ({ - getAnalyticsOverview: vi.fn(), - getMessageVolume: vi.fn(), - getTopChannels: vi.fn(), - getTopEmotes: vi.fn(), - getTopSenders: vi.fn() -})); +const homepageMocks = vi.hoisted(() => ({ getHomepage: vi.fn() })); -vi.mock("@/features/analytics/api", () => analyticsMocks); +vi.mock("@/features/landing/api", () => homepageMocks); +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => +})); describe("LandingPage", () => { - const testTimeoutMs = 15000; - beforeEach(() => { - analyticsMocks.getAnalyticsOverview.mockReset(); - analyticsMocks.getMessageVolume.mockReset(); - analyticsMocks.getTopChannels.mockReset(); - analyticsMocks.getTopEmotes.mockReset(); - analyticsMocks.getTopSenders.mockReset(); - - analyticsMocks.getAnalyticsOverview.mockResolvedValue({ - total_messages: 0, - total_senders: 0, - total_channels: 0, - total_emote_usages: 0, - first_message_at: null, - latest_message_at: null - }); - analyticsMocks.getMessageVolume.mockResolvedValue({ items: [] }); - analyticsMocks.getTopChannels.mockResolvedValue({ items: [] }); - analyticsMocks.getTopEmotes.mockResolvedValue({ items: [] }); - analyticsMocks.getTopSenders.mockResolvedValue({ items: [] }); - }); - - it( - "renders the v2 hero, stats bar, and analytics panels", - async () => { - analyticsMocks.getAnalyticsOverview.mockResolvedValue({ - total_messages: 482, - total_senders: 76, - total_channels: 5, - total_emote_usages: 314, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - }); - analyticsMocks.getMessageVolume.mockResolvedValue({ - items: [ - { bucket_start: "2026-05-13T00:00:00Z", message_count: 40 }, - { bucket_start: "2026-05-14T00:00:00Z", message_count: 60 } - ] - }); - analyticsMocks.getTopChannels.mockResolvedValue({ - items: [ - { - channel_id: 1, - slug: "hype", - display_name: "Hype", - profile_image_url: null, - banner_image_url: null, - message_count: 500, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - } - ] - }); - analyticsMocks.getTopEmotes.mockResolvedValue({ - items: [ - { - id: "37226", - name: "KEKW", - token: "[emote:37226:KEKW]", - image_url: "https://files.kick.com/emotes/37226/fullsize", - usage_count: 99, - message_count: 80 - } - ] - }); - analyticsMocks.getTopSenders.mockResolvedValue({ - items: [ - { - sender_id: 1, - kick_user_id: 10, - username: "Yavuz", - slug: "yavuz", - profile_image_url: null, - message_count: 120, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - } - ] - }); - - render(); - - expect( - await screen.findByRole("heading", { name: /kick chat için kalıcı log\./i }) - ).toBeInTheDocument(); - - expect(screen.getByText("TOPLAM MESAJ")).toBeInTheDocument(); - expect(screen.getByText("KANAL")).toBeInTheDocument(); - expect(screen.getByText("KULLANICI")).toBeInTheDocument(); - expect(screen.getByText("EMOTE")).toBeInTheDocument(); - - expect(screen.getByRole("heading", { name: "Mesaj hacmi" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Top kanallar" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Top kullanıcılar" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Top emoteler" })).toBeInTheDocument(); - - expect(await screen.findByText("Hype")).toBeInTheDocument(); - expect(screen.getByText("KEKW")).toBeInTheDocument(); - expect(screen.getByText("Yavuz")).toBeInTheDocument(); - }, - testTimeoutMs - ); + vi.useFakeTimers(); + homepageMocks.getHomepage.mockReset(); + homepageMocks.getHomepage.mockResolvedValue(snapshotFixture()); + }); - it("shows panel-level empty hints on a fresh install", async () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders all five 14-day panels from one ready snapshot and stops polling", async () => { render(); + await flush(); - expect(await screen.findByText("Kanal verisi henüz yok.")).toBeInTheDocument(); - expect(screen.getByText("Kullanıcı verisi henüz yok.")).toBeInTheDocument(); - expect(screen.getByText("Emote verisi henüz yok.")).toBeInTheDocument(); - expect(screen.getByText("Henüz veri yok.")).toBeInTheDocument(); + expect(screen.getByText("482")).toBeInTheDocument(); + expect(screen.getByText("Hype")).toBeInTheDocument(); + expect(screen.getByText("Yavuz")).toBeInTheDocument(); + expect(screen.getByText("KEKW")).toBeInTheDocument(); + expect(screen.getAllByText("Son 14 gün")).toHaveLength(5); + expect(screen.getByRole("heading", { name: "Mesaj hacmi" })).toBeInTheDocument(); + expect(homepageMocks.getHomepage).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + await advance(180000); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); }); - it("keeps successful analytics visible when one panel fails", async () => { - analyticsMocks.getAnalyticsOverview.mockResolvedValue({ - total_messages: 482, - total_senders: 76, - total_channels: 5, - total_emote_usages: 314, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - }); - analyticsMocks.getMessageVolume.mockResolvedValue({ - items: [{ bucket_start: "2026-05-14T00:00:00Z", message_count: 60 }] - }); - analyticsMocks.getTopChannels.mockRejectedValue(new Error("memory limit")); - analyticsMocks.getTopEmotes.mockResolvedValue({ - items: [ - { - id: "37226", - name: "KEKW", - token: "[emote:37226:KEKW]", - image_url: "https://files.kick.com/emotes/37226/fullsize", - usage_count: 99, - message_count: 80 - } - ] + it("keeps the hero, header, footer and profile navigation", async () => { + render(); + await flush(); + + expect(screen.getByRole("heading", { name: "Kick chat için kalıcı log." })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Arama başlat" })).toHaveAttribute("href", "/search"); + expect(screen.getByRole("link", { name: "Admin" })).toHaveAttribute("href", "/admin"); + expect(screen.getByRole("link", { name: /Hype/ })).toHaveAttribute("href", "/channels/hype"); + expect(screen.getByRole("link", { name: /Yavuz/ })).toHaveAttribute("href", "/users/yavuz"); + expect( + screen + .getAllByRole("link", { name: "GitHub" }) + .every((link) => link.getAttribute("href") === "https://github.com/YSelim0/kick-logs") + ).toBe(true); + expect( + within(screen.getByRole("contentinfo")).getByRole("link", { name: "Talep" }) + ).toHaveAttribute("href", "/request"); + }); + + it("shows motion-safe placeholders without zero values or empty hints while pending", () => { + homepageMocks.getHomepage.mockReturnValue(new Promise(() => {})); + const { container } = render(); + + expect(screen.getByLabelText("TOPLAM MESAJ yükleniyor")).toBeInTheDocument(); + expect(screen.getByLabelText("Mesaj hacmi yükleniyor")).toBeInTheDocument(); + expect(screen.getAllByLabelText("Liste yükleniyor")).toHaveLength(3); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect(screen.queryByText(/verisi henüz yok/)).not.toBeInTheDocument(); + const pulses = container.querySelectorAll('[class*="animate-pulse"]'); + expect(pulses.length).toBeGreaterThan(0); + for (const pulse of pulses) { + expect(pulse).toHaveClass("motion-safe:animate-pulse", "motion-reduce:animate-none"); + expect(pulse).not.toHaveClass("animate-pulse"); + } + }); + + it("retries initializing after five seconds and stops after ready", async () => { + homepageMocks.getHomepage.mockResolvedValueOnce({ + status: "initializing", + retry_after_seconds: 5 }); - analyticsMocks.getTopSenders.mockResolvedValue({ - items: [ - { - sender_id: 1, - kick_user_id: 10, - username: "Yavuz", - slug: "yavuz", - profile_image_url: null, - message_count: 120, - first_message_at: "2026-05-01T10:00:00Z", - latest_message_at: "2026-05-14T09:30:00Z" - } - ] + render(); + await flush(); + + expect(screen.queryByText("0")).not.toBeInTheDocument(); + await advance(4999); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + await advance(1); + expect(screen.getByText("482")).toBeInTheDocument(); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(2); + await advance(180000); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(2); + }); + + it.each([ + { value: 1, delay: 5000 }, + { value: 12, delay: 12000 }, + { value: 90, delay: 30000 } + ])("clamps retry_after_seconds=$value to the allowed delay", async ({ value, delay }) => { + homepageMocks.getHomepage.mockResolvedValueOnce({ + status: "initializing", + retry_after_seconds: value }); + render(); + await flush(); + await advance(delay - 1); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + await advance(1); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(2); + expect(screen.getByText("482")).toBeInTheDocument(); + }); + + it("cancels a scheduled retry on unmount", async () => { + homepageMocks.getHomepage.mockResolvedValue({ status: "initializing", retry_after_seconds: 5 }); + const { unmount } = render(); + await flush(); + const signal = homepageMocks.getHomepage.mock.calls[0][0] as AbortSignal; + unmount(); + + expect(signal.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + await advance(180000); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + }); + + it("aborts in-flight requests and ignores late initializing responses after unmount", async () => { + let resolve!: (response: HomepageResponse) => void; + homepageMocks.getHomepage.mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + const { unmount } = render(); + const signal = homepageMocks.getHomepage.mock.calls[0][0] as AbortSignal; + unmount(); + await act(async () => resolve({ status: "initializing", retry_after_seconds: 5 })); + expect(signal.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + }); + + it("stops after 24 attempts and offers manual retry without fake zeros", async () => { + homepageMocks.getHomepage.mockResolvedValue({ status: "initializing", retry_after_seconds: 5 }); render(); + await flush(); + await advance(115000); - expect( - await screen.findByText( - "Bazı analytics panelleri şu anda alınamadı. Mevcut veriler gösteriliyor." - ) - ).toBeInTheDocument(); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(24); + expect(screen.getByRole("alert")).toHaveTextContent(/alınamadı/); + expect(screen.getByRole("button", { name: "Tekrar dene" })).toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("TOPLAM MESAJ yükleniyor")).not.toBeInTheDocument(); + await advance(60000); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(24); + + homepageMocks.getHomepage.mockResolvedValue(snapshotFixture()); + fireEvent.click(screen.getByRole("button", { name: "Tekrar dene" })); + await flush(); expect(screen.getByText("482")).toBeInTheDocument(); - expect(screen.getByText("Kanal verisi henüz yok.")).toBeInTheDocument(); - expect(screen.getByText("KEKW")).toBeInTheDocument(); - expect(screen.getByText("Yavuz")).toBeInTheDocument(); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(25); + }); + + it("enforces the two-minute deadline even with longer retry delays", async () => { + homepageMocks.getHomepage.mockResolvedValue({ + status: "initializing", + retry_after_seconds: 30 + }); + render(); + await flush(); + await advance(120000); + + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(4); + expect(screen.getByRole("button", { name: "Tekrar dene" })).toBeInTheDocument(); + expect(vi.getTimerCount()).toBe(0); }); - it("renders v2 header and CTA links", async () => { + it("aborts a hung request at the deadline and ignores a late ready response", async () => { + let resolve!: (response: HomepageResponse) => void; + homepageMocks.getHomepage.mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); render(); + const signal = homepageMocks.getHomepage.mock.calls[0][0] as AbortSignal; + await advance(120000); + await act(async () => resolve(snapshotFixture())); - await waitFor(() => expect(analyticsMocks.getAnalyticsOverview).toHaveBeenCalledTimes(1)); + expect(signal.aborted).toBe(true); + expect(screen.getByRole("button", { name: "Tekrar dene" })).toBeInTheDocument(); + expect(screen.queryByText("482")).not.toBeInTheDocument(); + }); - expect(linkExists(/^search$/i, "/search")).toBe(true); - expect(linkExists(/^admin$/i, "/admin")).toBe(true); - expect(linkExists(/arama başlat/i, "/search")).toBe(true); - expect(linkExists(/github/i, "https://github.com/YSelim0/kick-logs")).toBe(true); + it("does not retry request failures automatically or fabricate empty data", async () => { + homepageMocks.getHomepage.mockRejectedValue(new Error("unavailable")); + render(); + await flush(); + await advance(180000); - expect(screen.queryByRole("link", { name: /support/i })).toBeNull(); + expect(screen.getByRole("alert")).toHaveTextContent(/alınamadı/); + expect(screen.getByRole("button", { name: "Tekrar dene" })).toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect(screen.queryByText(/verisi henüz yok/)).not.toBeInTheDocument(); + expect(homepageMocks.getHomepage).toHaveBeenCalledTimes(1); + }); + + it.each([false, true])("hides snapshot timestamps when stale is %s", async (stale) => { + homepageMocks.getHomepage.mockResolvedValue({ ...snapshotFixture(), stale }); + const { container } = render(); + await flush(); + + expect(screen.queryByText(/Veri aralığı \(UTC\)/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Güncelleme \(UTC\)/)).not.toBeInTheDocument(); + expect(container.querySelector("time")).toBeNull(); + expect(screen.getAllByText("Son 14 gün")).toHaveLength(5); + if (stale) { + expect(screen.getByRole("status")).toHaveTextContent(/güncel olmayabilir/); + } else { + expect(screen.queryByText(/güncel olmayabilir/)).not.toBeInTheDocument(); + } + expect(screen.getByText("482")).toBeInTheDocument(); + }); + + it("renders real empty values and retains the zero-day chart baseline in UTC", async () => { + homepageMocks.getHomepage.mockResolvedValue({ + ...snapshotFixture(), + overview: { + total_messages: 0, + total_senders: 0, + total_channels: 0, + total_emote_usages: 0, + first_message_at: null, + latest_message_at: null + }, + message_volume: [{ bucket_start: "2026-05-14T23:00:00Z", message_count: 0 }], + top_channels: [], + top_senders: [], + top_emotes: [] + }); + render(); + await flush(); + + expect( + within(screen.getByRole("region", { name: "Genel metrikler" })).getAllByText("0") + ).toHaveLength(4); + expect(screen.getByLabelText("14 May · 0 mesaj")).toHaveStyle({ height: "2%" }); + expect(screen.getByText("Kanal verisi henüz yok.")).toBeInTheDocument(); + expect(screen.getByText("Kullanıcı verisi henüz yok.")).toBeInTheDocument(); + expect(screen.getByText("Emote verisi henüz yok.")).toBeInTheDocument(); }); }); -function linkExists(name: RegExp, href: string) { - return screen.getAllByRole("link", { name }).some((link) => link.getAttribute("href") === href); +async function flush() { + await act(async () => {}); +} + +async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + +function snapshotFixture(): HomepageSnapshot { + return { + status: "ready", + as_of: "2026-05-15T00:05:00Z", + start: "2026-05-01T00:00:00Z", + end: "2026-05-15T00:00:00Z", + timezone: "UTC", + stale: false, + overview: { + total_messages: 482, + total_senders: 76, + total_channels: 5, + total_emote_usages: 314, + first_message_at: "2026-05-01T10:00:00Z", + latest_message_at: "2026-05-14T09:30:00Z" + }, + message_volume: [ + { bucket_start: "2026-05-13T00:00:00Z", message_count: 40 }, + { bucket_start: "2026-05-14T00:00:00Z", message_count: 60 } + ], + top_channels: [ + { + channel_id: 1, + slug: "hype", + display_name: "Hype", + profile_image_url: null, + banner_image_url: null, + message_count: 400, + first_message_at: "2026-05-01T10:00:00Z", + latest_message_at: "2026-05-14T09:30:00Z" + } + ], + top_senders: [ + { + sender_id: 1, + kick_user_id: 10, + username: "Yavuz", + slug: "yavuz", + profile_image_url: null, + message_count: 120, + first_message_at: "2026-05-01T10:00:00Z", + latest_message_at: "2026-05-14T09:30:00Z" + } + ], + top_emotes: [ + { + id: "37226", + name: "KEKW", + token: "[emote:37226:KEKW]", + image_url: "https://files.kick.com/emotes/37226/fullsize", + usage_count: 99, + message_count: 80 + } + ] + }; } diff --git a/apps/web/src/features/landing/landing-page.tsx b/apps/web/src/features/landing/landing-page.tsx index 5021aad..b7c5bba 100644 --- a/apps/web/src/features/landing/landing-page.tsx +++ b/apps/web/src/features/landing/landing-page.tsx @@ -2,17 +2,11 @@ /* eslint-disable @next/next/no-img-element */ -import { Copyright, Github, Search } from "lucide-react"; +import { Copyright, Github, RotateCw, Search } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { - getAnalyticsOverview, - getMessageVolume, - getTopChannels, - getTopEmotes, - getTopSenders -} from "@/features/analytics/api"; +import { getHomepage, type HomepageSnapshot } from "@/features/landing/api"; import { Button } from "@/components/ui/button"; import { SiteHeader } from "@/components/site-header"; import { cn } from "@/lib/utils"; @@ -24,84 +18,72 @@ import type { TopSenderAnalytics } from "@/types/api"; -type LandingAnalyticsState = { - overview: AnalyticsOverview; - volume: MessageVolumePoint[]; - topChannels: TopChannelAnalytics[]; - topEmotes: TopEmoteAnalytics[]; - topSenders: TopSenderAnalytics[]; -}; +type HomepageStatus = "loading" | "initializing" | "ready" | "error"; -const EMPTY_OVERVIEW: AnalyticsOverview = { - total_messages: 0, - total_senders: 0, - total_channels: 0, - total_emote_usages: 0, - first_message_at: null, - latest_message_at: null -}; +const MAX_ATTEMPTS = 24; +const LOAD_TIMEOUT_MS = 120000; export function LandingPage() { - const [analytics, setAnalytics] = useState(null); - const [status, setStatus] = useState<"loading" | "ready" | "partial" | "error">("loading"); + const [analytics, setAnalytics] = useState(null); + const [status, setStatus] = useState("loading"); + const [requestVersion, setRequestVersion] = useState(0); useEffect(() => { - let isMounted = true; + const controller = new AbortController(); + let stopped = false; + let attempts = 0; + let retryTimer: ReturnType | undefined; + const deadlineTimer = setTimeout(fail, LOAD_TIMEOUT_MS); + + setAnalytics(null); + setStatus("loading"); + + function fail() { + if (stopped) return; + stopped = true; + clearTimeout(retryTimer); + clearTimeout(deadlineTimer); + controller.abort(); + setAnalytics(null); + setStatus("error"); + } async function loadAnalytics() { - setStatus("loading"); - + attempts += 1; try { - const recentRange = getRecentVolumeRange(); - const overview = await settle(getAnalyticsOverview()); - const volume = await settle( - getMessageVolume({ bucket: "day", end: recentRange.end, start: recentRange.start }) - ); - const topChannels = await settle(getTopChannels({ limit: 5 })); - const topEmotes = await settle(getTopEmotes({ limit: 5 })); - const topSenders = await settle(getTopSenders({ limit: 5 })); - - if (!isMounted) { - return; + const snapshot = await getHomepage(controller.signal); + if (stopped) return; + + if (snapshot.status === "ready") { + stopped = true; + clearTimeout(deadlineTimer); + setAnalytics(snapshot); + setStatus("ready"); + } else if (snapshot.status === "initializing" && attempts < MAX_ATTEMPTS) { + setStatus("initializing"); + const retrySeconds = Number.isFinite(snapshot.retry_after_seconds) + ? Math.min(30, Math.max(5, snapshot.retry_after_seconds)) + : 5; + retryTimer = setTimeout(() => void loadAnalytics(), retrySeconds * 1000); + } else { + fail(); } - - const fulfilledCount = [ - overview.status, - volume.status, - topChannels.status, - topEmotes.status, - topSenders.status - ].filter((resultStatus) => resultStatus === "fulfilled").length; - if (fulfilledCount === 0) { - setAnalytics(null); - setStatus("error"); - return; - } - - setAnalytics({ - overview: valueOr(overview, EMPTY_OVERVIEW), - volume: itemsOrEmpty(volume), - topChannels: itemsOrEmpty(topChannels), - topEmotes: itemsOrEmpty(topEmotes), - topSenders: itemsOrEmpty(topSenders) - }); - setStatus(fulfilledCount === 5 ? "ready" : "partial"); } catch { - if (isMounted) { - setAnalytics(null); - setStatus("error"); - } + fail(); } } void loadAnalytics(); return () => { - isMounted = false; + stopped = true; + clearTimeout(retryTimer); + clearTimeout(deadlineTimer); + controller.abort(); }; - }, []); + }, [requestVersion]); - const overview = analytics?.overview ?? EMPTY_OVERVIEW; + const isLoading = status === "loading" || status === "initializing"; return (
@@ -110,14 +92,27 @@ export function LandingPage() {
- - - setRequestVersion((version) => version + 1)} /> + {analytics?.stale ? ( +

+ Veriler güncel olmayabilir. Son başarılı anlık görüntü gösteriliyor. +

+ ) : null} + {status !== "error" ? ( + <> + + + + ) : null}