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 @@
+RealFaviconGenerator https://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.
+
+
+
+
+
+
+ Arama sayfası
+
+
+
+
+
+ Ana sayfaya dön
+
+
+
+
+
+
+
+
+ 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 (
+
+
+
+ {`${label} y\u00fckleniyor...`}
+
+
+ {/* Keep the live announcement outside the busy region so it is not deferred. */}
+
+
+
+
+
+
+
+
+ {!isChannel ? : null}
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: isChannel ? 6 : 4 }, (_, index) => (
+
+
+
+
+ ))}
+
+
+
+ {[0, 1, 2].map((panel) => (
+
+
+ {panel === 0 ? (
+
+ {BAR_HEIGHTS.map((height, index) => (
+
+ ))}
+
+ ) : (
+
+ {Array.from({ length: 5 }, (_, index) => (
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+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) {
>
+
+ Talep
+
Admin
@@ -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 (
+
+ {content}
+
+ );
+ }
+
+ 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
+
+
+
+
+
setIsExportMenuOpen((current) => !current)}
+ title="Abone listesini indir"
+ type="button"
+ >
+
+
+
+ {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)}
+
+
+ {status === "loading-more" ? (
+
+ ) : null}
+ Daha fazla yükle
+
+
+
+ ) : 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 (
+ 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 (
+
+ {icon}
+ {label}
+
+ );
+}
+
+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 */}
-
-
- {/* 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 ? (
- 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"}
+
+
+
+ {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 ? (
+
+ void directory.loadMore()}
+ type="button"
+ >
+ {directory.loadingMore ? (
+
+ ) : (
+
+ )}
+ {directory.loadingMore ? "Yükleniyor…" : "Daha fazla yükle"}
+
+
+ ) : 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 ? (
+ 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}
@@ -128,11 +123,17 @@ export function LandingPage() {
function Footer() {
return (
-
+
{new Date().getFullYear()} kick-logs · Tüm hakları saklıdır.
+
+ Talep
+
);
@@ -169,49 +170,59 @@ function Hero() {
);
}
-function StatsBar({ overview }: { overview: AnalyticsOverview }) {
- const cells: { label: string; value: string }[] = [
- { label: "TOPLAM MESAJ", value: formatCompactNumber(overview.total_messages) },
- { label: "KANAL", value: formatCompactNumber(overview.total_channels) },
- { label: "KULLANICI", value: formatCompactNumber(overview.total_senders) },
- { label: "EMOTE", value: formatCompactNumber(overview.total_emote_usages) }
+function StatsBar({ overview }: { overview: AnalyticsOverview | null }) {
+ const cells = [
+ { label: "TOPLAM MESAJ", value: overview?.total_messages },
+ { label: "KANAL", value: overview?.total_channels },
+ { label: "KULLANICI", value: overview?.total_senders },
+ { label: "EMOTE", value: overview?.total_emote_usages }
];
return (
-
- {cells.map((cell) => (
-
-
{cell.label}
-
- {cell.value}
+
+ Son 14 gün
+
+ {cells.map((cell) => (
+
+
{cell.label}
+ {cell.value === undefined ? (
+
+ ) : (
+
+ {formatCompactNumber(cell.value)}
+
+ )}
-
- ))}
+ ))}
+
);
}
function AnalyticsGrid({
+ isLoading,
volume,
topChannels,
topSenders,
topEmotes
}: {
+ isLoading: boolean;
volume: MessageVolumePoint[];
topChannels: TopChannelAnalytics[];
topSenders: TopSenderAnalytics[];
topEmotes: TopEmoteAnalytics[];
}) {
return (
-
-
-
+
+
+
-
+
({
key: String(channel.channel_id),
label: channel.display_name,
@@ -223,8 +234,9 @@ function AnalyticsGrid({
emptyText="Kanal verisi henüz yok."
/>
-
+
({
key: String(sender.sender_id),
label: sender.username,
@@ -236,8 +248,9 @@ function AnalyticsGrid({
emptyText="Kullanıcı verisi henüz yok."
/>
-
+
({
key: emote.id,
label: emote.name,
@@ -271,7 +284,17 @@ function Panel({
);
}
-function MessageVolumeChart({ points }: { points: MessageVolumePoint[] }) {
+function MessageVolumeChart({
+ isLoading,
+ points
+}: {
+ isLoading: boolean;
+ points: MessageVolumePoint[];
+}) {
+ if (isLoading) {
+ return ;
+ }
+
if (!points.length) {
return ;
}
@@ -282,7 +305,7 @@ function MessageVolumeChart({ 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 (
;
+ }
+
if (!rows.length) {
return
;
}
@@ -387,18 +422,29 @@ function TopList({ rows, emptyText }: { rows: TopRow[]; emptyText: string }) {
);
}
-function StatusBanner({ status }: { status: "loading" | "ready" | "partial" | "error" }) {
+function StatusBanner({ status, onRetry }: { status: HomepageStatus; onRetry: () => void }) {
if (status === "ready") {
return null;
}
return (
-
- {status === "loading"
- ? "Analytics verileri yükleniyor…"
- : status === "partial"
- ? "Bazı analytics panelleri şu anda alınamadı. Mevcut veriler gösteriliyor."
- : "Analytics verileri şu anda alınamadı. Arama ve admin bağlantıları kullanılabilir."}
+
+
+ {status === "loading"
+ ? "Analytics verileri yükleniyor…"
+ : status === "initializing"
+ ? "Analytics verileri hazırlanıyor…"
+ : "Analytics verileri şu anda alınamadı. Arama ve admin bağlantıları kullanılabilir."}
+
+ {status === "error" ? (
+
+
+ Tekrar dene
+
+ ) : null}
);
}
@@ -407,33 +453,58 @@ function EmptyHint({ text }: { text: string }) {
return
{text}
;
}
-function getRecentVolumeRange() {
- const end = new Date();
- const start = new Date(end);
- start.setDate(start.getDate() - 13);
- start.setHours(0, 0, 0, 0);
- end.setHours(23, 59, 59, 999);
-
- return {
- end: end.toISOString(),
- start: start.toISOString()
- };
+function SkeletonBlock({
+ ariaLabel,
+ className,
+ styleHeight
+}: {
+ ariaLabel?: string;
+ className: string;
+ styleHeight?: string;
+}) {
+ return (
+
+ );
}
-function settle
(promise: Promise) {
- return Promise.allSettled([promise]).then((results) => results[0]);
-}
+function MessageVolumeSkeleton() {
+ const heights = [34, 54, 42, 68, 48, 74, 38, 58, 46, 84, 52, 70, 44, 62];
-function isFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult {
- return result.status === "fulfilled";
-}
-
-function valueOr(result: PromiseSettledResult, fallback: T): T {
- return isFulfilled(result) ? result.value : fallback;
+ return (
+
+ {heights.map((height, index) => (
+
+
+
+ ))}
+
+ );
}
-function itemsOrEmpty(result: PromiseSettledResult<{ items: T[] }>): T[] {
- return isFulfilled(result) ? result.value.items : [];
+function TopListSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+ );
}
const COMPACT_FORMATTER = new Intl.NumberFormat("tr-TR", {
@@ -448,6 +519,7 @@ function formatCompactNumber(value: number) {
function formatShortDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
- month: "short"
+ month: "short",
+ timeZone: "UTC"
}).format(new Date(value));
}
diff --git a/apps/web/src/features/requests/api.ts b/apps/web/src/features/requests/api.ts
new file mode 100644
index 0000000..12b094d
--- /dev/null
+++ b/apps/web/src/features/requests/api.ts
@@ -0,0 +1,56 @@
+import { apiClient, type ApiClient } from "@/lib/api-client";
+import type {
+ AddUserRequestNoteRequest,
+ CreateUserRequestRequest,
+ CreateUserRequestResponse,
+ UpdateUserRequestStatusRequest,
+ UserRequestDetailResponse,
+ UserRequestListParams,
+ UserRequestsResponse
+} from "@/types/api";
+
+export function createUserRequest(
+ payload: CreateUserRequestRequest,
+ client: ApiClient = apiClient
+) {
+ return client.post("/requests", payload);
+}
+
+export function listUserRequests(
+ params: UserRequestListParams = {},
+ client: ApiClient = apiClient
+) {
+ return client.get("/admin/requests", params);
+}
+
+export function getUserRequest(requestID: string, client: ApiClient = apiClient) {
+ return client.get(`/admin/requests/${encodeURIComponent(requestID)}`);
+}
+
+export function updateUserRequestStatus(
+ requestID: string,
+ payload: UpdateUserRequestStatusRequest,
+ client: ApiClient = apiClient
+) {
+ return client.post(
+ `/admin/requests/${encodeURIComponent(requestID)}/status`,
+ payload
+ );
+}
+
+export function addUserRequestNote(
+ requestID: string,
+ payload: AddUserRequestNoteRequest,
+ client: ApiClient = apiClient
+) {
+ return client.post(
+ `/admin/requests/${encodeURIComponent(requestID)}/notes`,
+ payload
+ );
+}
+
+export function archiveUserRequest(requestID: string, client: ApiClient = apiClient) {
+ return client.post(
+ `/admin/requests/${encodeURIComponent(requestID)}/archive`
+ );
+}
diff --git a/apps/web/src/features/requests/request-admin.test.tsx b/apps/web/src/features/requests/request-admin.test.tsx
new file mode 100644
index 0000000..c8996d1
--- /dev/null
+++ b/apps/web/src/features/requests/request-admin.test.tsx
@@ -0,0 +1,189 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RequestAdmin } from "@/features/requests/request-admin";
+import type { UserRequest, UserRequestDetailResponse, UserRequestEvent } from "@/types/api";
+
+const apiMocks = vi.hoisted(() => ({
+ addUserRequestNote: vi.fn(),
+ archiveUserRequest: vi.fn(),
+ getUserRequest: vi.fn(),
+ listUserRequests: vi.fn(),
+ updateUserRequestStatus: vi.fn()
+}));
+
+vi.mock("@/features/requests/api", () => ({
+ addUserRequestNote: apiMocks.addUserRequestNote,
+ archiveUserRequest: apiMocks.archiveUserRequest,
+ getUserRequest: apiMocks.getUserRequest,
+ listUserRequests: apiMocks.listUserRequests,
+ updateUserRequestStatus: apiMocks.updateUserRequestStatus
+}));
+
+describe("RequestAdmin", () => {
+ beforeEach(() => {
+ apiMocks.addUserRequestNote.mockReset();
+ apiMocks.archiveUserRequest.mockReset();
+ apiMocks.getUserRequest.mockReset();
+ apiMocks.listUserRequests.mockReset();
+ apiMocks.updateUserRequestStatus.mockReset();
+
+ apiMocks.listUserRequests.mockResolvedValue({ items: [requestFixture()], count: 1 });
+ apiMocks.getUserRequest.mockResolvedValue(detailFixture());
+ apiMocks.updateUserRequestStatus.mockResolvedValue(
+ detailFixture({
+ request: { ...requestFixture(), current_status: "approved" },
+ events: [eventFixture("evt_status", "status_changed", "approved")]
+ })
+ );
+ apiMocks.addUserRequestNote.mockResolvedValue(
+ detailFixture({
+ events: [eventFixture("evt_note", "note_added", "", "Kontrol edildi.")]
+ })
+ );
+ apiMocks.archiveUserRequest.mockResolvedValue(
+ detailFixture({
+ request: { ...requestFixture(), is_archived: true },
+ events: [eventFixture("evt_archive", "archived")]
+ })
+ );
+ });
+
+ it("loads active requests by default", async () => {
+ render( );
+
+ await waitFor(() => expect(apiMocks.listUserRequests).toHaveBeenCalledTimes(1));
+ expect(apiMocks.listUserRequests).toHaveBeenCalledWith({
+ archived: false,
+ limit: 50,
+ q: undefined,
+ start: undefined,
+ end: undefined,
+ status: undefined,
+ type: undefined
+ });
+ expect(await screen.findAllByText("Kanal takip edilsin")).not.toHaveLength(0);
+ expect(screen.getByText("#samplechannel · mod@example.com")).toBeInTheDocument();
+ });
+
+ it("applies request filters", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => expect(apiMocks.listUserRequests).toHaveBeenCalledTimes(1));
+
+ await user.selectOptions(screen.getByLabelText("Tip"), "feedback");
+ await user.selectOptions(screen.getByLabelText("Durum"), "reviewing");
+ await user.selectOptions(screen.getByLabelText("Arşiv"), "all");
+ await user.type(screen.getByLabelText("Arama"), "export");
+ await user.click(screen.getByRole("button", { name: "Filtrele" }));
+
+ await waitFor(() => expect(apiMocks.listUserRequests).toHaveBeenCalledTimes(2));
+ expect(apiMocks.listUserRequests).toHaveBeenLastCalledWith({
+ archived: undefined,
+ limit: 50,
+ q: "export",
+ start: undefined,
+ end: undefined,
+ status: "reviewing",
+ type: "feedback"
+ });
+ });
+
+ it("loads request detail and updates status", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await clickRequestRow(user);
+
+ await waitFor(() => expect(apiMocks.getUserRequest).toHaveBeenCalledWith("req_1"));
+ expect(await screen.findByText("Bu kanal eklenebilir mi?")).toBeInTheDocument();
+
+ await user.selectOptions(screen.getByDisplayValue("Yeni"), "approved");
+ await user.click(screen.getByRole("button", { name: "Kaydet" }));
+
+ await waitFor(() =>
+ expect(apiMocks.updateUserRequestStatus).toHaveBeenCalledWith("req_1", {
+ status: "approved"
+ })
+ );
+ expect(await screen.findByText("Durum: Onaylandı")).toBeInTheDocument();
+ });
+
+ it("adds a note and archives the selected request", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await clickRequestRow(user);
+ await screen.findByText("Bu kanal eklenebilir mi?");
+
+ await user.type(screen.getByPlaceholderText("İnceleme notu ekle"), "Kontrol edildi.");
+ await user.click(screen.getByRole("button", { name: "Not ekle" }));
+
+ await waitFor(() =>
+ expect(apiMocks.addUserRequestNote).toHaveBeenCalledWith("req_1", {
+ note: "Kontrol edildi."
+ })
+ );
+ expect(await screen.findByText("Not eklendi")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Arşivle" }));
+
+ await waitFor(() => expect(apiMocks.archiveUserRequest).toHaveBeenCalledWith("req_1"));
+ expect(await screen.findAllByText("Arşiv")).not.toHaveLength(0);
+ });
+});
+
+function requestFixture(overrides: Partial = {}): UserRequest {
+ return {
+ request_id: "req_1",
+ type: "channel_request",
+ title: "Kanal takip edilsin",
+ message: "Bu kanal eklenebilir mi?",
+ channel_slug: "samplechannel",
+ channel_display_name: "samplechannel",
+ contact: "mod@example.com",
+ current_status: "new",
+ is_archived: false,
+ created_at: "2026-06-14T09:00:00Z",
+ latest_event_at: "2026-06-14T09:00:00Z",
+ ...overrides
+ };
+}
+
+function eventFixture(
+ eventID: string,
+ eventType: UserRequestEvent["event_type"],
+ status: UserRequestEvent["status"] = "",
+ note = ""
+): UserRequestEvent {
+ return {
+ event_id: eventID,
+ request_id: "req_1",
+ event_type: eventType,
+ status,
+ note,
+ admin_id: 1,
+ created_at: "2026-06-14T09:05:00Z"
+ };
+}
+
+function detailFixture(
+ overrides: Partial = {}
+): UserRequestDetailResponse {
+ return {
+ request: requestFixture(),
+ events: [],
+ ...overrides
+ };
+}
+
+async function clickRequestRow(user: ReturnType) {
+ const titles = await screen.findAllByText("Kanal takip edilsin");
+ const rowButton = titles[0].closest("button");
+ if (!rowButton) {
+ throw new Error("request row button not found");
+ }
+ await user.click(rowButton);
+}
diff --git a/apps/web/src/features/requests/request-admin.tsx b/apps/web/src/features/requests/request-admin.tsx
new file mode 100644
index 0000000..625e58a
--- /dev/null
+++ b/apps/web/src/features/requests/request-admin.tsx
@@ -0,0 +1,771 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Archive,
+ CheckCircle2,
+ Clock3,
+ FileText,
+ Loader2,
+ MessageSquarePlus,
+ RefreshCcw,
+ Search,
+ Send
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog";
+import {
+ addUserRequestNote,
+ archiveUserRequest,
+ getUserRequest,
+ listUserRequests,
+ updateUserRequestStatus
+} from "@/features/requests/api";
+import { cn } from "@/lib/utils";
+import type {
+ UserRequest,
+ UserRequestDetailResponse,
+ UserRequestEvent,
+ UserRequestListParams,
+ UserRequestStatus,
+ UserRequestType
+} from "@/types/api";
+
+type FilterState = {
+ type: "" | UserRequestType;
+ status: "" | UserRequestStatus;
+ archived: "false" | "true" | "all";
+ q: string;
+ start: string;
+ end: string;
+};
+
+const DEFAULT_FILTERS: FilterState = {
+ type: "",
+ status: "",
+ archived: "false",
+ q: "",
+ start: "",
+ end: ""
+};
+
+const TYPE_OPTIONS: Array<{ value: "" | UserRequestType; label: string }> = [
+ { value: "", label: "Tüm tipler" },
+ { value: "channel_request", label: "Kanal Talebi" },
+ { value: "feedback", label: "Geri Bildirim" }
+];
+
+const STATUS_OPTIONS: Array<{ value: "" | UserRequestStatus; label: string }> = [
+ { value: "", label: "Tüm durumlar" },
+ { value: "new", label: "Yeni" },
+ { value: "reviewing", label: "İncelemede" },
+ { value: "approved", label: "Onaylandı" },
+ { value: "rejected", label: "Reddedildi" },
+ { value: "done", label: "Tamamlandı" },
+ { value: "duplicate", label: "Tekrar" }
+];
+
+const ARCHIVE_OPTIONS: Array<{ value: FilterState["archived"]; label: string }> = [
+ { value: "false", label: "Aktif" },
+ { value: "true", label: "Arşiv" },
+ { value: "all", label: "Tümü" }
+];
+
+export function RequestAdmin() {
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+ const [appliedFilters, setAppliedFilters] = useState(DEFAULT_FILTERS);
+ const [requests, setRequests] = useState([]);
+ const [detail, setDetail] = useState(null);
+ const [selectedRequestID, setSelectedRequestID] = useState(null);
+ const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
+ const [note, setNote] = useState("");
+ const [nextStatus, setNextStatus] = useState("reviewing");
+ const [error, setError] = useState(null);
+ const [detailError, setDetailError] = useState(null);
+ const [isLoadingList, setIsLoadingList] = useState(true);
+ const [isLoadingDetail, setIsLoadingDetail] = useState(false);
+ const [isSavingStatus, setIsSavingStatus] = useState(false);
+ const [isAddingNote, setIsAddingNote] = useState(false);
+ const [isArchiving, setIsArchiving] = useState(false);
+
+ const queryParams = useMemo(() => buildListParams(appliedFilters), [appliedFilters]);
+
+ const loadRequests = useCallback(async () => {
+ setIsLoadingList(true);
+ setError(null);
+
+ try {
+ const response = await listUserRequests(queryParams);
+ setRequests(response.items);
+ } catch (caught) {
+ setError(resolveRequestAdminError(caught));
+ setRequests([]);
+ } finally {
+ setIsLoadingList(false);
+ }
+ }, [queryParams]);
+
+ useEffect(() => {
+ void loadRequests();
+ }, [loadRequests]);
+
+ async function selectRequest(requestID: string) {
+ setSelectedRequestID(requestID);
+ setIsDetailModalOpen(true);
+ setNote("");
+ setIsLoadingDetail(true);
+ setDetailError(null);
+
+ try {
+ const nextDetail = await getUserRequest(requestID);
+ applyDetail(nextDetail);
+ } catch (caught) {
+ setDetail(null);
+ setDetailError(resolveRequestAdminError(caught));
+ } finally {
+ setIsLoadingDetail(false);
+ }
+ }
+
+ function submitFilters(event: FormEvent) {
+ event.preventDefault();
+ setAppliedFilters(filters);
+ setDetail(null);
+ setSelectedRequestID(null);
+ setIsDetailModalOpen(false);
+ }
+
+ function resetFilters() {
+ setFilters(DEFAULT_FILTERS);
+ setAppliedFilters(DEFAULT_FILTERS);
+ setDetail(null);
+ setSelectedRequestID(null);
+ setIsDetailModalOpen(false);
+ }
+
+ async function submitStatus(event: FormEvent) {
+ event.preventDefault();
+ if (!detail) return;
+
+ setIsSavingStatus(true);
+ setDetailError(null);
+
+ try {
+ applyDetail(
+ await updateUserRequestStatus(detail.request.request_id, {
+ status: nextStatus
+ })
+ );
+ } catch (caught) {
+ setDetailError(resolveRequestAdminError(caught));
+ } finally {
+ setIsSavingStatus(false);
+ }
+ }
+
+ async function submitNote(event: FormEvent) {
+ event.preventDefault();
+ if (!detail || !note.trim()) return;
+
+ setIsAddingNote(true);
+ setDetailError(null);
+
+ try {
+ applyDetail(await addUserRequestNote(detail.request.request_id, { note: note.trim() }));
+ setNote("");
+ } catch (caught) {
+ setDetailError(resolveRequestAdminError(caught));
+ } finally {
+ setIsAddingNote(false);
+ }
+ }
+
+ async function submitArchive() {
+ if (!detail) return;
+
+ setIsArchiving(true);
+ setDetailError(null);
+
+ try {
+ const nextDetail = await archiveUserRequest(detail.request.request_id);
+ applyDetail(nextDetail);
+ await loadRequests();
+ } catch (caught) {
+ setDetailError(resolveRequestAdminError(caught));
+ } finally {
+ setIsArchiving(false);
+ }
+ }
+
+ function applyDetail(nextDetail: UserRequestDetailResponse) {
+ setDetail(nextDetail);
+ setSelectedRequestID(nextDetail.request.request_id);
+ setNextStatus(nextDetail.request.current_status);
+ setRequests((current) => mergeRequest(current, nextDetail.request));
+ }
+
+ return (
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
Talep Listesi
+
+ {isLoadingList ? "yükleniyor" : `${requests.length} kayıt`}
+
+
+
+
+
+
+ TİP
+
+
+ TALEP
+
+
+ DURUM
+
+
+ TARİH
+
+
+
+ {isLoadingList ? (
+
+ Talepler yükleniyor...
+
+ ) : requests.length ? (
+ requests.map((request) => (
+ void selectRequest(request.request_id)}
+ request={request}
+ />
+ ))
+ ) : (
+
+ Bu filtrelerle talep bulunamadı.
+
+ )}
+
+
+ void submitArchive()}
+ onNoteChange={setNote}
+ onOpenChange={setIsDetailModalOpen}
+ onStatusChange={setNextStatus}
+ onSubmitNote={(event) => void submitNote(event)}
+ onSubmitStatus={(event) => void submitStatus(event)}
+ />
+
+ );
+}
+
+function RequestRow({
+ isSelected,
+ onSelect,
+ request
+}: {
+ isSelected: boolean;
+ onSelect: () => void;
+ request: UserRequest;
+}) {
+ return (
+
+
+
+
+
+
+
{request.title}
+
+ {request.channel_slug ? `#${request.channel_slug} · ` : ""}
+ {request.contact ? request.contact : request.message}
+
+
+
+
+
+
+ {formatDate(request.created_at)}
+
+
+
+
+
+
+
+
+
+
{request.title}
+
+ {request.channel_slug ? `#${request.channel_slug} · ` : ""}
+ {formatDate(request.created_at)}
+
+
+
+
+ );
+}
+
+function RequestDetailModal({
+ detail,
+ detailError,
+ isAddingNote,
+ isArchiving,
+ isLoadingDetail,
+ isOpen,
+ isSavingStatus,
+ nextStatus,
+ note,
+ onArchive,
+ onNoteChange,
+ onOpenChange,
+ onStatusChange,
+ onSubmitNote,
+ onSubmitStatus
+}: {
+ detail: UserRequestDetailResponse | null;
+ detailError: string | null;
+ isAddingNote: boolean;
+ isArchiving: boolean;
+ isLoadingDetail: boolean;
+ isOpen: boolean;
+ isSavingStatus: boolean;
+ nextStatus: UserRequestStatus;
+ note: string;
+ onArchive: () => void;
+ onNoteChange: (value: string) => void;
+ onOpenChange: (open: boolean) => void;
+ onStatusChange: (value: UserRequestStatus) => void;
+ onSubmitNote: (event: FormEvent) => void;
+ onSubmitStatus: (event: FormEvent) => void;
+}) {
+ return (
+
+
+ onOpenChange(false)} />
+
+
Talep Detayı
+
+ {detail ? detail.request.request_id : "detay yükleniyor"}
+
+
+
+ {isLoadingDetail ? (
+
+ Detay yükleniyor...
+
+ ) : detail ? (
+
+ {detailError ? (
+
+ {detailError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {detail.request.title}
+
+
+ {detail.request.message}
+
+
+
+ {detail.request.channel_slug ? (
+ Kanal: #{detail.request.channel_slug}
+ ) : null}
+ {detail.request.contact ? İletişim: {detail.request.contact} : null}
+ Oluşturulma: {formatDate(detail.request.created_at)}
+ Son hareket: {formatDate(detail.request.latest_event_at)}
+
+
+
+
+
+
+
+
+
+
+ {isArchiving ? (
+
+ ) : (
+
+ )}
+ Arşivle
+
+
+ ) : (
+
+
+
+ {detailError ?? "Detay görüntülemek için listeden bir talep seç."}
+
+
+ )}
+
+
+ );
+}
+
+function Timeline({ events }: { events: UserRequestEvent[] }) {
+ if (!events.length) {
+ return Henüz admin hareketi yok.
;
+ }
+
+ return (
+
+ {events.map((event) => (
+
+
+ {eventLabel(event)}
+ {formatDate(event.created_at)}
+
+ {event.note ? (
+
+ {event.note}
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+function FilterSelect({
+ label,
+ onChange,
+ options,
+ value
+}: {
+ label: string;
+ onChange: (value: string) => void;
+ options: Array<{ value: string; label: string }>;
+ value: string;
+}) {
+ const id = `request-filter-${label.toLowerCase()}`;
+ return (
+
+
+ {label}
+
+ onChange(event.target.value)}
+ value={value}
+ >
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
+function FilterInput({
+ icon,
+ label,
+ onChange,
+ placeholder,
+ type = "text",
+ value
+}: {
+ icon?: React.ReactNode;
+ label: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ type?: string;
+ value: string;
+}) {
+ const id = `request-filter-${label.toLowerCase()}`;
+ return (
+
+
+ {label}
+
+
+ {icon ? {icon} : null}
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ type={type}
+ value={value}
+ />
+
+
+ );
+}
+
+function TypeBadge({ type }: { type: UserRequestType }) {
+ return (
+
+ {typeLabel(type)}
+
+ );
+}
+
+function StatusBadge({ archived, status }: { archived: boolean; status: UserRequestStatus }) {
+ return (
+
+
+ {archived ? "Arşiv" : statusLabel(status)}
+
+ );
+}
+
+function buildListParams(filters: FilterState): UserRequestListParams {
+ return {
+ type: filters.type || undefined,
+ status: filters.status || undefined,
+ archived: filters.archived === "all" ? undefined : filters.archived === "true",
+ q: optionalValue(filters.q),
+ start: localDateTimeToISO(filters.start),
+ end: localDateTimeToISO(filters.end),
+ limit: 50
+ };
+}
+
+function localDateTimeToISO(value: string) {
+ if (!value) return undefined;
+ return new Date(value).toISOString();
+}
+
+function optionalValue(value: string) {
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function mergeRequest(current: UserRequest[], request: UserRequest) {
+ const next = current.map((item) => (item.request_id === request.request_id ? request : item));
+ return next.some((item) => item.request_id === request.request_id) ? next : [request, ...next];
+}
+
+function typeLabel(type: UserRequestType) {
+ return type === "channel_request" ? "Kanal" : "Feedback";
+}
+
+function statusLabel(status: UserRequestStatus) {
+ const match = STATUS_OPTIONS.find((option) => option.value === status);
+ return match?.label ?? status;
+}
+
+function eventLabel(event: UserRequestEvent) {
+ if (event.event_type === "status_changed") {
+ return `Durum: ${event.status ? statusLabel(event.status) : "-"}`;
+ }
+ if (event.event_type === "note_added") {
+ return "Not eklendi";
+ }
+ if (event.event_type === "archived") {
+ return "Arşivlendi";
+ }
+ return event.event_type;
+}
+
+function formatDate(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));
+}
+
+function resolveRequestAdminError(error: unknown) {
+ if (error instanceof Error) return error.message;
+ return "Talep işlemi tamamlanamadı.";
+}
diff --git a/apps/web/src/features/requests/request-page.test.tsx b/apps/web/src/features/requests/request-page.test.tsx
new file mode 100644
index 0000000..6925d96
--- /dev/null
+++ b/apps/web/src/features/requests/request-page.test.tsx
@@ -0,0 +1,111 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RequestPage } from "@/features/requests/request-page";
+
+const apiMocks = vi.hoisted(() => ({
+ createUserRequest: vi.fn()
+}));
+
+vi.mock("next/image", () => ({
+ default: ({ alt }: { alt: string }) =>
+}));
+
+vi.mock("@/features/requests/api", () => ({
+ createUserRequest: apiMocks.createUserRequest
+}));
+
+describe("RequestPage", () => {
+ beforeEach(() => {
+ apiMocks.createUserRequest.mockReset();
+ apiMocks.createUserRequest.mockResolvedValue({ request_id: "req_123" });
+ });
+
+ it("renders the public request page with header request navigation", () => {
+ render( );
+
+ expect(screen.getByRole("heading", { name: "Talep" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Talep" })).toHaveAttribute("href", "/request");
+ expect(screen.getByLabelText("Kanal adı")).toBeInTheDocument();
+ expect(screen.getByLabelText("Başlık")).toBeInTheDocument();
+ expect(screen.getByLabelText("Mesaj")).toBeInTheDocument();
+ });
+
+ it("submits a channel request with the normalized payload keys expected by the API", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Kanal adı"), { target: { value: "kick.com/NuriBen" } });
+ fireEvent.change(screen.getByLabelText("Başlık"), {
+ target: { value: "Kanal takip edilsin" }
+ });
+ fireEvent.change(screen.getByLabelText("Mesaj"), {
+ target: { value: "Bu kanal listede olursa iyi olur." }
+ });
+ fireEvent.change(screen.getByLabelText(/İletişim/), {
+ target: { value: "mod@example.com" }
+ });
+ await user.click(screen.getByRole("button", { name: "Gönder" }));
+
+ await waitFor(() => expect(apiMocks.createUserRequest).toHaveBeenCalledTimes(1));
+ expect(apiMocks.createUserRequest).toHaveBeenCalledWith({
+ type: "channel_request",
+ title: "Kanal takip edilsin",
+ message: "Bu kanal listede olursa iyi olur.",
+ channel_slug: "kick.com/NuriBen",
+ channel_display_name: "kick.com/NuriBen",
+ contact: "mod@example.com",
+ website: ""
+ });
+ expect(await screen.findByText("Talebin alındı.")).toBeInTheDocument();
+ expect(screen.getByText("ID req_123")).toBeInTheDocument();
+ });
+
+ it("switches to feedback mode and omits channel fields from the request payload", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: /Geri Bildirim/ }));
+
+ expect(screen.queryByLabelText("Kanal adı")).not.toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText("Başlık"), {
+ target: { value: "Yeni filtre önerisi" }
+ });
+ fireEvent.change(screen.getByLabelText("Mesaj"), {
+ target: { value: "Arama ekranına yeni bir filtre eklenebilir." }
+ });
+ await user.click(screen.getByRole("button", { name: "Gönder" }));
+
+ await waitFor(() => expect(apiMocks.createUserRequest).toHaveBeenCalledTimes(1));
+ expect(apiMocks.createUserRequest).toHaveBeenCalledWith({
+ type: "feedback",
+ title: "Yeni filtre önerisi",
+ message: "Arama ekranına yeni bir filtre eklenebilir.",
+ channel_slug: undefined,
+ channel_display_name: undefined,
+ contact: undefined,
+ website: ""
+ });
+ });
+
+ it("keeps submit disabled until required fields are ready", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const submit = screen.getByRole("button", { name: "Gönder" });
+ expect(submit).toBeDisabled();
+
+ await user.type(screen.getByLabelText("Kanal adı"), "levo");
+ await user.type(screen.getByLabelText("Başlık"), "OK");
+ await user.type(screen.getByLabelText("Mesaj"), "short enough");
+
+ expect(submit).toBeDisabled();
+
+ await user.clear(screen.getByLabelText("Başlık"));
+ await user.type(screen.getByLabelText("Başlık"), "Kanal ekleme isteği");
+
+ expect(submit).toBeEnabled();
+ });
+});
diff --git a/apps/web/src/features/requests/request-page.tsx b/apps/web/src/features/requests/request-page.tsx
new file mode 100644
index 0000000..5579f56
--- /dev/null
+++ b/apps/web/src/features/requests/request-page.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import { CheckCircle2, Hash, Mail, MessageSquareText, Send, Type } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { SiteHeader } from "@/components/site-header";
+import { Button } from "@/components/ui/button";
+import { createUserRequest } from "@/features/requests/api";
+import { ApiClientError } from "@/lib/api-client";
+import { cn } from "@/lib/utils";
+import type { UserRequestType } from "@/types/api";
+
+type FormState = {
+ type: UserRequestType;
+ channelSlug: string;
+ title: string;
+ message: string;
+ contact: string;
+ website: string;
+};
+
+const INITIAL_FORM: FormState = {
+ type: "channel_request",
+ channelSlug: "",
+ title: "",
+ message: "",
+ contact: "",
+ website: ""
+};
+
+const MODES: { value: UserRequestType; label: string; description: string }[] = [
+ {
+ value: "channel_request",
+ label: "Kanal Talebi",
+ description: "Takip edilmesini istediğin Kick kanalını gönder."
+ },
+ {
+ value: "feedback",
+ label: "Geri Bildirim",
+ description: "Uygulama fikri, hata bildirimi veya genel mesaj gönder."
+ }
+];
+
+export function RequestPage() {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
+ const [error, setError] = useState(null);
+ const [requestID, setRequestID] = useState(null);
+
+ const isChannelRequest = form.type === "channel_request";
+ const canSubmit = useMemo(() => {
+ const title = form.title.trim();
+ const message = form.message.trim();
+ const channelSlug = form.channelSlug.trim();
+ return (
+ status !== "submitting" &&
+ title.length >= 3 &&
+ message.length >= 5 &&
+ (!isChannelRequest || channelSlug.length >= 2)
+ );
+ }, [form.channelSlug, form.message, form.title, isChannelRequest, status]);
+
+ async function submitRequest() {
+ setStatus("submitting");
+ setError(null);
+ setRequestID(null);
+
+ try {
+ const response = await createUserRequest({
+ type: form.type,
+ title: form.title.trim(),
+ message: form.message.trim(),
+ channel_slug: isChannelRequest ? form.channelSlug.trim() : undefined,
+ channel_display_name: isChannelRequest ? form.channelSlug.trim() : undefined,
+ contact: optionalValue(form.contact),
+ website: form.website
+ });
+ setRequestID(response.request_id);
+ setStatus("success");
+ setForm({ ...INITIAL_FORM, type: form.type });
+ } catch (caught) {
+ setError(resolveRequestError(caught));
+ setStatus("error");
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ if (canSubmit) {
+ void submitRequest();
+ }
+ }}
+ >
+
+ Talep tipi
+ {MODES.map((mode) => (
+ {
+ setForm((current) => ({
+ ...current,
+ type: mode.value,
+ channelSlug: mode.value === "feedback" ? "" : current.channelSlug
+ }));
+ setStatus("idle");
+ setError(null);
+ setRequestID(null);
+ }}
+ type="button"
+ >
+
+
+ {mode.label}
+
+
+ {mode.description}
+
+
+ ))}
+
+
+
+ {isChannelRequest ? (
+ }>
+
+ setForm((current) => ({ ...current, channelSlug: event.target.value }))
+ }
+ placeholder="Kanal adı veya Kick kanal URL'i"
+ value={form.channelSlug}
+ />
+
+ ) : null}
+
+ }>
+
+ setForm((current) => ({ ...current, title: event.target.value }))
+ }
+ placeholder={
+ isChannelRequest ? "Kanal takip listesine eklensin" : "Kısa bir konu yaz"
+ }
+ value={form.title}
+ />
+
+
+ }
+ >
+
+ setForm((current) => ({ ...current, message: event.target.value }))
+ }
+ placeholder={
+ isChannelRequest
+ ? "Bu kanal neden takip edilmeli?"
+ : "Görüşünü, hata bildirimini veya istediğin geliştirmeyi yaz."
+ }
+ value={form.message}
+ />
+
+
+ }
+ >
+
+ setForm((current) => ({ ...current, contact: event.target.value }))
+ }
+ placeholder="E-posta veya Discord"
+ value={form.contact}
+ />
+
+
+
+ Website
+
+ setForm((current) => ({ ...current, website: event.target.value }))
+ }
+ tabIndex={-1}
+ value={form.website}
+ />
+
+
+
+ {status === "success" ? (
+
+
+
+
+ Talebin alındı.
+ {requestID ? (
+
+ ID {requestID}
+
+ ) : null}
+
+
+
+ ) : null}
+
+ {status === "error" && error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ Gönderimler spam koruması ile korunur.
+
+
+
+ {status === "submitting" ? "Gönderiliyor..." : "Gönder"}
+
+
+
+
+
+
+
+
Süreç
+
+ Talebin nasıl değerlendirilir?
+
+
+
+
+ Kanal talepleri uygunluk, tekrar durumu ve izlenebilirlik açısından kontrol
+ edilir.
+
+
+ Geri bildirimler hata düzeltmeleri, arama deneyimi ve yeni özellik planlaması için
+ dikkate alınır.
+
+
+ İletişim bilgisi bırakırsan yalnızca talebin hakkında ek bilgi gerektiğinde geri
+ dönüş yapılır.
+
+
Gönderim yapmak, talebin kesin olarak kabul edildiği anlamına gelmez.
+
+
+
+
+
+
+ );
+}
+
+function Field({
+ children,
+ hint,
+ htmlFor,
+ icon,
+ label
+}: {
+ children: React.ReactNode;
+ hint?: string;
+ htmlFor: string;
+ icon: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+
+
+ {icon}
+ {label}
+
+ {hint ? {hint} : null}
+
+ {children}
+
+ );
+}
+
+const inputClassName =
+ "w-full rounded-md border border-border bg-elevated px-3 py-2.5 text-[13px] text-foreground outline-none transition-colors placeholder:text-faint focus:border-border-strong";
+
+function optionalValue(value: string) {
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function resolveRequestError(error: unknown) {
+ if (error instanceof ApiClientError) {
+ if (error.status === 429) {
+ return "Çok fazla talep gönderdin. Kısa süre sonra tekrar dene.";
+ }
+ if (error.status === 400) {
+ return "Form alanlarını kontrol edip tekrar gönder.";
+ }
+ }
+
+ return "Talep gönderilemedi. Biraz sonra tekrar dene.";
+}
diff --git a/apps/web/src/features/search/message-content.tsx b/apps/web/src/features/search/message-content.tsx
index 61a9164..e344fbc 100644
--- a/apps/web/src/features/search/message-content.tsx
+++ b/apps/web/src/features/search/message-content.tsx
@@ -5,6 +5,7 @@
import { useMemo, useState } from "react";
import type { MessageEmote } from "@/types/api";
+import { cn } from "@/lib/utils";
type TextPart = {
type: "text";
@@ -35,24 +36,39 @@ export type MessageTextSegment =
type MessageContentProps = {
content: string;
emotes: MessageEmote[];
+ className?: string;
+ emoteClassName?: string;
highlight?: string;
+ textPartClassName?: string;
};
const emoteTokenPattern = /\[emote:(\d+):([^\]]+)\]/g;
const urlPattern = /https?:\/\/[^\s<>"']+/gi;
const trailingUrlPunctuationPattern = /[),.!?;:]+$/;
-export function MessageContent({ content, emotes, highlight = "" }: MessageContentProps) {
+export function MessageContent({
+ className,
+ content,
+ emoteClassName,
+ emotes,
+ highlight = "",
+ textPartClassName
+}: MessageContentProps) {
const [failedTokens, setFailedTokens] = useState>(() => new Set());
const parts = useMemo(() => splitMessageContent(content, emotes), [content, emotes]);
const highlightText = highlight.trim();
return (
-
+
{parts.map((part, index) => {
if (part.type === "text") {
return (
-
+
{renderTextSegments(part.value, highlightText)}
);
@@ -72,7 +88,10 @@ export function MessageContent({ content, emotes, highlight = "" }: MessageConte
return (
{
expect(screen.getByText("hello")).toBeInTheDocument();
});
+ it("renders emotes inside reply context", () => {
+ renderMessageList([
+ {
+ ...messageFixture(),
+ message_type: "reply",
+ reply_metadata: {
+ original_sender: {
+ id: 97891494,
+ username: "Cansu98xx",
+ slug: "cansu_98xx"
+ },
+ original_message: {
+ id: "1be196b8-55c7-4980-8022-a1112723acea",
+ content: "cevap [emote:37226:KEKW]"
+ }
+ }
+ }
+ ]);
+
+ expect(screen.getByRole("img", { name: "KEKW" })).toHaveAttribute(
+ "src",
+ "https://files.kick.com/emotes/37226/fullsize"
+ );
+ });
+
it("links sender name to the public user profile", () => {
renderMessageList([messageFixture()]);
diff --git a/apps/web/src/features/search/message-list.tsx b/apps/web/src/features/search/message-list.tsx
index 2dffebd..0e5e2aa 100644
--- a/apps/web/src/features/search/message-list.tsx
+++ b/apps/web/src/features/search/message-list.tsx
@@ -159,7 +159,13 @@ function MessageRow({ message, highlightQuery }: { message: Message; highlightQu
@{replyContext.senderUsername}:
)}
- {replyContext.content}
+
) : null}
{
);
});
+ it("keeps the header and breadcrumb available while loading, then replaces the skeleton", async () => {
+ let resolveProfile!: (profile: UserProfile) => void;
+ profileMocks.getUserProfile.mockReturnValue(
+ new Promise((resolve) => {
+ resolveProfile = resolve;
+ })
+ );
+
+ render( );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Kullanıcı profili yükleniyor...");
+ expect(screen.getByRole("region", { name: "Kullanıcı profili" })).toHaveAttribute(
+ "aria-busy",
+ "true"
+ );
+ expect(screen.getByRole("banner")).toBeInTheDocument();
+ expect(
+ within(screen.getByRole("navigation", { name: "Breadcrumb" })).getByText("yavuz")
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: /mesajlarda ara/i })).not.toBeInTheDocument();
+
+ await act(async () => resolveProfile(profileFixture()));
+
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Kullanıcı profili" })).not.toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Yavuz" })).toBeInTheDocument();
+ expect(profileMocks.getUserProfile).toHaveBeenCalledExactlyOnceWith("yavuz");
+ });
+
+ it("replaces loading with the existing error and search action", async () => {
+ profileMocks.getUserProfile.mockRejectedValue(new ApiClientError(500, { detail: "failed" }));
+
+ render( );
+
+ expect(await screen.findByText("Kullanıcı profili şu anda alınamadı.")).toBeInTheDocument();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Kullanıcı profili" })).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /search'te ara/i })).toHaveAttribute(
+ "href",
+ "/search?sender=yavuz"
+ );
+ });
+
it("renders not-found state for unknown senders", async () => {
profileMocks.getUserProfile.mockRejectedValue(new ApiClientError(404, { detail: "missing" }));
render( );
expect(await screen.findByText("Kullanıcı 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/user-profile/user-profile-page.tsx b/apps/web/src/features/user-profile/user-profile-page.tsx
index 71a5cc3..0d5aab4 100644
--- a/apps/web/src/features/user-profile/user-profile-page.tsx
+++ b/apps/web/src/features/user-profile/user-profile-page.tsx
@@ -7,6 +7,7 @@ import Link from "next/link";
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 { getUserProfile } from "@/features/user-profile/api";
@@ -68,7 +69,7 @@ export function UserProfilePage({ slug }: { slug: string }) {
- {status === "loading" ?
: null}
+ {status === "loading" ?
: null}
{status === "not-found" ? (
{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 (
({
- getTopSenders: vi.fn()
+const directoryMocks = vi.hoisted(() => ({
+ getDirectoryUsers: vi.fn(),
+ getDirectoryChannels: vi.fn()
}));
-
+const analyticsMocks = vi.hoisted(() => ({ getTopSenders: vi.fn(), getTopChannels: 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("UsersIndexPage", () => {
beforeEach(() => {
- analyticsMocks.getTopSenders.mockReset();
- analyticsMocks.getTopSenders.mockResolvedValue(usersFixture());
- });
-
- it("renders empty idle prompt on initial load without calling the API", () => {
- render(
);
-
- expect(screen.getByText("Kullanıcı bulmak için arama yapın")).toBeInTheDocument();
- expect(analyticsMocks.getTopSenders).not.toHaveBeenCalled();
- });
-
- it("renders search input and submit button", () => {
- render(
);
-
- expect(screen.getByRole("searchbox", { name: /kullanıcı 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: /kullanıcı ara/i }), "alpha");
-
- expect(analyticsMocks.getTopSenders).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: /kullanıcı ara/i }), "a");
- expect(submit).toBeDisabled();
-
- await user.type(screen.getByRole("searchbox", { name: /kullanıcı ara/i }), "l");
- expect(submit).not.toBeDisabled();
- });
-
- it("shows user results after clicking Ara button", async () => {
- const user = userEvent.setup();
- render(
);
-
- const input = screen.getByRole("searchbox", { name: /kullanıcı ara/i });
- await user.type(input, "alpha");
- await user.click(screen.getByRole("button", { name: /^ara$/i }));
-
- await waitFor(() =>
- expect(analyticsMocks.getTopSenders).toHaveBeenCalledWith(
- expect.objectContaining({ q: "alpha", limit: 20 })
- )
- );
-
- expect(await screen.findByText("@alpha")).toBeInTheDocument();
- expect(screen.getByText("@beta")).toBeInTheDocument();
- });
-
- it("triggers search on Enter key in the input", async () => {
- const user = userEvent.setup();
- render(
);
-
- const input = screen.getByRole("searchbox", { name: /kullanıcı ara/i });
- await user.type(input, "alpha{Enter}");
-
- await waitFor(() =>
- expect(analyticsMocks.getTopSenders).toHaveBeenCalledWith(
- expect.objectContaining({ q: "alpha", limit: 20 })
- )
- );
- });
-
- it("shows empty state when API returns no results", async () => {
- analyticsMocks.getTopSenders.mockResolvedValue({ items: [] });
-
- const user = userEvent.setup();
- render(
);
-
- await user.type(screen.getByRole("searchbox", { name: /kullanıcı ara/i }), "xyz");
- await user.click(screen.getByRole("button", { name: /^ara$/i }));
-
- await waitFor(() =>
- expect(screen.getByText(/"xyz" için kullanıcı bulunamadı\./)).toBeInTheDocument()
- );
- });
-
- it("shows error state when API fails", async () => {
- analyticsMocks.getTopSenders.mockRejectedValue(new Error("network error"));
-
- const user = userEvent.setup();
- render(
);
-
- await user.type(screen.getByRole("searchbox", { name: /kullanıcı 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 user row links to the user profile page with _ to - slug conversion", async () => {
- analyticsMocks.getTopSenders.mockResolvedValue({
+ vi.clearAllMocks();
+ directoryMocks.getDirectoryUsers.mockResolvedValue({
items: [
{
- sender_id: 99,
- kick_user_id: 99,
- username: "example_user",
+ id: 1,
+ name: "example_user",
slug: "example_user",
- profile_image_url: null,
- message_count: 5,
- first_message_at: "2026-05-01T10:00:00Z",
- latest_message_at: "2026-05-14T09:30:00Z"
+ profile_image_url: "https://example.com/avatar.png"
}
- ]
+ ],
+ next_cursor: null
});
+ });
+ it("searches the identity directory and preserves avatars and canonical profile links without analytics", async () => {
const user = userEvent.setup();
render(
);
-
- await user.type(screen.getByRole("searchbox", { name: /kullanıcı ara/i }), "example");
- await user.click(screen.getByRole("button", { name: /^ara$/i }));
-
- await waitFor(() => expect(screen.getByText("@example_user")).toBeInTheDocument());
-
- // slug converts _ to - per Kick profile URL convention
- const links = screen.getAllByRole("link");
- const profileLink = links.find((l) => l.getAttribute("href") === "/users/example-user");
- expect(profileLink).toBeDefined();
+ await user.type(screen.getByRole("searchbox", { name: "Kullanıcı ara" }), " example {Enter}");
+ expect(await screen.findByRole("link", { name: /@example_user/ })).toHaveAttribute(
+ "href",
+ "/users/example-user"
+ );
+ 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.getDirectoryUsers).toHaveBeenCalledWith(
+ { prefix: "example", limit: 50 },
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ );
+ expect(directoryMocks.getDirectoryChannels).not.toHaveBeenCalled();
+ expect(analyticsMocks.getTopSenders).not.toHaveBeenCalled();
+ expect(screen.queryByText(/mesaj|son aktivite/i)).not.toBeInTheDocument();
});
- it("user rows with no slug render without a user profile link", async () => {
- analyticsMocks.getTopSenders.mockResolvedValue({
- items: [
- {
- sender_id: 100,
- kick_user_id: 100,
- username: "nolink",
- slug: "",
- profile_image_url: null,
- message_count: 1,
- first_message_at: "2026-05-01T10:00:00Z",
- latest_message_at: "2026-05-14T09:30:00Z"
- }
- ]
+ it("preserves the fallback row when a stored identity has no link", async () => {
+ directoryMocks.getDirectoryUsers.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: /kullanıcı ara/i }), "nolink");
- await user.click(screen.getByRole("button", { name: /^ara$/i }));
-
- await waitFor(() => expect(screen.getByText("@nolink")).toBeInTheDocument());
- const links = screen.getAllByRole("link");
- const userProfileLink = links.find((l) => l.getAttribute("href")?.startsWith("/users/"));
- expect(userProfileLink).toBeUndefined();
+ await user.type(screen.getByRole("searchbox"), "nolink{Enter}");
+ expect(await screen.findByText("@nolink")).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: /nolink/ })).not.toBeInTheDocument();
});
});
-
-function usersFixture(): TopSendersResponse {
- return {
- items: [
- {
- sender_id: 1,
- kick_user_id: 10,
- username: "alpha",
- slug: "alpha",
- profile_image_url: null,
- message_count: 1200,
- first_message_at: "2026-05-01T10:00:00Z",
- latest_message_at: "2026-05-14T09:30:00Z"
- },
- {
- sender_id: 2,
- kick_user_id: 20,
- username: "beta",
- slug: "beta",
- profile_image_url: null,
- message_count: 600,
- first_message_at: "2026-05-02T10:00:00Z",
- latest_message_at: "2026-05-13T08:00:00Z"
- }
- ]
- };
-}
diff --git a/apps/web/src/features/user-profile/users-index-page.tsx b/apps/web/src/features/user-profile/users-index-page.tsx
index b9be2d5..bc9c377 100644
--- a/apps/web/src/features/user-profile/users-index-page.tsx
+++ b/apps/web/src/features/user-profile/users-index-page.tsx
@@ -1,219 +1,7 @@
"use client";
-/* eslint-disable @next/next/no-img-element */
-
-import { Search, User } from "lucide-react";
-import Link from "next/link";
-import { useCallback, useState } from "react";
-
-import { SiteHeader } from "@/components/site-header";
-import { getTopSenders } from "@/features/analytics/api";
-import { buildUserProfileHref } from "@/lib/kick-profile-slugs";
-import type { TopSenderAnalytics } 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 UsersIndexPage() {
- 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 getTopSenders({ 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 */}
-
-
- Users
-
-
- Mesaj sayısına göre sıralı
-
-
-
- {/* Search form */}
-
-
-
- setQuery(e.target.value)}
- placeholder="Kullanıcı adı veya slug ile ara…"
- spellCheck={false}
- type="search"
- value={query}
- />
-
-
- {state === "loading" ? "Aranıyor…" : "Ara"}
-
-
-
- {/* Content area */}
- {state === "idle" ?
: null}
- {state === "loading" ?
: null}
- {state === "error" ?
: null}
- {state === "empty" ?
: null}
- {state === "ready" ?
: null}
-
-
- );
-}
-
-function IdlePrompt() {
- return (
-
-
-
-
-
Kullanıcı bulmak için arama yapın
-
- Kullanıcı 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 kullanıcı bulunamadı.
-
- );
-}
-
-function UserList({ users }: { users: TopSenderAnalytics[] }) {
- return (
-
-
- {users.map((user) => (
-
- ))}
-
-
- );
-}
-
-function UserRow({ user }: { user: TopSenderAnalytics }) {
- const [imgFailed, setImgFailed] = useState(false);
- const href = buildUserProfileHref(user.slug);
- const initial = user.username.charAt(0).toUpperCase() || "U";
-
- const inner = (
- <>
- {/* Avatar */}
- {user.profile_image_url && !imgFailed ? (
- setImgFailed(true)}
- src={user.profile_image_url}
- width={36}
- />
- ) : (
-
- {initial}
-
- )}
-
- {/* Username & slug */}
-
-
@{user.username}
- {user.slug !== user.username ? (
-
{user.slug}
- ) : null}
-
-
- {/* Message count */}
-
-
- {formatCompactNumber(user.message_count)}
-
-
- mesaj
-
-
- >
- );
-
- if (href) {
- return (
-
- {inner}
-
- );
- }
-
- return {inner}
;
-}
-
-const COMPACT_FORMATTER = new Intl.NumberFormat("tr-TR", {
- notation: "compact",
- maximumFractionDigits: 1
-});
-
-function formatCompactNumber(value: number) {
- return COMPACT_FORMATTER.format(value);
+ return ;
}
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index 5a7bc3d..2aa1770 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -31,6 +31,83 @@ export type CreateAdminUserRequest = {
password: string;
};
+export type UserRequestType = "channel_request" | "feedback";
+
+export type UserRequestStatus =
+ | "new"
+ | "reviewing"
+ | "approved"
+ | "rejected"
+ | "done"
+ | "duplicate";
+
+export type CreateUserRequestRequest = {
+ type: UserRequestType;
+ title: string;
+ message: string;
+ channel_slug?: string;
+ channel_display_name?: string;
+ contact?: string;
+ website?: string;
+};
+
+export type CreateUserRequestResponse = {
+ request_id: string;
+};
+
+export type UserRequest = {
+ request_id: string;
+ type: UserRequestType;
+ title: string;
+ message: string;
+ channel_slug: string | null;
+ channel_display_name: string | null;
+ contact: string | null;
+ current_status: UserRequestStatus;
+ is_archived: boolean;
+ created_at: string;
+ latest_event_at: string;
+};
+
+export type UserRequestEvent = {
+ event_id: string;
+ request_id: string;
+ event_type: "status_changed" | "note_added" | "archived";
+ status: UserRequestStatus | "";
+ note: string;
+ admin_id: number;
+ created_at: string;
+};
+
+export type UserRequestsResponse = {
+ items: UserRequest[];
+ count: number;
+};
+
+export type UserRequestDetailResponse = {
+ request: UserRequest;
+ events: UserRequestEvent[];
+};
+
+export type UserRequestListParams = {
+ type?: UserRequestType;
+ status?: UserRequestStatus;
+ archived?: boolean;
+ q?: string;
+ start?: string;
+ end?: string;
+ limit?: number;
+ offset?: number;
+};
+
+export type UpdateUserRequestStatusRequest = {
+ status: UserRequestStatus;
+};
+
+export type AddUserRequestNoteRequest = {
+ note: string;
+};
+
export type Channel = {
id: number;
kick_channel_id: number | null;
@@ -389,6 +466,31 @@ export type ChannelSubscriptionSummary = {
latest_event_at: string | null;
};
+export type ChannelSubscriber = {
+ subscriber_kick_user_id: number;
+ username: string;
+ slug: string;
+ profile_image_url: string;
+ is_gift: boolean;
+ gifter_kick_user_id?: number;
+ gifter_username?: string;
+ gifter_slug?: string;
+ gifter_profile_image_url?: string;
+ started_at: string;
+ expires_at: string;
+};
+
+export type ChannelSubscribersResponse = {
+ channel_slug: string;
+ gift_only: boolean;
+ count: number;
+ limit: number;
+ offset: number;
+ items: ChannelSubscriber[];
+};
+
+export type ChannelSubscriberExportFormat = "json" | "csv" | "txt";
+
export type EventSubStatus = {
event_type: string;
kick_subscription_id: string;
diff --git a/apps/web/src/types/directory.ts b/apps/web/src/types/directory.ts
new file mode 100644
index 0000000..e364c32
--- /dev/null
+++ b/apps/web/src/types/directory.ts
@@ -0,0 +1,19 @@
+export type DirectoryKind = "users" | "channels";
+
+export type DirectoryIdentity = {
+ id: number;
+ name: string;
+ slug: string;
+ profile_image_url: string | null;
+};
+
+export type DirectoryPage = {
+ items: DirectoryIdentity[];
+ next_cursor: string | null;
+};
+
+export type DirectoryQuery = {
+ prefix: string;
+ limit?: number;
+ after?: string;
+};
diff --git a/clickhouse/config.d/logging.xml b/clickhouse/config.d/logging.xml
new file mode 100644
index 0000000..cb41c5e
--- /dev/null
+++ b/clickhouse/config.d/logging.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ warning
+ 1
+ warning
+ 10M
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
diff --git a/clickhouse/users.d/logging.xml b/clickhouse/users.d/logging.xml
new file mode 100644
index 0000000..8898029
--- /dev/null
+++ b/clickhouse/users.d/logging.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+
+
+
diff --git a/compose.yaml b/compose.yaml
index 77d379e..7707a4b 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -23,11 +23,18 @@ services:
- "${CH_BIND_HOST:-127.0.0.1}:${CLICKHOUSE_NATIVE_PORT:-9000}:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
- # Mount only this file, not the whole config.d directory: a directory bind
+ # Mount individual files, not the whole config.d directory: a directory bind
# mount would shadow the image's own config.d files (e.g. the one setting
# listen_host 0.0.0.0), leaving ClickHouse bound to localhost so the api
# and listener containers get "connection refused" on clickhouse:9000.
- ./clickhouse/config.d/memory.xml:/etc/clickhouse-server/config.d/memory.xml:ro
+ - ./clickhouse/config.d/logging.xml:/etc/clickhouse-server/config.d/logging.xml:ro
+ - ./clickhouse/users.d/logging.xml:/etc/clickhouse-server/users.d/logging.xml:ro
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
healthcheck:
test:
[
diff --git a/docs/app-logo.png b/docs/app-logo.png
index 8e1b550..cd26f73 100644
Binary files a/docs/app-logo.png and b/docs/app-logo.png differ
diff --git a/docs/architecture.md b/docs/architecture.md
index 1dceefa..0c7228c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -118,6 +118,8 @@ ClickHouse stores data-plane rows:
- `raw_kick_events`
- `raw_event_attempts`
- `channel_subscription_periods`
+- `user_requests`
+- `user_request_events`
`chat_messages` is denormalized. It includes sender/channel snapshots, normalized helper columns,
reply metadata, emote arrays/image URLs, badges, raw payload JSON, message timestamps, and ingestion
@@ -141,6 +143,8 @@ The Go API preserves the existing frontend contract:
```text
GET /health
+POST /requests
+
POST /auth/login
POST /auth/logout
GET /auth/me
@@ -155,6 +159,12 @@ DELETE /admin/channels/{channel_id}
GET /admin/users
POST /admin/users
+GET /admin/requests
+GET /admin/requests/{request_id}
+POST /admin/requests/{request_id}/status
+POST /admin/requests/{request_id}/notes
+POST /admin/requests/{request_id}/archive
+
GET /admin/operations/summary
GET /admin/data-management/summary
@@ -163,6 +173,9 @@ POST /admin/data-management/cleanup/preview
POST /admin/data-management/cleanup/confirm
GET /analytics/overview
+GET /analytics/homepage
+GET /directory/users
+GET /directory/channels
GET /analytics/message-volume
GET /analytics/top-senders
GET /analytics/top-channels
@@ -178,11 +191,41 @@ GET /admin/webhooks/health
POST /admin/webhooks/sync
```
+### Prepared Homepage Analytics
+
+`usecase/homepage` prepares one versioned 14-day UTC snapshot per API process. Requests read
+memory only; a background task runs the existing deduplicated analytics queries sequentially
+every 15 minutes. A dedicated ClickHouse connection wrapper limits only this task to two threads,
+384 MiB and 15 seconds per query, with a 90-second overall refresh deadline. Existing analytics
+and all-time profile endpoints retain their original contracts.
+
+The last successful snapshot is atomically persisted as `homepage-analytics-v1.json` beside
+`SQLITE_PATH`, restored on restart, and served during refresh/failure for at most 24 hours. This is
+a disposable read cache, not another source of message history. Partial refreshes never publish.
+Failures retry with exponential delays from one to 15 minutes. An empty cache returns HTTP 202
+with `Retry-After`, not fabricated zero statistics. See `operations/public_analytics.md` for rollout.
+
+### All-Time Profile Summaries
+
+`usecase/profiles` uses the optional `ports.ProfileSummaryRepository` capability to fetch overview
+and top-five counterpart identities in one query. ClickHouse groups the filtered `chat_messages
+FINAL` rows with `WITH TOTALS`; exact totals cover all groups before the ranking limit. One tuple
+`argMax` retrieves metadata using the existing deterministic rank fields. Nullable IDs, deletion
+filters, all-time scope and response ordering retain the original query semantics.
+
+Repositories without this capability, or a failed combined query, use the original independent
+overview/ranking calls so existing partial-result behavior is retained. Profile caches, volume,
+emotes, latest messages and public schemas are unchanged. This is a read-query optimization only,
+without new storage, migrations or changes to ingestion. The benchmark and rollout procedure are
+in `operations/public_analytics.md`.
+
Public routes:
+- `POST /requests`
- `/messages`
- `/messages/export`
- `/analytics/*`
+- `/directory/users` and `/directory/channels`
- `/users/{slug}/analytics`
- `/channels/{slug}/analytics`
- `/channels/{slug}/subscription-summary`
diff --git a/docs/context/change_log.md b/docs/context/change_log.md
index 701193e..4cf776f 100644
--- a/docs/context/change_log.md
+++ b/docs/context/change_log.md
@@ -2,6 +2,164 @@
This is a living implementation log. Add new entries for each meaningful project change.
+## 2026-10-02 (all-time profile query pair)
+
+- Added optional combined profile summary queries using WITH TOTALS and tuple argMax. Overview
+ and top counterpart ranking now share one scan while keeping all-time results and exact counts.
+- Profile service uses the capability with legacy fallback. Cache behavior, HTTP schemas, UI,
+ message search, subscriptions and database schemas are unchanged.
+- Added old/new real-ClickHouse equivalence tests, HTTP response/cache/fallback regression tests,
+ and an opt-in read-only benchmark. Isolated integration fixtures cover historical/null/empty,
+ identity normalization, replacement, tombstone, metadata, ordering and ranking-limit cases.
+- Local 1.4M-row comparison returned identical results: half the rows read, approximately 22%/26%
+ faster channel/user summary pairs. Timing is local, not a full-page or production guarantee.
+- Documented measurement conditions, deployment, rollback and remaining issue #29 acceptance work
+ in `docs/operations/public_analytics.md`. No application rows were migrated or rewritten.
+- Passed backend CI-equivalent tests/vet/format, isolated integration/migration smoke checks and
+ repository Prettier. Rebuilt the local API and verified health plus both profile endpoints (200).
+
+## 2026-10-01 (homepage timestamp copy)
+
+- Removed visible UTC date-range/update metadata from the homepage while keeping period labels
+ and the stale-data notice. API timestamps, caching and calculations are unchanged.
+- Updated regression coverage for both fresh and stale snapshots.
+
+## 2026-10-01 (animated profile loading)
+
+- Added shared responsive user/channel skeletons for identity, metrics, charts and latest messages.
+- Added accessible status/busy semantics and reduced-motion-safe animation. Existing profile APIs,
+ all-time values, loaded content and error/404 states are unchanged.
+- Verified desktop/mobile screenshots and no horizontal overflow with delayed test responses.
+- Final validation: full Go tests, vet, gofmt, isolated ClickHouse integration and both migration
+ smoke commands; 165 frontend tests, typecheck, lint, production build, and full Prettier check.
+- Browser checks used isolated fixture databases, not production data. Directory submit/pagination
+ and homepage period labels were exercised; production-scale load benchmarks remain outstanding.
+
+## 2026-10-01 (prepared 14-day homepage)
+
+- Added a dedicated homepage service/endpoint and versioned atomic file cache, restored across
+ restarts. Background work uses shared 14-day UTC boundaries, sequential queries and resource caps.
+- Kept existing ClickHouse deduplication and original analytics/profile contracts; no data backfill
+ or application-table migration was introduced for this snapshot.
+- Replaced the landing page's five-request waterfall with one snapshot request, explicit period and
+ update labels, bounded initializing retries, stale notice, and honest failure/retry state.
+- Added refresh/restart/cancellation/partial-failure tests, file corruption/size tests, HTTP/rate-limit
+ tests, and real ClickHouse boundary/deduplication assertions in the existing CI integration suite.
+
+## 2026-10-01 (indexed user/channel directories)
+
+- Added public prefix directory APIs backed solely by SQLite identity metadata, with indexed
+ ranges, escaped literal input, bounded pages, validated query-bound cursors, and rate limits.
+- Added SQLite migration 9 for name/slug expression indexes; no chat-history migration required.
+- Updated both index pages to show identity-only results and append pages on demand.
+- Added backend route/repository/use-case and frontend behavior tests, including query-plan checks.
+- Follow-up verification of the earlier logging change passed on an isolated ClickHouse 24.8
+ container with fixture data. No real application tables or volumes were modified.
+
+## 2026-10-01 (ClickHouse logging controls)
+
+- Added `clickhouse/config.d/logging.xml` and `clickhouse/users.d/logging.xml` to disable routine
+ query/system history collectors and query profiling in the default user profile.
+- Mounted the overrides individually through Compose, preserving the image's own configuration.
+- Kept warning/error text diagnostics with file rotation and added bounded Docker console logging.
+- Added a deployment/verification/rollback runbook with optional, explicitly scoped system-log
+ cleanup. Existing application data and historical logs were not modified.
+- Verified Compose configuration, XML syntax, and diff whitespace. ClickHouse runtime testing could
+ not run because Docker Desktop's Linux engine was unavailable.
+
+## 2026-07-06 (subscription expiry fallback)
+
+- Updated Kick webhook subscription normalization so any subscription event without a Kick-provided
+ `expires_at` falls back to `created_at + 31d` instead of `created_at + 30d`.
+- Kept Kick-provided `expires_at` authoritative when present.
+- Added regression coverage for missing-expiry subscription and gift webhook payloads.
+
+## 2026-06-14 (brand favicon set)
+
+- Added the generated favicon set to `apps/web/public`.
+- Replaced the shared visible app logo asset with the new 512px favicon logo.
+- Updated Next metadata so browsers use `/favicon.ico`, `/favicon.svg`, `/favicon-96x96.png`,
+ `/apple-touch-icon.png`, and `/site.webmanifest`.
+- Updated the documentation logo at `docs/app-logo.png` so README/design references match the app.
+
+## 2026-06-14 (active channel subscribers)
+
+- Added public active subscriber list and export APIs for channel profiles:
+ - `GET /channels/{slug}/subscribers`
+ - `GET /channels/{slug}/subscribers/export`
+- Extended the subscription period domain/port and ClickHouse repository with paginated list and
+ full export methods.
+- Added subscriber export formatters for JSON, CSV, and readable TXT.
+- Added public rate-limit policies for subscriber list and subscriber export routes.
+- Added backend tests for list query mapping, gift-only filtering, JSON/CSV/TXT export, invalid
+ format handling, missing channel behavior, and ClickHouse integration coverage for active/gift
+ filtering.
+- Added frontend subscriber API helpers and response types.
+- Added a channel-profile subscriber modal:
+ - `AKTİF ABONE` opens all active subscribers.
+ - `HEDİYE ABONE` opens gift-only active subscribers.
+ - first page loads 50 rows and `Daha fazla yükle` appends more.
+ - download menu supports JSON, CSV, and TXT and closes on outside click.
+ - empty state uses user-facing copy without webhook/internal terms.
+- Updated design and context docs for the subscriber modal and no-streak/no-month-count decision.
+
+## 2026-06-14 (admin request management frontend)
+
+- Implemented `/admin/requests` for managing public request form submissions.
+- Added `Requests` to the admin sidebar navigation.
+- Added admin request API wrappers for list, detail, status update, note creation, and archive.
+- Added request list filters for type, status, archive state, text query, and optional date range.
+- Added list/detail workflow:
+ - list rows show request type, title, channel/contact preview, status, archive state, and created date.
+ - request list uses full width; detail opens in a modal.
+ - detail modal shows original content, metadata, status control, note form, timeline, and archive.
+- Added frontend tests for active default listing, filter query mapping, detail loading, status
+ update, note creation, and archive action.
+- Updated design/context/implementation docs and README for the completed request workflow.
+- Completed final verification for the request-form feature:
+ - `go test ./...`
+ - `pnpm --filter @kick-logs/web test`
+ - `pnpm --filter @kick-logs/web typecheck`
+ - `pnpm --filter @kick-logs/web lint`
+ - `pnpm --filter @kick-logs/web build`
+ - targeted Prettier check for changed files
+ - `gofmt -l cmd internal`
+ - `docker compose ps`
+
+## 2026-06-13 (public request form frontend)
+
+- Implemented the public `/request` page for the request form feature.
+- Added `Talep` to the public header:
+ - desktop right action area near GitHub/Admin,
+ - mobile hamburger panel above Admin.
+- Added a compact two-mode form:
+ - `Kanal Talebi` submits channel tracking requests,
+ - `Geri Bildirim` submits product feedback or general messages.
+- Wired submit to `POST /requests` through `features/requests/api.ts`.
+- Added inline success/error states, returned request id display, and hidden honeypot field support.
+- Added frontend tests for navigation visibility, channel request payload, feedback payload, and
+ required-field gating.
+- Updated design/context/implementation docs. Admin request management frontend remains pending.
+
+## 2026-06-13 (public request form backend)
+
+- Implemented the backend foundation for the public request form feature:
+ - `user_requests` ClickHouse table for immutable public submissions.
+ - `user_request_events` ClickHouse table for append-only admin workflow events.
+ - domain models, storage port, ClickHouse repository, and request use case.
+- Added public `POST /requests` with validation, channel slug normalization, honeypot rejection,
+ HMAC-hashed IP/user-agent metadata, and a dedicated IP-based rate-limit policy.
+- Added authenticated admin APIs:
+ - `GET /admin/requests`
+ - `GET /admin/requests/{request_id}`
+ - `POST /admin/requests/{request_id}/status`
+ - `POST /admin/requests/{request_id}/notes`
+ - `POST /admin/requests/{request_id}/archive`
+- Admin request state is computed from events: status defaults to `new`, notes are timeline events,
+ and archive is an event instead of a hard delete.
+- Updated architecture, project plan, implementation plan, and context docs. Frontend `/request` and
+ `/admin/requests` screens remain pending.
+
## 2026-06-02 (channel index aggregate hardening)
- Reworked ClickHouse analytics aggregates to avoid
@@ -341,7 +499,7 @@ Backend pipeline for tracking Kick subscription events via webhooks. Phase 8 (fr
fail-closed (503 with no key, 401 on bad sig); rate-limit exempt; 8 route tests.
- **Phase 5 — Processor and normalization**: `webhookprocessor.Service` (5s tick, background
worker); normalizer handles `channel.subscription.new/renewal` (1 period) and
- `channel.subscription.gifts` (1 period/giftee); `expires_at` fallback `created_at + 30d`;
+ `channel.subscription.gifts` (1 period/giftee); `expires_at` fallback `created_at + 31d`;
`ErrIgnored` for unsupported/unfollowed events; 13 tests.
- **Phase 6 — Backend query APIs**: `GET /channels/{slug}/subscription-summary` (public active
count); `GET /admin/webhooks/health` (inbox counts, sync status, config flags);
diff --git a/docs/context/decisions.md b/docs/context/decisions.md
index 7e92ec5..3024293 100644
--- a/docs/context/decisions.md
+++ b/docs/context/decisions.md
@@ -1,5 +1,126 @@
# Decisions
+## 2026-10-02 (SQL-only profile optimization)
+
+- **Preserve displayed results and all-time scope.** The owner approved query changes, not new
+ profile data windows, cache policies or UI. Keep exact counts, metadata, ordering and schemas.
+- **Combine overview and counterpart ranking.** `WITH TOTALS` shares one history scan; tuple
+ `argMax` shares metadata selection. Totals are not a sum of per-group distinct counts.
+- **Retain read correctness.** Keep FINAL, deleted-row filters, nullable identity behavior and
+ existing rank fields. Do not push non-key filters before FINAL or use approximate distincts.
+- **Keep the original fallback.** Optional repository capability isolates other consumers; failure
+ falls back to independent calls and existing partial results, with the extra-query cost explicit.
+- **Measure without rewriting data.** A read-only, fixed-cutoff benchmark compares full results
+ and read metrics. Local improvement does not establish production p95 or issue #29 completion.
+- **Continue on dev.** Use the existing folder and `dev` as requested; no extra worktree or push.
+
+## 2026-10-01 (homepage copy simplification)
+
+- **Hide explicit snapshot timestamps in the UI.** Keep `Son 14 gün` and the stale notice, but remove
+ the date-range/update text. API `start`, `end`, `as_of` and cache behavior remain unchanged.
+
+## 2026-10-01 (profile waiting experience)
+
+- **Profile work is visual only in this pass.** Keep all-time metrics and APIs. Replace the plain
+ loading message with a layout-matched skeleton and small spinner; no fabricated counts/progress.
+- **Respect accessibility and stable layout.** Expose a polite loading status and busy region,
+ hide decorative shapes, preserve mobile wrapping, and disable animation under reduced motion.
+
+## 2026-10-01 (homepage recent-window snapshots)
+
+- **Only the homepage becomes recent-only.** Use 14 UTC calendar days including today, ending at
+ an explicit second-precision `as_of`. Keep `/search` and all-time profile queries unchanged.
+- **Visitors do not refresh analytics.** One background task prepares all five panels, then swaps
+ the complete snapshot atomically. A fixed endpoint prevents timestamp-fragmented cache keys.
+- **Persist only the small result, not another message copy.** A versioned atomic JSON file beside
+ SQLite survives API restarts and is disposable; existing message history remains authoritative.
+- **Bound background cost and failures.** Sequential queries, resource/time limits, single-flight,
+ exponential retry backoff, and a 24-hour maximum stale age. Keep the last good result on error.
+- **Represent unavailability honestly.** Initializing returns 202; UI uses skeletons and bounded
+ retries. Stale data shows its actual date range. Existing public analytics contracts are additive,
+ not silently redefined. No exact production speed claim without representative measurements.
+
+## 2026-10-01 (public directory lookup)
+
+- **Directory search is identity lookup, not analytics.** Match name/slug prefixes from existing
+ SQLite metadata with expression indexes. Never count or group chat history on this path.
+- **Keep explicit submit.** Two-character minimum, 50-row default / 100-row API maximum, keyset
+ pagination, and a dedicated rate-limit bucket shared by the two directory routes.
+- **Do not imply historical totals.** Remove message count and last-message columns rather than
+ replacing them with approximate values. Alphabetical identity order replaces activity ranking.
+- **Keep historical data and existing APIs.** Disabled channels remain searchable. Metadata-only
+ lookup cannot discover a sender missing from `sender_profiles`; no automatic archive scan is
+ introduced. Existing analytics consumers, profiles, and `/search` keep their contracts.
+
+## 2026-10-01 (ClickHouse diagnostic storage)
+
+- **Routine query/system-log history is disabled in Compose.** Keep the small VPS disk budget for
+ application data rather than indefinite diagnostic tables and profiling samples.
+- **Do not suppress actionable errors.** Keep warning/error text logs with bounded rotation, live
+ system metrics, and crash/backup logs. Historical query dashboards lose new data by design.
+- **Existing history cleanup is explicit and optional.** Disabling collectors does not delete old
+ log rows. Never remove application tables or volumes to reclaim diagnostic storage.
+- **Deploy requires container recreation.** New XML mounts and Docker logging options do not take
+ effect through a plain restart; follow `docs/operations/clickhouse_logging.md`.
+
+## 2026-06-14 (active channel subscribers)
+
+- **Active subscriber detail is public on channel profiles.** `/channels/{slug}` lets visitors open
+ the active subscriber list from the `AKTİF ABONE` stat cell and the gift-only list from
+ `HEDİYE ABONE`.
+- **The list uses captured subscription periods only.** Active means `expires_at > now()` from
+ `channel_subscription_periods`; the UI must not imply knowledge of subscriptions from before
+ webhook tracking was enabled.
+- **Subscribers are deduplicated per Kick user.** If multiple active periods exist for the same
+ subscriber, public list/export use the latest period instead of rendering duplicates.
+- **No inferred streak/month count.** Current persisted webhook data does not include a reliable
+ streak/month value. The modal and export omit it instead of deriving a misleading value from
+ partial captured history.
+- **Export is visitor-friendly.** The modal exposes one download icon with JSON, CSV, and readable
+ TXT options. TXT is the human-readable default format.
+
+## 2026-06-14 (admin request management frontend)
+
+- **Request management lives in one admin route.** `/admin/requests` owns filtering, list, detail,
+ status, note, and archive controls instead of splitting the workflow across multiple pages.
+- **Request detail opens as a modal.** The list keeps the full admin page width for scanning many
+ submissions; status, note, timeline, and archive actions live inside the selected request modal.
+- **Active requests are the default view.** Archived requests are available through the archive
+ filter so the default operator view stays focused on current work.
+- **Archive remains a workflow action.** The admin UI exposes `Arşivle`, not delete, matching the
+ append-only backend event model.
+
+## 2026-06-13 (public request form frontend)
+
+- **`/request` is a compact app page, not a landing page.** The form uses the existing dark
+ operations-tool layout: page title, one form panel, one supporting side panel, no hero treatment,
+ no decorative effects.
+- **One form handles both public request types.** `Kanal Talebi` and `Geri Bildirim` are mode
+ buttons inside the same page. Switching to feedback hides channel-specific fields instead of
+ sending empty channel UI.
+- **The public header exposes request submission as `Talep`.** Desktop places it in the right action
+ area near GitHub/Admin; mobile places it inside the hamburger panel above Admin.
+- **Success and failure stay inline.** The page does not redirect after submit. A successful submit
+ shows the returned request id; validation and rate-limit errors remain in the form panel.
+
+## 2026-06-13 (public request form backend)
+
+- **Request form data belongs in ClickHouse, not SQLite.** Public channel requests and feedback are
+ product/user history, not control-plane runtime state. SQLite remains for admin users, followed
+ channels, heartbeat/operations state, webhook registry/inbox state, and other small operational
+ records.
+- **The request workflow is append-only.** `user_requests` stores immutable public submissions.
+ Admin actions append to `user_request_events` instead of updating the original row. Current status
+ is computed from the latest `status_changed` event and defaults to `new`.
+- **Archive replaces delete for the MVP.** Admin archive writes an `archived` event. There is no hard
+ delete endpoint in the first implementation.
+- **Public request metadata is hashed before storage.** IP and user-agent values are HMAC-hashed
+ before insertion. The API should not store raw visitor IP/user-agent values for this feature by
+ default.
+- **Public request submissions are rate-limited.** `POST /requests` has its own IP-based policy
+ (5 requests per 10 minutes, burst 2) plus a honeypot field (`website`) that rejects simple bot
+ submissions.
+
## 2026-06-02 (channel/user top-list aggregate hardening)
- **Channel index and admin channel counts must avoid window scans.** `/channels` search and the
@@ -170,7 +291,7 @@ sender profile cache behavior, and webhook inbox maintenance where noted.
- **`channel_subscription_periods` uses `ReplacingMergeTree(ingested_at)` ORDER BY `id`.** The
deterministic `id` (`messageID` for new/renewal, `messageID_gifteeUserID` for gifts) makes
re-processing idempotent. Queries use `FINAL` to deduplicate on read.
-- **Subscription period `expires_at` is always set before storage.** The fallback (`created_at + 30d`)
+- **Subscription period `expires_at` is always set before storage.** The fallback (`created_at + 31d`)
is applied during normalization (Phase 5), not at query time. The ClickHouse column is NOT NULL.
- **`kick_subscription_id` preserved on upsert conflict.** The `ON CONFLICT DO UPDATE` SQL uses
`CASE WHEN excluded.kick_subscription_id != '' THEN ... ELSE existing END` so a sync error
diff --git a/docs/context/living_brain.md b/docs/context/living_brain.md
index 5dd297e..5f9c1a7 100644
--- a/docs/context/living_brain.md
+++ b/docs/context/living_brain.md
@@ -5,7 +5,10 @@ implementation details, or working assumptions change.
## Current State
-- Branch: `feat/issue-23-storage-hot-path-hardening`.
+- Integration target: `dev`; issue #27's approved first-pass changes were developed on
+ `feat/issue-27-analytics-performance` from `dev`.
+- Issue #27 is closed. The owner approved the narrower SQL-only profile follow-up from #29 directly
+ on `dev`, in the existing workspace. Issue #29's broader acceptance/backfill work remains open.
- Active architecture: JetStream durable ingestion (see `docs/implementation_plan.md`, issue #23).
Live chat ingestion now runs as `listener -> NATS JetStream -> processor -> ClickHouse`, with
SQLite used for control-plane state only.
@@ -35,6 +38,107 @@ implementation details, or working assumptions change.
- `raw_kick_events`: 121664
- `raw_event_attempts`: 121664
+## Public Identity Directory (issue #27)
+
+The owner-approved scope is homepage recent statistics, prefix directories, and profile loading
+visuals only. `/search`, exports, subscription calculations, and profile all-time queries are not
+being redesigned in this pass. The wider issue's production benchmarking/profile-query work is
+not implied complete by these changes.
+
+- `/users` and `/channels` use `GET /directory/users` and `GET /directory/channels`.
+- Lookup reads indexed SQLite `sender_profiles` / `followed_channels` metadata, not chat history.
+ Disabled followed channels remain discoverable; users require an existing sender-profile cache
+ entry. No historical identity backfill or remote Kick lookup runs during search.
+- Prefix-only matching on name/slug, normalized `_`/`-` variants, minimum two characters.
+ Results sort by normalized slug then id, with 50-row pages and opaque continuation cursors.
+- Directory rows show identity/avatar only, not all-time message counts or activity timestamps.
+- SQLite migration 9 adds four expression indexes. Existing message/subscription data is unchanged.
+- `/search` and the all-time profile analytics contracts remain unchanged.
+
+## Prepared Homepage (issue #27)
+
+- User/channel profiles now share responsive `ProfileLoading` skeletons with accessible status and
+ reduced-motion support. Their all-time data request/response behavior is unchanged.
+
+- `GET /analytics/homepage` returns a coherent 14-day UTC snapshot with `as_of`, period, and stale
+ metadata. All five panels use the same window; the volume always contains 14 daily bins.
+- One API-lifetime background refresh, five sequential bounded queries, 15-minute refresh interval.
+ Requests never trigger aggregate queries. No new materialized views or historical rewrites.
+- Last good snapshot survives restarts in `/data/homepage-analytics-v1.json` (beside `SQLITE_PATH`),
+ even if ClickHouse is unavailable at startup. File format is versioned and bounded to 1 MiB.
+- Maximum stale age 24 hours, 90-second refresh deadline, per-query 2 threads / 384 MiB / 15s.
+ Failure backoff 1-15 minutes. Future, malformed, incompatible or expired cache is rejected.
+- Before any valid snapshot exists, return 202 initializing with Retry-After 5; the UI retries for
+ at most two minutes, then offers manual retry. Errors are not rendered as real zero values.
+- This optimizes request paths, not every existing analytics endpoint. Production 18M-row latency,
+ memory and ingestion-backlog comparisons still require measurement on the deployment.
+
+## All-Time Profile Queries (issue #29)
+
+- Combined overview/top-five counterpart query replaces two history scans with one on the normal
+ profile path. Optional `ProfileSummaryRepository` capability keeps other analytics consumers
+ unchanged and falls back to the old calls on error or when unavailable.
+- `WITH TOTALS` preserves exact counts over every matching group before LIMIT. Tuple `argMax`
+ preserves metadata selection. Keep FINAL, tombstones, null identities and all-time scope.
+- No schema/data migration, UI change, cache redesign or `/search` change. Remaining profile
+ volume/emote/latest-message queries are unchanged.
+- Real local data gave equal outputs and 50% fewer rows read for the changed query pair. Local
+ timings improved approximately 22% for a channel and 26% for a user. Not production acceptance;
+ see `docs/operations/public_analytics.md` for conditions and read-only benchmark instructions.
+
+## ClickHouse Logging Policy
+
+- Compose individually mounts `clickhouse/config.d/logging.xml` and
+ `clickhouse/users.d/logging.xml` to disable routine query/profiling/system-log history.
+- Warning/error text logs remain available with rotation; Docker console logs are capped separately.
+ Live system metrics and application history are unaffected.
+- Existing diagnostic tables are not automatically deleted. Deployment, optional scoped cleanup,
+ and rollback are documented in `docs/operations/clickhouse_logging.md`.
+- Runtime verified on isolated ClickHouse 24.8: existing diagnostic rows stopped growing, live
+ metrics remained available, and application fixture rows survived restart and opt-in log cleanup.
+
+## User Request Form Backend
+
+- Active implementation plan now targets a public request form for two request types:
+ - `channel_request`: visitors ask for a Kick channel to be added to tracking.
+ - `feedback`: visitors send product feedback, bug reports, or feature ideas.
+- Backend storage uses ClickHouse append-only tables, not SQLite:
+ - `user_requests`: immutable public submissions.
+ - `user_request_events`: admin workflow events (`status_changed`, `note_added`, `archived`).
+- Current status is computed from latest `status_changed` event and defaults to `new`.
+- Archive is append-only through an `archived` event; there is no delete workflow in the MVP.
+- Public endpoint:
+ - `POST /requests`
+ - IP and user-agent are HMAC-hashed before storage.
+ - Honeypot field `website` is rejected when filled.
+ - Public route rate limit: `POST /requests`, IP key, 5 requests per 10 minutes, burst 2.
+- Admin endpoints:
+ - `GET /admin/requests`
+ - `GET /admin/requests/{request_id}`
+ - `POST /admin/requests/{request_id}/status`
+ - `POST /admin/requests/{request_id}/notes`
+ - `POST /admin/requests/{request_id}/archive`
+- Admin status values:
+ - `new`
+ - `reviewing`
+ - `approved`
+ - `rejected`
+ - `done`
+ - `duplicate`
+- Public frontend is implemented:
+ - `/request` is public and mounts `features/requests/request-page.tsx`.
+ - Header includes `Talep`; desktop shows it near GitHub/Admin, mobile shows it above Admin.
+ - The form has `Kanal Talebi` and `Geri Bildirim` modes and posts through
+ `features/requests/api.ts`.
+ - Success renders the returned request id inline; rate-limit/validation errors stay inline.
+- Admin request management frontend is implemented:
+ - `/admin/requests` mounts `features/requests/request-admin.tsx`.
+ - Admin sidebar includes `Requests` for regular admin and super admin users.
+ - Default list filter shows active requests only.
+ - Filters cover type, status, archive state, text query, and date range.
+ - The request list is full width; selecting a row opens a detail modal.
+ - The detail modal supports status change, note creation, event timeline, and archive action.
+
## Webhook Subscription Pipeline (issue #22, Phase 6 complete)
- `domain.KickWebhookEvent` — webhook inbox model; status: `pending/processed/failed/ignored`
@@ -50,6 +154,31 @@ implementation details, or working assumptions change.
- SQLite migrations v5 (broadcaster_user_id), v6 (kick_webhook_events), v7 (kick_event_subscriptions)
- ClickHouse migration v5 (channel_subscription_periods, ReplacingMergeTree ORDER BY id)
- Active count query uses `FINAL` + `countDistinctIf(subscriber_kick_user_id, expires_at > now())`
+- Webhook normalization keeps Kick-provided `expires_at` authoritative. If Kick omits `expires_at`,
+ the fallback expiry is `created_at + 31d`.
+
+## Active Channel Subscribers
+
+- Public channel profiles now expose detailed active subscriber lists from captured webhook periods.
+- Backend endpoints:
+ - `GET /channels/{slug}/subscribers?limit=50&offset=0&gift_only=false`
+ - `GET /channels/{slug}/subscribers/export?gift_only=false&format=txt|csv|json`
+- Both endpoints resolve the channel through followed-channel metadata and read
+ `channel_subscription_periods` from ClickHouse.
+- Active subscriber definition matches summary counts: `expires_at > now()`.
+- List/export collapse multiple active periods for the same `subscriber_kick_user_id` and use the
+ latest period by expiry/start/ingest ordering.
+- `gift_only=true` returns active subscribers whose selected active period is gifted.
+- Public export supports JSON, CSV, and human-readable TXT. TXT includes channel, generated time,
+ total, user id, username, optional Kick profile slug, optional gifter, start, and expiry.
+- The UI intentionally omits streak/month count because the current stored Kick webhook data does
+ not include a reliable value and historical capture may be partial.
+- Frontend `/channels/[slug]` behavior:
+ - `AKTİF ABONE` opens all active subscribers.
+ - `HEDİYE ABONE` opens gift-only active subscribers.
+ - modal loads 50 rows, appends with `Daha fazla yükle`, and shows
+ `Bu kanal için henüz aktif abonelik kaydı yok.` when empty.
+ - download menu follows the search export pattern and closes on outside click.
## Default Data Stores
@@ -201,9 +330,17 @@ GET /analytics/top-emotes
GET /users/{slug}/analytics
GET /channels/{slug}/analytics
GET /channels/{slug}/subscription-summary
+GET /channels/{slug}/subscribers
+GET /channels/{slug}/subscribers/export
+POST /requests
POST /webhooks/kick
GET /admin/webhooks/health
POST /admin/webhooks/sync
+GET /admin/requests
+GET /admin/requests/{request_id}
+POST /admin/requests/{request_id}/status
+POST /admin/requests/{request_id}/notes
+POST /admin/requests/{request_id}/archive
```
Public routes remain unauthenticated. Admin routes require the HttpOnly JWT session cookie and an
diff --git a/docs/context/recent_changes.md b/docs/context/recent_changes.md
index e260875..5eca03c 100644
--- a/docs/context/recent_changes.md
+++ b/docs/context/recent_changes.md
@@ -2,6 +2,199 @@
This file is the short handoff summary of the latest project changes. Keep it concise and update it after each meaningful change so the next agent can quickly see what just happened.
+## Latest (all-time profile query optimization, issue #29)
+
+- Work continues directly on `dev` in the same folder at the owner's request.
+- Profile overview and top-five counterpart ranking share one ClickHouse history scan through an
+ optional port, with original-query fallback. All-time data, exact totals, metadata/order, cache,
+ HTTP responses, UI and `/search` remain unchanged. No schema or stored-data migration.
+- Old/new equality verified on isolated edge-case fixtures and real local historical data. The
+ changed pair reads half as many rows; local median time improved about 22% channel / 26% user.
+- Read-only benchmark and rollout notes: `docs/operations/public_analytics.md`. Production p95,
+ metadata coverage/backfill and other broader #29 tasks are not claimed complete. #28 untouched.
+- Validation passed: full Go tests, vet, gofmt, isolated ClickHouse integration, SQLite/ClickHouse
+ migration smoke checks, full Prettier and API Docker build. Local health and both profile routes
+ return 200. The local API was rebuilt; no frontend source changes were needed.
+- Separate local startup warning: Kick token requests return 401, preventing public-key retrieval
+ and webhook subscription sync. Credentials were not changed; this is outside the SQL task.
+
+## Previous (homepage timestamp copy)
+
+- Removed the homepage's explicit UTC date range and update timestamp at the owner's request.
+- Kept all `Son 14 gün` labels, stale-data warning, API metadata, and refresh behavior unchanged.
+- Integration verification passed: Go tests/vet/gofmt, isolated ClickHouse integration, 166 frontend
+ tests, typecheck, lint, production build and repository-wide Prettier. Wider issue #27 follow-ups
+ remain open; this is not production-scale performance acceptance.
+
+## Previous (profile loading and verification, issue #27)
+
+- User/channel profiles use animated, responsive skeletons with accessible status and reduced-motion
+ support. Profile all-time queries, loaded views and `/search` remain unchanged.
+- Full backend/frontend CI-equivalent checks passed locally, including isolated ClickHouse tests,
+ migration smoke checks, 165 frontend tests and production build. GitHub CI has not run yet.
+- Browser checks covered real fixture-backed prefix paging and homepage results plus delayed profile
+ loading on desktop/mobile. No application data was rewritten or deleted.
+- The approved first-pass features are implemented; broader issue #27 production benchmarking and
+ profile-query optimization are not claimed complete. i18n (#28) is untouched.
+
+## Previous (prepared 14-day homepage, issue #27)
+
+- `/` fetches one `/analytics/homepage` snapshot. Every panel states `Son 14 gün`, with actual
+ UTC dates/update time. Existing all-time endpoints and `/search` are untouched.
+- API prepares data in the background, persists the last good snapshot beside SQLite, and serves
+ memory without request-triggered aggregates. Limits/backoff/stale handling are documented in
+ `docs/operations/public_analytics.md`.
+- Initialization and errors use placeholders/retry, never fake zero counts. Volume keeps all 14 days.
+- Production-scale performance has not been measured locally; no issue-wide completion claim.
+
+## Previous (indexed identity directories, issue #27)
+
+- `/users` and `/channels` now use SQLite-backed `/directory/users` and `/directory/channels`.
+- Search matches name/slug prefixes rather than arbitrary substrings; `_` and `-` are normalized.
+- Explicit submit, alphabetic identity order, cursor pagination, no history counts/activity fields.
+- Migration 9 adds four metadata expression indexes; existing data and `/search` are unchanged.
+- Directory coverage is the existing metadata cache, not a new scan/backfill of all chat history.
+
+## Previous (ClickHouse logging controls)
+
+- Added individually mounted server/profile XML overrides to disable routine query/profiling and
+ high-volume system-log history without changing application tables or query behavior.
+- Kept server warning/error diagnostics with 10 MiB rotation and three archives; bounded the
+ ClickHouse container's Docker console logs to three 10 MiB files.
+- Added `docs/operations/clickhouse_logging.md` for deployment, verification, explicit opt-in
+ historical log cleanup, and rollback. No database cleanup or service restart was performed.
+- Compose validation, XML parsing, and isolated ClickHouse 24.8 runtime verification passed after
+ Docker became available. Historical log cleanup was tested only on disposable fixture data.
+- Follow-up work: performance issue #27 on `feat/issue-27-analytics-performance`, then i18n #28.
+
+## Latest (subscription expiry fallback)
+
+- Changed Kick webhook subscription normalization fallback expiry from `created_at + 30d` to
+ `created_at + 31d` when Kick does not provide `expires_at`.
+- Kick-provided `expires_at` remains authoritative and is not clamped.
+- Added regression coverage for missing-expiry normal subscription and gift payloads.
+
+## Latest (brand favicon set)
+
+- Replaced the visible Kick Logs app logo asset with the new favicon-generated logo.
+- Added the full favicon set to `apps/web/public`:
+ - `favicon.ico`
+ - `favicon.svg`
+ - `favicon-96x96.png`
+ - `apple-touch-icon.png`
+ - `web-app-manifest-192x192.png`
+ - `web-app-manifest-512x512.png`
+ - `site.webmanifest`
+- Updated Next metadata to expose the favicon, SVG icon, Apple touch icon, and web manifest from
+ root public paths.
+- Updated `docs/app-logo.png` so README and design references use the same current product mark.
+
+## Latest (active channel subscribers)
+
+- Implemented public active subscriber detail for channel profiles.
+- Backend additions:
+ - `GET /channels/{slug}/subscribers`
+ - `GET /channels/{slug}/subscribers/export`
+ - ClickHouse repository methods for paginated active subscribers and full export lists.
+ - Route rate limits for subscriber list and subscriber export.
+- Subscriber list behavior:
+ - active means `expires_at > now()`,
+ - rows are deduplicated by `subscriber_kick_user_id`,
+ - duplicate active periods choose the latest period,
+ - `gift_only=true` returns gifted active subscribers only.
+- Export supports JSON, CSV, and readable TXT. Streak/month count is intentionally omitted because
+ the current stored data does not contain a reliable value.
+- Frontend `/channels/[slug]` additions:
+ - `AKTİF ABONE` opens the active subscriber modal,
+ - `HEDİYE ABONE` opens the gift-only modal,
+ - modal loads 50 rows at a time and supports `Daha fazla yükle`,
+ - download menu offers JSON, CSV, and TXT and closes on outside click,
+ - empty state says `Bu kanal için henüz aktif abonelik kaydı yok.`
+- Verification run so far:
+ - `go test ./...` from `apps/api-go`: passed
+ - `go vet ./...` from `apps/api-go`: passed
+ - `gofmt -l cmd internal` from `apps/api-go`: passed
+ - `pnpm --filter @kick-logs/web test`: passed
+ - `pnpm --filter @kick-logs/web typecheck`: passed
+ - `pnpm --filter @kick-logs/web lint`: passed
+ - `pnpm --filter @kick-logs/web build`: passed
+ - `pnpm format:check`: passed
+- Local ClickHouse integration test against the developer data volume hit an existing raw-event
+ memory-limit failure before reaching the new subscriber query. CI runs the same test against a
+ clean service.
+
+## Previously Latest (admin request management frontend)
+
+- `/admin/requests` frontend is implemented and wired to the request workflow APIs.
+- Admin sidebar now includes `Requests` for regular admin and super admin users.
+- The Requests page includes:
+ - filters for request type, current status, archive state, text query, start, and end,
+ - active-only requests as the default archive filter,
+ - full-width request list rows with type/status/date and channel/contact preview,
+ - detail modal with original content, metadata, current status, event timeline, status update,
+ note form, and archive action.
+- Frontend API wrapper now covers:
+ - `GET /admin/requests`
+ - `GET /admin/requests/{request_id}`
+ - `POST /admin/requests/{request_id}/status`
+ - `POST /admin/requests/{request_id}/notes`
+ - `POST /admin/requests/{request_id}/archive`
+- Added `request-admin.test.tsx` coverage for default active listing, filters, detail loading,
+ status update, notes, and archive.
+- Final request-form verification completed:
+ - `go test ./...`
+ - `pnpm --filter @kick-logs/web test`
+ - `pnpm --filter @kick-logs/web typecheck`
+ - `pnpm --filter @kick-logs/web lint`
+ - `pnpm --filter @kick-logs/web build`
+ - targeted Prettier check for changed files
+ - `gofmt -l cmd internal`
+ - `docker compose ps`
+- Updated `docs/implementation_plan.md` status: Phases 1-7 complete.
+
+## Previously Latest (public request form frontend)
+
+- Public `/request` frontend is implemented and wired to the backend request API.
+- Global public header is sticky on desktop and mobile.
+- Header now includes `Talep`:
+ - desktop: right action area near GitHub/Admin,
+ - mobile: inside the hamburger panel above Admin.
+- `/request` is public and compact:
+ - `Kanal Talebi` mode collects channel slug/name, title, message, and optional contact.
+ - `Geri Bildirim` mode hides channel fields and collects title, message, and optional contact.
+ - submit calls `POST /requests`,
+ - success renders the returned request id inline,
+ - validation/rate-limit failures render inline,
+ - hidden honeypot field `website` is included for backend bot rejection.
+- The `/request` right-side panel explains the user-facing review process and avoids internal
+ implementation details.
+- Added frontend request page tests for navigation, channel payload, feedback payload, and required
+ field gating.
+- Backend request-form foundation remains:
+ - public endpoint and admin request APIs are implemented,
+ - admin request management frontend remains pending.
+- Public submissions use ClickHouse, not SQLite:
+ - `user_requests` stores immutable form submissions.
+ - `user_request_events` stores append-only admin workflow events.
+- Public endpoint:
+ - `POST /requests`
+ - supports `channel_request` and `feedback`
+ - validates and normalizes payloads
+ - normalizes channel slugs from plain names or Kick URLs
+ - rejects filled honeypot field `website`
+ - stores HMAC-hashed IP and user-agent metadata
+ - has a dedicated IP rate-limit policy: 5 requests per 10 minutes, burst 2
+- Admin endpoints:
+ - `GET /admin/requests`
+ - `GET /admin/requests/{request_id}`
+ - `POST /admin/requests/{request_id}/status`
+ - `POST /admin/requests/{request_id}/notes`
+ - `POST /admin/requests/{request_id}/archive`
+- Current status is computed from the latest `status_changed` event and defaults to `new`. Archive
+ is represented by an `archived` event; no hard delete was added.
+- Updated `docs/implementation_plan.md` status: Phases 1-5 complete, admin frontend and final
+ verification pending.
+
## Latest (channel index aggregate hardening)
- Root cause for `/channels` search failing on high-volume channels such as `hype`: the endpoint
diff --git a/docs/design/design.md b/docs/design/design.md
index 07e9f00..317f10b 100644
--- a/docs/design/design.md
+++ b/docs/design/design.md
@@ -18,17 +18,17 @@ Do not commit screenshots or exported images unless explicitly requested.
- `/admin`: authenticated admin dashboard for backend operations.
- `/login`: login screen.
- `/`: public compact landing page with project positioning and live analytics.
-- `/users`: public user search index. Search-first — empty prompt on load, results populate as
- the user types.
+- `/users`: public user search index. Search-first, results populate on explicit submit.
- `/users/[slug]`: public sender profile with identity, analytics, and latest messages.
-- `/channels`: public channel search index. Search-first — empty prompt on load, results populate
- as the user types.
+- `/channels`: public channel search index. Search-first, results populate on explicit submit.
- `/channels/[slug]`: public channel profile with stored Kick metadata, analytics, and latest
messages.
- `/prediction`: public channel search page for Kick predictions. Search-first — empty prompt on
load. Submitting navigates to `/prediction/{slug}`; it does not render analytics itself.
- `/prediction/[slug]`: public latest-prediction analysis for the channel (summary, charts, outcome
cards, top users), fetched live from Kick in the browser.
+- `/request`: public request form. Visitors can submit channel tracking requests or general feedback
+ without logging in.
Landing must stay product-focused and must not turn into a marketing site.
@@ -36,11 +36,15 @@ Landing must stay product-focused and must not turn into a marketing site.
- Search-first: initial load shows an empty state with a centered icon and a prompt string.
- Explicit submit only: typing does NOT fire requests. User clicks the `Ara` button or presses
- Enter to send the query. Debounce-while-typing is intentionally avoided so high-traffic
- ingestion is not put under additional ClickHouse `LIKE` pressure on every keystroke.
+ Enter to send the query. Typing alone must not create network requests.
- Submit button is disabled until the trimmed query is at least 2 characters long.
- Submit button text switches to `Aranıyor…` and is disabled while a request is in flight.
-- Results are ordered by message count (backend default).
+- Prefix-only matching on the name/slug, with case-insensitive ASCII Kick identity matching and
+ `_`/`-` normalization. Search uses indexed identity metadata, not analytics endpoints.
+- Results are alphabetic by normalized slug, then id. Rows show the avatar, name, and profile
+ link; no message totals, activity timestamps, or implied total-match count.
+- Fetch 50 identities per page. `Daha fazla yükle` appends the next cursor page while preserving
+ existing rows; a new submitted query resets pagination.
- Empty-results state quotes the last submitted query, not the current input value.
- Loading, empty-results, and error states are all handled.
- Each result row links to the corresponding profile page.
@@ -117,18 +121,39 @@ Type scale (Tailwind):
## Global Header
- 56px high, `bg-page` with bottom `border-subtle`.
-- Desktop: left side has logo square + `kick logs` wordmark + nav links; right side has GitHub icon + `Admin` outline button.
-- Mobile: left side has logo square + `kick logs` wordmark; right side has GitHub icon + hamburger (Menu/X toggle). Nav links hidden.
-- Hamburger opens a fixed dropdown panel below the header (z-40) with a dark backdrop. Panel lists all nav links (Search, Channels, Users, Prediction) plus Admin. Clicking any link closes the menu.
+- Sticky on both desktop and mobile (`top: 0`) so it stays visible while scrolling.
+- Desktop: left side has logo square + `kick logs` wordmark + nav links; right side has GitHub icon,
+ `Talep`, and `Admin` outline button.
+- Mobile: left side has logo square + `kick logs` wordmark; right side has GitHub icon + hamburger
+ (Menu/X toggle). Nav links hidden.
+- Hamburger opens a fixed dropdown panel below the header (z-40) with a dark backdrop. Panel lists
+ all nav links (Search, Channels, Users, Prediction), then `Talep`, then Admin. Clicking any link
+ closes the menu.
- `Channels`, `Users`, and `Prediction` nav links are active and point to `/channels`, `/users`,
and `/prediction`.
-- `ActiveRoute` type supports `"search" | "channels" | "users" | "prediction"` to highlight the
- current section. Profile pages (`/channels/[slug]`, `/users/[slug]`) use the parent route
- (`"channels"`, `"users"`) so the nav item stays highlighted when browsing within a section.
+- `ActiveRoute` type supports `"search" | "channels" | "users" | "prediction" | "request"` to
+ highlight the current section. Profile pages (`/channels/[slug]`, `/users/[slug]`) use the parent
+ route (`"channels"`, `"users"`) so the nav item stays highlighted when browsing within a section.
- Clicking the brand goes to `/`.
- Admin page uses a different chrome: brand + `/ admin` breadcrumb on the left, user email +
`SUPER ADMIN` badge + `Çıkış` outline button on the right.
+## Public Request Page (`/request`)
+
+- Public, no auth.
+- Compact page title `Talep` with one short explanatory line.
+- One form panel with two mode buttons:
+ - `Kanal Talebi`: shows `Kanal adı`, `Başlık`, `Mesaj`, optional `İletişim`.
+ - `Geri Bildirim`: shows `Başlık`, `Mesaj`, optional `İletişim`; channel field is hidden.
+- `Talep` header action is primary when this route is active.
+- Submit maps to `POST /requests`; success renders an inline confirmation with request id.
+- Errors stay inline. Rate-limit error copy should clearly ask the visitor to retry later.
+- The right-side support panel explains the user-facing evaluation process only: request review,
+ possible follow-up, and the fact that submission is not guaranteed acceptance. Do not mention admin
+ panels, databases, or internal storage there.
+- Honeypot field exists in the DOM but is hidden and never part of the visible UX.
+- No hero layout, large typography, decorative cards, blur, or glow.
+
## Landing Page (`/`)
- Compact hero: pill badge (`● Self-hosted · Açık kaynak`), large title `Kick chat için kalıcı log.`
@@ -137,8 +162,16 @@ Type scale (Tailwind):
rounded border, separated by 1px hairlines.
- Two analytics rows of 2 columns each: `Mesaj hacmi` bar chart (14 days, accent green bars), `Top
kanallar`, `Top kullanıcılar`, `Top emoteler`. Each as a panel with title + mono sub.
-- Data sources: `/analytics/overview`, `/analytics/message-volume?bucket=day`,
- `/analytics/top-channels`, `/analytics/top-emotes`, `/analytics/top-senders`.
+- All five analytics panels describe `Son 14 gün`: today and the preceding 13 UTC calendar days,
+ ending at the snapshot timestamp. Channels/chatters mean identities active within this window;
+ emotes mean occurrences, not distinct emote names or messages containing emotes.
+- One data source: `/analytics/homepage`. Keep `Son 14 gün` labels, but do not render the explicit
+ reporting date range or update timestamp on the homepage. Time metadata remains in the API.
+ A stale snapshot still receives a visible last-successful-update notice.
+- Keep layout-matched, reduced-motion-aware placeholders while initializing. Never substitute zero
+ for loading/failure. Retry initializing responses at a bounded interval; after at most two minutes
+ offer `Tekrar dene`. Request errors also offer manual retry, without automatic retry loops.
+- Stop polling after a ready snapshot; dispose requests/timers on navigation.
## Search Screen (`/search`)
@@ -226,6 +259,13 @@ no card-per-row treatment.
## User Profile (`/users/[slug]`)
+- While loading, use the shared `ProfileLoading` layout: identity/avatar, metric cells, chart/list
+ shapes, and message-row placeholders. Use staggered neutral pulse and a small accent spinner,
+ disabled for reduced-motion preferences. Announce loading once via a polite status outside the
+ `aria-busy` region; hide decorative placeholders from assistive technologies.
+- This changes waiting visuals only. Keep all-time metrics, existing errors/404 states, links, and
+ the data request contract unchanged. Apply the same rules to channel profiles.
+
- Breadcrumb (mono): `users / yavuz`.
- Identity panel (`bg-panel`, horizontal): circular avatar (real image when available), username
(22/600), `@slug` (mono muted), mono meta row (`ilk mesaj`, `son aktivite`), right-aligned primary
@@ -242,7 +282,18 @@ no card-per-row treatment.
- Breadcrumb (mono): `channels / exampleChannel`.
- Identity panel: rounded-square channel image, display name (22/600), `LOGGING` accent pill, mono
meta row (`ilk log`, `son aktivite`), CTA `Kanalda ara` linking to `/search?channel={slug}`.
-- 4-cell stats bar: `MESAJ`, `KULLANICI`, `EMOTE`, `İLK LOG`.
+- Stats bar: `MESAJ`, `KULLANICI`, `EMOTE`, `İLK LOG`, `AKTİF ABONE`, `HEDİYE ABONE`.
+- `AKTİF ABONE` and `HEDİYE ABONE` cells are clickable after subscription summary loads. They open
+ the active subscriber modal without leaving the channel page.
+- Active subscriber modal:
+ - title is `Aktif aboneler` or `Hediye aktif aboneler`.
+ - first page loads 50 rows; `Daha fazla yükle` appends more rows.
+ - rows show circular subscriber avatar/fallback, username link, Kick user id, gift badge/gifter
+ when available, start date, and expiry date.
+ - empty state copy is exactly `Bu kanal için henüz aktif abonelik kaydı yok.`
+ - download action is one square icon button with JSON, CSV, and TXT options; the menu closes on
+ outside click.
+ - do not show inferred streak/month counts.
- 3-column analytics grid with **equal panel heights**: `Mesaj hacmi`, `Top kullanıcılar`,
`Top emoteler`.
- `Son mesajlar` panel: username (rendered in sender color) + message + mono timestamp. Same emote
@@ -329,8 +380,8 @@ Login required.
### Sidebar Nav
-- `Operations`, `Channels`, `Users`, `Data`, `Settings`. Active item: `bg-panel` background with
- hairline, accent icon, `text-primary` label.
+- `Operations`, `Channels`, `Requests`, `Users`, `Data`, `Settings`. Active item: `bg-panel`
+ background with hairline, accent icon, `text-primary` label.
- Regular `admin` (non-super) users do not see `Users`.
### Operations Section
@@ -378,6 +429,21 @@ ClickHouse geçmişi, depolama özeti`. Right: `Yenile` outline button.
- Visually separate from channel management. No hero/landing treatment.
+### Requests Section
+
+- Route: `/admin/requests`.
+- Dense single-page workflow for public form submissions.
+- Top filter panel includes request type, current status, archive state, text query, and optional
+ start/end date range.
+- Default archive filter is active requests only.
+- Main list uses the full available width; do not reserve a persistent side detail panel.
+- List rows show type, title, optional channel/contact preview, current status, archive state, and
+ created date. Desktop/mobile row variants may coexist behind breakpoint classes.
+- Selecting a row opens a modal dialog for the request detail.
+- The detail modal shows original content, metadata, current status, event timeline, status control,
+ note form, and archive action. Admin workflow actions are performed from this modal.
+- Archive is represented as a workflow action, not a destructive delete.
+
### Default Super Admin Credentials (local MVP)
```text
diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md
index 392c872..a5b3331 100644
--- a/docs/implementation_plan.md
+++ b/docs/implementation_plan.md
@@ -1,424 +1,402 @@
-# Issue 23 - JetStream Durable Ingestion Plan
+# Active Channel Subscribers Implementation Plan
## Summary
-Status: implemented on branch `feat/issue-23-storage-hot-path-hardening`. This file remains the
-reference for why the current JetStream architecture exists and what boundaries future changes must
-preserve.
+This plan defines the next product feature: visitors should be able to open a channel profile page
+at `/channels/{slug}` and view the active subscriber list for that channel.
-Issue 23 stays on branch `feat/issue-23-storage-hot-path-hardening`, but the implementation direction changes after the ingestion audit.
+The list is based only on subscription periods already captured by Kick Logs. It must not imply that
+the app knows subscribers from before webhook tracking was enabled.
-The first hardening work reduced SQLite growth and clarified operational data, but it did not fully satisfy the core product rule: once a Kick chat event reaches the application, the application must not silently drop it because an in-memory buffer is full, a retry budget is exhausted, or a planned reconnect cycle runs.
+The feature builds on the existing subscription summary flow:
-The implemented long-term fix moves the live chat ingestion hot path to NATS JetStream:
+- `GET /channels/{slug}/subscription-summary`
+- `SubscriptionPeriodRepository.ActiveSummary`
+- ClickHouse table: `channel_subscription_periods`
-```text
-Kick WebSocket listener -> NATS JetStream durable stream -> processor workers -> ClickHouse batch inserts
-```
-
-SQLite remains for control-plane data only. ClickHouse remains the long-lived analytics and search store. NATS JetStream is only a durable backlog for unprocessed raw chat events.
-
-Public API contracts, frontend request/response shapes, and existing ClickHouse history must remain compatible.
-
-## Core Principle
-
-The application's most important rule is data capture continuity.
+The existing summary count stays in place. The new work adds a detailed subscriber list modal and a
+user-friendly full-list download.
-If a Kick chat event reaches the listener process, the system must either:
+## Current Status
-- durably persist it to the ingestion stream, or
-- fail loudly with visible operational errors/backpressure.
+- Active subscriber counts already exist on channel profile pages.
+- Backend active count logic counts distinct subscribers where `expires_at > now()`.
+- Gifted active count counts distinct subscribers where `expires_at > now()` and `is_gift = 1`.
+- Backend public subscriber list and export endpoints have been implemented.
+- Channel profile frontend exposes the active and gifted subscriber counts as modal triggers.
+- The modal supports paginated viewing plus JSON, CSV, and TXT download options.
+- Streak/month count remains intentionally omitted because the stored webhook data does not contain a
+ reliable source field.
-The system must never intentionally discard a reached event silently.
+## Product Goals
-The target delivery model is **at-least-once ingestion with idempotent writes**, not false exactly-once semantics. Duplicate delivery is acceptable internally if visible search/profile results remain deduplicated.
+- Let visitors inspect which captured subscribers are currently active for a channel.
+- Keep channel profile pages compact by opening the subscriber list in a modal.
+- Make the active subscriber count itself discoverable and actionable.
+- Let visitors download the full active subscriber list as JSON, CSV, or a readable TXT report.
+- Keep UI copy understandable for normal visitors; do not mention webhook internals in public empty
+ states.
+- Preserve the existing dark, dense, professional visual system.
-## Current Risk
+## Non-Goals
-The audit found these ingestion risks:
+- No attempt to reconstruct subscribers from before Kick Logs started capturing subscription events.
+- No admin-only subscriber management UI.
+- No public account system.
+- No XLSX/PDF export for this feature.
+- No display of subscription streak/month count unless Kick payload data is explicitly available and
+ persisted later.
+- No database migration just to infer streak/month count.
+- No new landing page or large marketing-style section.
-- `bufferedRawWriter.Submit` can drop the oldest event or the incoming event when the memory buffer is full.
-- ClickHouse flush retries can eventually drop a whole batch after the retry limit.
-- `RunOnce` can increment stored counters after a memory submit, before durable storage is guaranteed.
-- The parser can discard incomplete `ChatMessageEvent` payloads before raw payload archival.
-- Some old SQLite queue/claim abstractions still make the data path harder to reason about.
+## Data Source
-These are acceptable only as temporary implementation history. They are not acceptable as the final architecture.
+Use ClickHouse table `channel_subscription_periods`.
-## Target Runtime Shape
+Relevant stored fields already available:
-The production stack should contain these services:
+- `followed_channel_id`
+- `channel_slug`
+- `channel_display_name`
+- `subscriber_kick_user_id`
+- `subscriber_username`
+- `subscriber_slug`
+- `subscriber_profile_image_url`
+- `gifter_kick_user_id`
+- `gifter_username`
+- `gifter_slug`
+- `gifter_profile_image_url`
+- `is_gift`
+- `started_at`
+- `expires_at`
+- `event_type`
-- `nats`: NATS JetStream with file storage and a persistent Docker volume.
-- `clickhouse`: long-lived event, message, analytics, and subscription data.
-- `api`: HTTP API and admin/search/profile endpoints.
-- `listener`: Kick Pusher capture service. It subscribes to Kick and publishes raw events to JetStream.
-- `processor`: JetStream consumer service. It normalizes raw events and writes ClickHouse batches.
-- `web`: Next.js frontend.
+Active subscriber definition:
-Listener and processor are intentionally separate. Listener priority is staying connected to Kick and acknowledging JetStream publishes. Processor priority is batching, retrying, and writing to ClickHouse efficiently.
+- `expires_at > now()`
-## Storage Ownership
+The existing summary query does not check `started_at <= now()`. Keep list behavior aligned with the
+existing count unless a later bug fix intentionally changes both summary and list together.
-### NATS JetStream
+## Streak And Month Count Decision
-JetStream owns temporary durable backlog for unprocessed raw chat events.
+Do not show month/streak data in the UI or export for now.
-It must not become a long-term analytics database. Acked events should leave the backlog according to stream retention.
+Reasoning:
-### ClickHouse
+- Current Kick webhook payload handling does not persist an explicit streak/month field.
+- Deriving a "registered month count" from captured periods can be misleading because the app may
+ not have historical periods from before tracking began.
+- Public UI and downloaded files should avoid presenting inferred values as subscription truth.
-ClickHouse owns the durable data plane:
+If Kick payloads later expose a reliable streak/month field, add it in a separate feature with a
+clear migration/storage plan.
-- `raw_kick_events`
-- `chat_messages`
-- raw event failure/terminal ignored audit data, if still needed
-- analytics tables/views
-- subscription period data
+## Backend API
-ClickHouse is the source for search, user profile pages, channel profile pages, analytics, and admin data size summaries.
+### Paginated Subscriber List
-### SQLite
+Endpoint:
-SQLite owns control-plane state only:
-
-- admin users
-- followed channels
-- sender profile cache
-- retention settings
-- worker/processor heartbeat rows
-- webhook subscription registry and webhook inbox state
-- data migration metadata
+```text
+GET /channels/{slug}/subscribers?limit=50&offset=0&gift_only=false
+```
-SQLite must not be used as the live chat raw-event queue after this plan is complete.
+Access:
-Existing `raw_event_queue` and `raw_event_claims` tables may remain for compatibility during migration, but they must not be active ingestion dependencies.
+- Public.
-## JetStream Design
+Purpose:
-Initial stream design:
+- Used by the channel profile modal.
+- Returns only the requested page of active subscribers.
-- Stream name: `KICK_RAW_EVENTS`
-- Subject: `kick.raw.chat`
-- Storage: file
-- Retention: backlog/work-queue style retention for unacked events
-- Consumer: durable pull consumer for processor workers
-- Ack policy: explicit ACK
-- Redelivery: enabled through ack wait / max delivery policy
+Query params:
-JetStream must not be configured to silently discard old messages under normal pressure. If stream size limits are hit, that is a critical operational problem, not an acceptable data-loss policy.
+- `limit`: default `50`, max `100`.
+- `offset`: default `0`.
+- `gift_only`: optional boolean. When true, return only active gifted subscribers.
-Recommended raw event envelope:
+Suggested response:
```json
{
- "raw_event_id": "deterministic-or-generated-id",
- "kick_message_id": "message-id-if-present",
- "event_name": "App\\Events\\ChatMessageEvent",
- "pusher_channel": "chatrooms.123.v2",
- "followed_channel_id": 1,
- "channel_slug": "example",
- "kick_channel_id": 123,
- "kick_chatroom_id": 456,
- "received_at": "2026-06-02T12:00:00Z",
- "body": {}
+ "channel_slug": "nuriben",
+ "items": [
+ {
+ "subscriber_kick_user_id": 123456,
+ "username": "example_user",
+ "slug": "example-user",
+ "profile_image_url": "https://...",
+ "is_gift": true,
+ "gifter_kick_user_id": 987654,
+ "gifter_username": "gift_sender",
+ "gifter_slug": "gift-sender",
+ "gifter_profile_image_url": "https://...",
+ "started_at": "2026-05-17T06:05:00Z",
+ "expires_at": "2026-06-16T06:05:00Z"
+ }
+ ],
+ "count": 248,
+ "limit": 50,
+ "offset": 0
}
```
-Idempotency key order:
-
-1. Kick message id, when present.
-2. Deterministic hash of stable raw event fields when Kick message id is missing.
-3. Generated id only for event types where no natural id exists and duplicate visibility is impossible or handled elsewhere.
-
-## Listener Capture Behavior
-
-The listener should:
-
-- resolve enabled followed channels,
-- subscribe to Kick Pusher channels,
-- avoid periodic reconnects,
-- reconnect only on websocket failure or actual followed-channel set changes,
-- publish every reached `ChatMessageEvent` raw payload to JetStream,
-- wait for JetStream PubAck before counting the event as captured,
-- surface publish failure as an operational failure,
-- avoid memory-buffer drop policies entirely.
-
-The listener should not require all normalized message fields before durable capture. Raw capture happens first; validation and terminal invalid classification happen in the processor.
-
-Listener metrics should include:
-
-- websocket connected status,
-- subscribed channel count,
-- last Kick event received time,
-- last JetStream publish ack time,
-- publish failure count,
-- current stream/backlog health if cheaply available.
-
-## Processor Behavior
-
-The processor should:
-
-- pull batches from the durable JetStream consumer,
-- batch insert raw payloads into ClickHouse,
-- normalize valid chat messages,
-- batch insert normalized `chat_messages`,
-- record terminal invalid/ignored events when the raw payload cannot become a visible message,
-- ACK JetStream messages only after required durable writes succeed,
-- avoid per-success attempt bloat,
-- rely on redelivery for transient ClickHouse failures.
-
-Terminal invalid events should be ACKed only after their terminal status is durable enough for diagnosis. Transient failures must remain unacked so JetStream can redeliver them.
-
-## Idempotency Requirements
-
-At-least-once delivery means duplicate processing can happen. The visible product must remain stable.
-
-Required behavior:
+### Full Export
-- duplicate raw delivery must not create duplicate visible chat rows,
-- `/search` results must not show duplicate messages,
-- `/users/{slug}` and `/channels/{slug}` timelines must not show duplicate messages,
-- analytics should count unique messages according to the chosen message identity,
-- retries and processor restarts must be safe.
+Endpoint:
-Implementation options:
+```text
+GET /channels/{slug}/subscribers/export?gift_only=false&format=txt
+```
-- deterministic `raw_event_id`,
-- ClickHouse `ReplacingMergeTree` or equivalent read-side dedupe,
-- query-time `argMax`/latest row selection where needed,
-- strict `kick_message_id`-based message identity for visible `chat_messages`.
+Access:
-The chosen approach must be tested with redelivery scenarios.
+- Public.
-## Operations And Admin Visibility
+Purpose:
-Admin Operations should show the new ingestion health clearly:
+- Downloads the full active subscriber list.
+- Supported formats:
+ - `json`
+ - `csv`
+ - `txt`
-- listener heartbeat,
-- processor heartbeat,
-- JetStream stream pending count,
-- consumer pending count,
-- ack-pending count,
-- redelivery count,
-- oldest pending event age,
-- ClickHouse latest raw event time,
-- ClickHouse latest visible message time,
-- processor insert failure count.
+Response headers:
-Old SQLite raw queue tables should not be presented as the active ingestion queue once JetStream is live. If they remain visible, they must be marked as legacy/internal.
+- TXT:
+ - `Content-Type: text/plain; charset=utf-8`
+ - `Content-Disposition: attachment; filename="{channel_slug}-active-subscribers.txt"`
+- CSV:
+ - `Content-Type: text/csv; charset=utf-8`
+ - `Content-Disposition: attachment; filename="{channel_slug}-active-subscribers.csv"`
+- JSON:
+ - `Content-Type: application/json; charset=utf-8`
+ - `Content-Disposition: attachment; filename="{channel_slug}-active-subscribers.json"`
-Any event-drop metric should be treated as critical. A non-zero value means a bug or infrastructure failure, not expected operation.
+Suggested text output:
-## Migration And Cutover
+```text
+Kick Logs Aktif Abone Listesi
+
+Kanal: nuriben
+Liste: Tüm aktif aboneler
+Oluşturulma: 14.06.2026 01:15
+Toplam: 248
+
+1. ID: 123456
+ Kullanıcı: example_user
+ Başlangıç: 17.05.2026 06:05
+ Bitiş: 16.06.2026 06:05
+ Tür: Hediye
+ Hediye eden: gift_sender
+```
-Historical ClickHouse data must not be deleted or rewritten as part of the first JetStream cutover.
+Gift-only TXT export should change `Liste` to `Hediye aktif aboneler`.
-Existing tables remain queryable:
+Empty export should still download a useful file/payload with total `0` and no item rows.
-- `chat_messages`
-- `raw_kick_events`
-- `raw_event_attempts`
-- subscription-related tables
+## Backend Query Design
-Cutover strategy:
+The list must avoid duplicate subscribers.
-1. Add NATS JetStream service and volume.
-2. Add publisher/consumer ports and infrastructure implementations.
-3. Add processor service while keeping public API unchanged.
-4. Route listener raw capture through JetStream.
-5. Verify processor writes ClickHouse batches and search/profile results stay unchanged.
-6. Disable old memory buffered writer and SQLite raw queue usage in live ingestion.
-7. Leave old queue tables in place until a later cleanup issue.
+Expected behavior:
-Short deployment downtime is acceptable if explicitly chosen, but normal runtime must not intentionally disconnect from Kick on a timer.
+- A subscriber appears once in the current active list.
+- If multiple active periods exist for the same subscriber, choose the latest period by
+ `started_at DESC`, then `expires_at DESC`.
+- `gift_only=true` filters rows where the selected active period is gifted.
+- Sort public list by `started_at DESC`, then `subscriber_username ASC`.
+- Return a total count for the current filter.
-Old `raw_event_queue` rows should be handled one of these ways:
+Implementation approach:
-- drain them before enabling JetStream-only live ingestion,
-- migrate them once into JetStream,
-- or keep them as legacy rows if they are already terminal/processed and no longer needed.
+- Add a domain model for active channel subscribers.
+- Extend `SubscriptionPeriodRepository` port with:
+ - paginated list method,
+ - full export/list method or an unbounded method with a safe export cap if needed.
+- Add ClickHouse repository methods using query-time aggregation/windowing.
+- Keep the query readable first; optimize later only if production data requires it.
-The chosen path must be documented before production deploy.
+## Rate Limiting
-## Earlier Issue 23 Work
+Add a public rate-limit policy for subscriber export.
-The earlier branch work remains useful as preliminary hardening:
+Recommended:
-- sender profile writes became less critical to message ingestion,
-- sender profile cache writes were throttled,
-- processed/terminal queue cleanup was clarified,
-- webhook inbox retention was added,
-- admin copy around operational tables was improved.
+- `GET /channels/{slug}/subscribers/export`
+- IP key
+- similar to message export, e.g. 3 requests per 60 seconds with burst 2
-That work does not replace the JetStream ingestion cutover. The final architecture must remove intentional chat-event drops from the live path.
+Paginated list calls can reuse the existing profile/analytics public limits only if matching remains
+clear. If not, add a dedicated `channel-subscribers` policy with a moderate limit.
-## Implementation Phases
+## Frontend UX
-### Phase 1 - Plan And Context
+Location:
-- Replace this implementation plan with the JetStream durable ingestion plan.
-- Update context docs only where they describe old intentional reconnect/drop behavior as acceptable.
-- Keep this phase docs-only.
+- Channel profile page: `/channels/{slug}`.
-Exit criteria:
+Modal triggers:
-- The plan clearly states NATS JetStream as the target hot path.
-- SQLite is documented as control-plane only.
-- No runtime code changes are included in this phase unless explicitly requested.
+- `AKTİF ABONE` stat cell opens the modal for all active subscribers.
+- `HEDİYE ABONE` stat cell opens the same modal with `gift_only=true`.
-### Phase 2 - NATS Foundation
+Modal behavior:
-- Add NATS Go dependency.
-- Add `nats` service to `compose.yaml` with persistent JetStream volume.
-- Add environment/config values for NATS URL, stream name, subject, consumer name, ack wait, batch size, and fetch timeout.
-- Create application ports for raw event publishing, raw event consuming, and stream stats.
-- Add infrastructure package for JetStream connection, stream setup, publisher, consumer, and stats.
-- Keep listener behavior unchanged until the publisher is tested behind an interface.
+- Title reflects the selected mode:
+ - `Aktif aboneler`
+ - `Hediye aktif aboneler`
+- First open fetches `limit=50&offset=0`.
+- `Daha fazla yükle` fetches the next page.
+- The download button opens a compact export menu, matching the search page export pattern.
+- Export menu options:
+ - JSON
+ - CSV
+ - TXT
+- Selecting an option downloads the full list for the same modal mode.
+- Modal can be closed by close button, Escape, or backdrop click using the existing dialog pattern.
-Exit criteria:
+List row content:
-- App can create/verify the stream and durable consumer.
-- Unit tests cover config defaults and publisher/consumer construction with fakes where possible.
-- Docker Compose can start NATS without breaking existing services.
+- Subscriber avatar when available; otherwise a compact fallback initial.
+- Subscriber username.
+- Subscriber username links to the app user profile when slug is available.
+- Gift badge when `is_gift=true`.
+- Gifter username when available.
+- Start date.
+- Expiry date.
-### Phase 3 - Raw Event Envelope
+Do not show:
-- Introduce a stable raw event envelope type.
-- Preserve raw Pusher payload before strict message normalization.
-- Make incomplete `ChatMessageEvent` payloads captureable.
-- Move invalid/ignored classification to processor logic.
-- Define deterministic identity rules for raw events and visible chat messages.
+- webhook terminology,
+- inferred month count,
+- inferred streak count,
+- internal event IDs.
-Exit criteria:
+Empty state:
-- Tests prove incomplete chat payloads are not discarded before durable capture.
-- Message identity fallback rules are covered by tests.
-- Existing valid message parsing behavior remains compatible.
-
-### Phase 4 - Listener Publishes To JetStream
+```text
+Bu kanal için henüz aktif abonelik kaydı yok.
+```
-- Replace live listener memory submission with JetStream publish.
-- Wait for PubAck before incrementing capture counters.
-- Treat publish failure as an error that triggers visible reconnect/backoff behavior.
-- Keep channel resync logic based on actual channel set changes, not periodic forced reconnect.
-- Remove or disable memory buffer drop metrics in the listener path.
+## Download UX
-Exit criteria:
+The modal download action should follow the search page export pattern:
-- Listener tests prove events are counted only after publish ack.
-- Publish failure does not silently drop events.
-- No configured buffer-full policy can discard a reached chat event.
+- A square/icon download button opens an export menu.
+- The menu contains JSON, CSV, and TXT options.
+- Clicking outside closes the menu.
+- Selecting an option starts the download and closes the menu.
-### Phase 5 - Processor Service
+Behavior:
-- Add `cmd/processor`.
-- Pull JetStream messages in batches.
-- Insert raw events into ClickHouse in batches.
-- Normalize valid messages and insert `chat_messages` in batches.
-- Write terminal invalid/ignored records when needed.
-- ACK only after required durable writes succeed.
-- Do not ACK transient ClickHouse failures.
+- Each option starts a browser download from the export endpoint with the selected `format`.
+- The button should not require loading the full list into the modal first.
+- Download uses the current modal mode:
+ - all active subscribers,
+ - gift-only active subscribers.
-Exit criteria:
+TXT should be the most human-readable report format. JSON and CSV are provided for users who want to
+reuse the list elsewhere.
-- Processor tests cover success, transient failure/redelivery, and terminal invalid handling.
-- Batch insert behavior remains available under load.
-- Processor can run independently from listener.
+## Implementation Phases
-### Phase 6 - ClickHouse Idempotency
+### Phase 1 - Plan And Context
-- Review `raw_kick_events` and `chat_messages` table engines/keys.
-- Add deterministic dedupe behavior for visible messages.
-- Ensure retries/redeliveries do not create duplicate search/profile rows.
-- Keep existing API response shapes unchanged.
+- Replace active implementation plan with this active subscriber list plan.
+- Update context docs with the new feature decision.
+- Do not implement runtime code in this phase unless explicitly requested.
Exit criteria:
-- Tests prove duplicate redelivery does not create duplicate visible messages.
-- Search, user profile, channel profile, and analytics queries remain compatible.
-- Existing production data can continue to be queried.
-
-### Phase 7 - Remove Old Hot Path Usage
-
-- Stop writing live chat events to SQLite `raw_event_queue`.
-- Stop using `raw_event_claims` for live chat ingestion.
-- Remove or deprecate `bufferedRawWriter` drop behavior from active listener code.
-- Update loadgen and internal wiring to exercise JetStream capture + processor.
+- `docs/implementation_plan.md` contains only this active feature plan.
+- Previous request-form implementation plan content is removed from the active plan.
+- Context docs mention the no-streak/no-month-count decision.
+
+### Phase 2 - Backend List And Export API
+
+- Add domain response models for active channel subscribers.
+- Extend `SubscriptionPeriodRepository` port.
+- Implement ClickHouse paginated list query.
+- Implement ClickHouse full export query or safe full-list method.
+- Add public routes:
+ - `GET /channels/{slug}/subscribers`
+ - `GET /channels/{slug}/subscribers/export`
+- Add schemas/mappers for JSON response.
+- Add JSON, CSV, and TXT export formatters.
+- Add focused backend tests for:
+ - active subscribers only,
+ - gift-only filtering,
+ - duplicate subscriber collapse,
+ - pagination,
+ - JSON, CSV, and TXT export formats,
+ - missing channel behavior.
Exit criteria:
-- Live chat ingestion no longer depends on SQLite queue tables.
-- No active code path intentionally drops chat events after they reach the listener.
-- Legacy tables are either unused or clearly marked for later cleanup.
-
-### Phase 8 - Admin Operations Update
-
-Status: implemented in this branch.
-
-- Add JetStream and processor health to admin Operations.
-- Show backlog size, ack-pending count, redelivery count, oldest pending age, and processor heartbeat.
-- Remove misleading active-queue wording for old SQLite raw queue tables.
-- Keep failed/terminal raw event visibility where useful.
+- Public list endpoint returns stable JSON.
+- Public export endpoint downloads JSON, CSV, and readable TXT formats.
+- Existing subscription summary behavior is not broken.
+
+### Phase 3 - Frontend Modal
+
+- Add channel subscriber API helpers.
+- Add TypeScript response types.
+- Make `AKTİF ABONE` and `HEDİYE ABONE` stat cells clickable when a channel profile is ready.
+- Add subscriber list modal using the existing dialog pattern.
+- Add first-page loading, load-more, empty, and error states.
+- Add download menu with JSON, CSV, and TXT options.
+- Add frontend tests for:
+ - clicking active count opens all-active modal,
+ - clicking gifted count opens gift-only modal,
+ - empty state copy,
+ - load-more behavior,
+ - export link/button target.
Exit criteria:
-- Admin users can understand whether ingestion is capturing, backlogged, redelivering, or failing.
-- The UI does not imply SQLite is still the live chat queue.
-
-### Phase 9 - Documentation And Verification
+- Visitors can inspect active subscribers without leaving the channel page.
+- Modal is responsive and does not render huge lists at once.
+- Visitors can download the full active subscriber list in JSON, CSV, or TXT.
-Status: documentation updated; verification passed in this branch.
+### Phase 4 - Docs And Verification
-- Update architecture docs, context docs, README operational notes, and deployment notes.
-- Document NATS volume backup/restore expectations.
-- Document production cutover and rollback steps.
-- Run backend tests, frontend checks, formatting, and Docker Compose smoke tests.
+- Update design docs for clickable stats and subscriber modal.
+- Update context docs with completed implementation details.
+- Run backend tests.
+- Run frontend tests.
+- Run typecheck, lint, build, and formatting checks.
Exit criteria:
- CI-relevant checks pass.
-- Compose smoke proves listener -> NATS -> processor -> ClickHouse works.
-- Load test proves burst traffic increases backlog instead of dropping events, then drains.
-
-## Test Plan
-
-Required unit tests:
-
-- incomplete `ChatMessageEvent` is captured before validation,
-- listener counts an event only after PubAck,
-- publish failure is not treated as successful capture,
-- processor ACKs only after raw and visible writes succeed,
-- transient ClickHouse failure causes redelivery,
-- terminal invalid event becomes durable diagnostic state and then ACKs,
-- duplicate redelivery does not duplicate visible chat messages.
-
-Required integration or smoke tests:
-
-- NATS JetStream starts through Docker Compose,
-- stream and durable consumer are created,
-- listener can publish a sample raw event,
-- processor consumes and writes ClickHouse rows,
-- `/search` can read the inserted message,
-- burst/loadgen shows zero intentional drops and eventual backlog drain.
-
-Required production checks:
-
-- backup current volumes before cutover,
-- deploy NATS volume,
-- verify listener heartbeat,
-- verify processor heartbeat,
-- verify JetStream backlog metrics,
-- verify latest raw event time advances,
-- verify latest visible message time advances,
-- verify existing historical search still works.
-
-## Out Of Scope
-
-- Changing public API response bodies.
-- Redesigning frontend search/profile pages.
-- Deleting historical ClickHouse data.
-- Removing old SQLite queue tables immediately.
-- Building multi-node leader election for listeners.
-- Reworking webhook subscription architecture.
-- Storing long-term raw history in NATS.
+- Documentation reflects the new feature.
+- No unrelated files are included.
+
+## Manual Test Plan
+
+1. Start Docker Compose.
+2. Visit `/channels/{slug}` for a channel with captured subscription periods.
+3. Click `AKTİF ABONE`.
+4. Confirm the modal lists active subscribers.
+5. Click `Daha fazla yükle` when more than one page exists.
+6. Close and reopen from `HEDİYE ABONE`.
+7. Confirm only gifted active subscribers are shown.
+8. Click the download button.
+9. Confirm the export menu shows JSON, CSV, and TXT options.
+10. Download each format and confirm the files include channel name, generated time, total, and
+ subscriber rows where that format supports those fields.
+11. Visit a channel with no active subscriptions and confirm the public empty state copy.
+
+## Open Decisions
+
+No blocking decisions remain.
+
+Non-blocking implementation details:
+
+- exact modal row density,
+- exact file name pattern,
+- exact export date formatting,
+- whether list export should have a hard maximum row cap later.
diff --git a/docs/operations/clickhouse_logging.md b/docs/operations/clickhouse_logging.md
new file mode 100644
index 0000000..b91a8cb
--- /dev/null
+++ b/docs/operations/clickhouse_logging.md
@@ -0,0 +1,127 @@
+# ClickHouse Logging
+
+## Policy
+
+Routine query/profiling history is disabled in the Compose deployment. Application SELECT/INSERT
+queries continue to work, but they no longer produce historical system-log rows. This avoids
+unbounded diagnostic storage and background merges on the small VPS.
+
+- `clickhouse/config.d/logging.xml` removes the query, thread, view, trace, text, part, metric,
+ asynchronous metric, error-counter, processor-profile, asynchronous-insert, and OpenTelemetry
+ log collectors.
+- `clickhouse/users.d/logging.xml` disables query logging and sampling in the `default` profile,
+ also used by the Docker-created `CLICKHOUSE_USER`. It does not change credentials or grants.
+- Server warnings/errors still go to the console and the existing server log files. Each file
+ stream rotates at 10 MiB with three archives; this is not a total disk quota.
+- Docker console logs have their own rotation: `json-file`, 10 MiB, three files.
+- Live diagnostics (`system.processes`, `system.metrics`, `system.events`, `system.errors`,
+ `system.parts`, `system.merges`) remain available. Crash and backup logs remain enabled.
+- Application history (`chat_messages`, `raw_kick_events`, `raw_event_attempts`, subscription
+ periods, requests) and all SQLite/NATS state are unchanged.
+
+Tradeoff: historical query analysis and ClickHouse dashboards backed by disabled log tables no
+longer collect new data. Diagnose performance with controlled `EXPLAIN`, client timings, live
+metrics, or temporarily re-enable the required collector during an investigation.
+
+## Deploy
+
+After deploying these files, recreate ClickHouse to apply the new mounts and Docker log rotation.
+A plain `docker compose restart` does not add mounts or change the logging driver configuration.
+There is a short API outage. The listener can continue publishing to JetStream during this window;
+monitor queue capacity and keep the maintenance window short.
+
+Run from the repository root on the VPS:
+
+```bash
+docker compose config --quiet
+docker compose stop api processor
+docker compose up -d --no-deps --force-recreate --wait clickhouse
+# Continue only after ClickHouse is healthy.
+docker compose start api processor
+docker compose ps
+docker compose logs --tail=50 clickhouse api processor
+```
+
+Do not use `down -v`, volume pruning, or data-directory deletion. No application schema migration
+or historical message rewrite is required.
+
+## Verify
+
+Define this helper in the same Bash terminal. It uses the container's existing credentials without
+printing them or requiring a password in shell history:
+
+```bash
+ch() {
+ docker compose exec -T clickhouse sh -c '
+ exec clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" \
+ --receive_timeout 3600 --send_timeout 3600 --max_execution_time 0 "$@"
+ ' sh "$@"
+}
+
+ch --query "SELECT 1"
+ch --query "
+ SELECT name, value FROM system.settings
+ WHERE name IN (
+ 'log_queries', 'log_query_threads', 'log_query_views', 'log_processors_profiles',
+ 'query_profiler_real_time_period_ns', 'query_profiler_cpu_time_period_ns'
+ ) ORDER BY name FORMAT PrettyCompact
+"
+```
+
+All six values should be zero. Also confirm the merged server configuration has no query-log
+collector (the following command should print nothing):
+
+```bash
+docker compose exec -T clickhouse clickhouse extract-from-config \
+ --config-file=/etc/clickhouse-server/config.xml --key=query_log.database --try
+```
+
+Exercise a search and inspect processor health. On an existing installation, log tables can still
+exist with old rows: presence alone does not mean collection is enabled. Compare row counts before
+and after a test query and `SYSTEM FLUSH LOGS`; disabled collectors should not add rows. On a fresh
+volume the disabled log tables normally do not exist.
+
+## Optional Existing History Cleanup
+
+Disabling collectors does **not** delete old history. First inspect the largest system log tables:
+
+```bash
+ch --query "
+ SELECT table, sum(rows) AS rows, formatReadableSize(sum(bytes_on_disk)) AS size
+ FROM system.parts
+ WHERE database = 'system' AND active
+ AND (endsWith(table, '_log') OR match(table, '_log_[0-9]+$'))
+ GROUP BY table ORDER BY sum(bytes_on_disk) DESC FORMAT PrettyCompact
+"
+```
+
+Only after verifying the new configuration and deciding that this diagnostic history is no longer
+needed, the following **irreversibly removes old rows from the explicitly named system logs**.
+Export required diagnostics first. It does not touch any application table:
+
+```bash
+for table in query_log query_thread_log query_views_log trace_log text_log part_log \
+ metric_log asynchronous_metric_log error_log processors_profile_log \
+ asynchronous_insert_log opentelemetry_span_log; do
+ ch --query "TRUNCATE TABLE IF EXISTS system.${table} SYNC" || break
+done
+```
+
+Older versions may have renamed log tables such as `query_log_0`. They are intentionally not
+automatically selected for deletion: inspect them and explicitly truncate only reviewed diagnostic
+tables. Physical space reclamation may lag while files are still referenced. This procedure does
+not remove pre-existing text-log archives or orphaned Docker volumes; inspect their exact paths
+separately and never prune application volumes to reclaim logs.
+
+## Rollback / Temporary Diagnostics
+
+Remove only the two logging XML mounts from Compose and recreate ClickHouse using the same
+maintenance sequence above. Keep the memory configuration and all data volumes. This restores
+image-default logging, which can grow rapidly, so use it only for a bounded investigation. Already
+truncated history cannot be restored without a backup.
+
+## References
+
+- [ClickHouse 24.8 server configuration](https://github.com/ClickHouse/ClickHouse/blob/v24.8.14.39-lts/programs/server/config.xml)
+- [System-log collector creation in 24.8](https://github.com/ClickHouse/ClickHouse/blob/v24.8.14.39-lts/src/Interpreters/SystemLog.cpp)
+- [Docker user profile initialization in 24.8](https://github.com/ClickHouse/ClickHouse/blob/v24.8.14.39-lts/docker/server/entrypoint.sh)
diff --git a/docs/operations/public_analytics.md b/docs/operations/public_analytics.md
new file mode 100644
index 0000000..5812efb
--- /dev/null
+++ b/docs/operations/public_analytics.md
@@ -0,0 +1,130 @@
+# Public Analytics Performance
+
+## Scope
+
+Issue #27's owner-approved first pass covers the homepage, identity directories and profile loading
+visuals. Message search/export and profile all-time analytics remain unchanged. No message or
+subscription history is deleted, re-keyed, or backfilled. The later SQL-only profile improvement
+from issue #29 preserves the all-time contract; its implementation and measurements are below.
+
+## Runtime
+
+- `GET /analytics/homepage` reads a memory snapshot, not ClickHouse. It covers today and the preceding
+ 13 UTC calendar days through `as_of`; late arrivals appear on the next successful refresh.
+- The API refreshes once at startup if needed, then every 15 minutes. It uses the existing `FINAL`
+ queries, preserving replacement/deduplication semantics and computing period-wide distinct users
+ directly (not by summing daily unique counts).
+- Five queries run sequentially: overview, volume, top channels, top users, top emotes. Each is limited
+ to two threads, 384 MiB and 15 seconds; the whole refresh has a 90-second deadline. This avoids
+ visitor-driven concurrency, but periodic aggregate cost still depends on recent traffic volume.
+- A complete result is published atomically. Refresh errors retain the previous result and retry
+ with 1, 2, 4, 8, then 15-minute delays. No partial snapshot is published.
+- `homepage-analytics-v1.json` beside `SQLITE_PATH` is an atomic, size-bounded, versioned cache. The
+ default Docker data volume preserves it across API recreation. Missing, corrupt, future-dated,
+ incompatible or older-than-24-hour snapshots are ignored and rebuilt. A file-write failure does
+ not discard a valid memory snapshot.
+- Ready responses include actual dates and `stale`; stale data is at most 24 hours old and may belong
+ to the previous UTC day. HTTP 202 + `Retry-After: 5` means no valid snapshot exists yet. Browser
+ retries are bounded; after readiness there is no homepage polling.
+- The scheduler is per API process. Multiple API replicas would need shared refresh coordination;
+ this change targets the existing single-API Compose deployment.
+
+## Directory Coverage
+
+`GET /directory/users` reads `sender_profiles`; `/directory/channels` reads `followed_channels`.
+Migration 9 indexes normalized name/slug expressions. Prefix queries never read chat messages and
+return bounded cursor pages without counts. ASCII Kick identity matching is case-insensitive;
+SQLite's built-in case folding is not full Unicode folding. `_` and `-` normalize to the same form.
+
+Retained inactive channels and old cached users remain discoverable, irrespective of message age.
+This is not a historical identity backfill: a sender present only in ClickHouse but missing from the
+best-effort SQLite metadata cache is absent from directory results. Profile and message lookup
+contracts remain available as before. Assess metadata coverage before any later backfill project.
+
+## Rollout And Verification
+
+1. Back up the existing SQLite and ClickHouse volumes as usual. Do not run `down -v`.
+2. Rebuild/recreate API and web from the same revision: `docker compose up -d --build api web`.
+3. API startup applies migration 9 to existing SQLite metadata; allow the one-time index build.
+4. Check `/analytics/homepage`: expect 200 ready, or 202 while the first snapshot is prepared.
+ Verify 14 ordered daily bins, matching `start`/`end`/`as_of`, and `Son 14 gün` labels on `/`.
+ The UI intentionally omits the explicit date range and update timestamp.
+5. Check prefix search and pagination on `/users` and `/channels`; no `top-*` analytics request
+ should occur there. Check both profile loading layouts and all-time values after loading.
+6. Recreate only the API and verify a valid persisted snapshot remains available without waiting
+ for an aggregate refresh. Inspect warnings for refresh failures or unwritable cache storage.
+
+Unit/HTTP/frontend tests cover failure, retry, cancellation, pagination and snapshot boundaries.
+The opt-in ClickHouse CI suite verifies bounded dates and duplicate redelivery with real queries.
+Directory query-plan tests verify indexed ranges, not full metadata scans.
+
+Before claiming production acceptance, record p50/p95 response latency, peak memory and ingestion
+backlog under the same workload as the prior version. Small isolated fixtures prove behavior, not
+the performance of an 18-million-message VPS. The profile optimization below addresses one query
+pair, not every profile query. Metadata completeness/backfill and production load benchmarking
+remain separate follow-up work under issue #29.
+
+Rollback can redeploy the previous revision without deleting data. The extra SQLite indexes and
+homepage cache do not alter stored message/subscription rows; neither needs removal for rollback.
+
+## All-Time Profile Query Optimization
+
+User/channel profiles now request overview plus the top five counterpart channels/senders in a
+single ClickHouse query through the optional `ProfileSummaryRepository` port. `GROUP BY ... WITH
+TOTALS` returns both ranked rows and exact totals across every matching group before `LIMIT`.
+The native driver reads the totals after consuming the rows. One tuple `argMax` selects all identity
+metadata using the existing `(message_created_at, ingested_at, id)` ordering.
+
+The query retains `FINAL`, deleted-row filtering, exact distinct counts, nullable identity rules,
+slug matching, and ranking order. There is no recent-window restriction on profile summaries.
+Volume, top emotes, latest messages, UI, HTTP responses and profile cache behavior are unchanged.
+The older independent queries remain for other analytics consumers and as a fallback if the
+combined query fails or a repository does not implement the optional port. On failure this can
+cost one attempted combined query plus the two original queries; existing partial-result behavior
+is intentionally retained rather than redesigned in this SQL-only change.
+
+No migration, index, projection, backfill, or historical data rewrite is needed. Recreate only the
+API to deploy this change: `docker compose up -d --build --no-deps api`. Rolling back the API is
+sufficient to restore the previous query path.
+
+### Local Measurement
+
+Measured on 2026-10-02 against local ClickHouse 24.8, using all history before
+`2026-10-01T20:00:00Z`. This fixed cutoff excludes live arrivals from the comparison. The channel
+matched 379,367 messages; the user matched 8,567. Both complete result objects matched the old
+queries before measurement, including metadata and ranking order.
+
+| Summary query pair | Old median | Combined median | Old read rows | Combined read rows | Old read bytes | Combined read bytes |
+| ------------------ | ---------- | --------------- | ------------- | ------------------ | -------------- | ------------------- |
+| Channel | 1,040 ms | 807 ms | 2,800,248 | 1,400,124 | 501,009,881 | 297,725,542 |
+| User | 760 ms | 558 ms | 2,800,248 | 1,400,124 | 660,628,981 | 502,467,440 |
+
+These are medians of three benchmark runs with three iterations each, one query thread, a 256 MiB
+memory cap, 10-second execution limit and ClickHouse query cache disabled. Filesystem/page caches
+were not cleared. Concurrent local ingestion and Docker/network overhead affect timing. This is
+about 22%/26% less elapsed time for the changed pair and 50% fewer rows read, not a whole-page or
+production p95 claim. Remaining profile queries still scan history and require separate evidence
+before any further optimization.
+
+### Reproduce Safely
+
+From `apps/api-go`, point `CLICKHOUSE_ADDR`, `CLICKHOUSE_DATABASE`, `CLICKHOUSE_USERNAME` and
+`CLICKHOUSE_PASSWORD` at the dataset to inspect using the normal application configuration. Prefer
+a read-only database account. Set these additional variables (PowerShell example):
+
+```powershell
+$env:KICK_LOGS_PROFILE_BENCHMARK_CHANNEL = 'hype'
+$env:KICK_LOGS_PROFILE_BENCHMARK_SENDER = 'botrix'
+$env:KICK_LOGS_PROFILE_BENCHMARK_END = '2026-10-01T20:00:00Z'
+go test ./internal/infra/clickhouse -run '^$' -bench '^BenchmarkProfileSummaries$' -benchtime=3x -count=3 -v
+```
+
+Choose existing representative slugs and a fixed historical cutoff. The benchmark runs SELECTs
+only, does not migrate/insert, rejects empty profiles, and stops if old/new results differ. Run
+during a suitable maintenance/test window because both variants intentionally read history.
+Do not enable `KICK_LOGS_RUN_CLICKHOUSE_TESTS` or run the integration-test command against real
+application data; that separate suite inserts fixtures and belongs in an isolated database.
+
+Regression coverage includes empty profiles, data from 2020, identity fallbacks/nulls, renamed
+metadata, duplicate replacements, tombstones, ties, limits of 1/5/default, identical HTTP responses,
+unchanged cache hits and fallback after a combined-query error.
diff --git a/docs/project_plan.md b/docs/project_plan.md
index 757099d..3d0b165 100644
--- a/docs/project_plan.md
+++ b/docs/project_plan.md
@@ -11,7 +11,8 @@ The default runtime is Go + NATS JetStream + ClickHouse + SQLite:
- Go listener subscribes to Kick chat streams and publishes reached raw events to JetStream.
- Go processor consumes JetStream batches and writes raw events plus normalized messages to ClickHouse.
- NATS JetStream is the durable live chat ingestion backlog for issue #23.
-- ClickHouse stores chat messages, raw Kick events, exports, analytics, and profile aggregates.
+- ClickHouse stores chat messages, raw Kick events, exports, analytics, profile aggregates, and
+ public request-form history.
- SQLite stores admin users, followed channels, sender profile cache, retention settings,
heartbeats, webhook inbox/registry state, legacy queue compatibility tables, and migration
metadata.
@@ -40,7 +41,9 @@ docker compose up --build -d
- Render reply context and inline emotes in search results.
- Export filtered messages as JSON or CSV.
- Provide public analytics, user profile pages, and channel profile pages.
+- Provide a public request form for channel tracking requests and general feedback.
- Provide admin operations and data-management panels.
+- Let admins review, status-change, note, and archive public requests.
- Run locally through Docker Compose.
## Runtime Architecture
@@ -144,6 +147,7 @@ If the image fails, the UI falls back to emote name/token text.
## Public Pages
- `/`: compact public landing page with self-hosted positioning and analytics blocks.
+- `/request`: public request form for channel tracking requests and general feedback.
- `/search`: public historical message search.
- `/users/[slug]`: public sender profile with analytics and latest messages.
- `/channels/[slug]`: public channel profile with metadata, analytics, and latest messages.
@@ -162,6 +166,7 @@ convert `_` to `-`.
- create admin user when current user is `super_admin`
- operations health, storage growth, raw event status, and listener freshness
- retention settings and guarded cleanup preview/confirm flows
+- request-form review workflow with status, notes, and archive actions
Operations treats JetStream pending and ack-pending counts as the active live chat backlog. SQLite
raw-event queue depth is shown only as legacy/migration state. Processed raw-event history is read