From 7e1c0019ae4a96e737465b1d520dad7b54fef24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yavuz=20Selim=20=C5=9Een?= Date: Sat, 13 Jun 2026 22:59:08 +0300 Subject: [PATCH 01/20] docs(requests): plan public request form --- docs/implementation_plan.md | 572 +++++++++++++++++------------------- 1 file changed, 267 insertions(+), 305 deletions(-) diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 392c872..ed28701 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -1,424 +1,386 @@ -# Issue 23 - JetStream Durable Ingestion Plan +# User Request Form 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: a public request form that lets visitors send two kinds +of input to Kick Logs: -Issue 23 stays on branch `feat/issue-23-storage-hot-path-hardening`, but the implementation direction changes after the ingestion audit. +1. channel tracking requests, where a visitor asks for a Kick channel to be added to the tracked + channel list, +2. general feedback, where a visitor sends product ideas, bug reports, or other messages. -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 form will be available from the public site navigation through a `Talep` button. Submitted data +will be visible to admins in a single admin page with filters and archive/status workflows. -The implemented long-term fix moves the live chat ingestion hot path to NATS JetStream: +The feature should be built as a production-quality MVP: small surface area, clear validation, +spam-resistant public endpoint behavior, and no unnecessary CRUD complexity. -```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. - -If a Kick chat event reaches the listener process, the system must either: - -- durably persist it to the ingestion stream, or -- fail loudly with visible operational errors/backpressure. +## Product Goals -The system must never intentionally discard a reached event silently. +- Give visitors a clear way to request new tracked channels. +- Give visitors a clear way to send feedback or product requests. +- Keep both request types in one admin workflow. +- Let admins filter, review, status-change, note, and archive requests. +- Avoid storing this feature in SQLite because SQLite should stay focused on operational/control + data. +- Preserve the existing visual system: dark theme, compact professional layout, strict palette, and + no marketing-style page composition. -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. +## Non-Goals -## Current Risk +- No public account system. +- No email notifications in the first implementation. +- No hard delete workflow in the first implementation. +- No user-facing request tracking page. +- No CAPTCHA dependency unless rate limiting and lightweight bot checks prove insufficient. +- No changes to existing search, profile, prediction, webhook, or ingestion APIs beyond shared + navigation/layout usage. -The audit found these ingestion risks: +## Storage Decision -- `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. +Requests will be stored in ClickHouse, not SQLite. -These are acceptable only as temporary implementation history. They are not acceptable as the final architecture. +Reasoning: -## Target Runtime Shape +- The feature is append-heavy and audit-oriented. +- Admin listing needs filtering over historical product input. +- Request data is not runtime control-plane state. +- SQLite should remain focused on admin users, followed channels, webhook subscription registry, + heartbeat/operations state, and other small operational state. -The production stack should contain these services: +ClickHouse should be used with an append-only model. The implementation must avoid treating +ClickHouse like a row-update OLTP database. -- `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. +## Data Model -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. +### `user_requests` -## Storage Ownership +Stores the immutable public submission. -### NATS JetStream +Suggested fields: -JetStream owns temporary durable backlog for unprocessed raw chat events. +- `request_id`: stable unique id generated by the API. +- `type`: `channel_request` or `feedback`. +- `title`: short summary. +- `message`: visitor-provided explanation. +- `channel_slug`: nullable, normalized Kick channel slug for channel requests. +- `channel_display_name`: nullable, raw/display channel name if provided. +- `contact`: nullable visitor contact field. +- `ip_hash`: nullable hash of submitter IP for abuse investigation and rate-limit auditing. +- `user_agent_hash`: nullable hash of user agent. +- `created_at`: submission timestamp. -It must not become a long-term analytics database. Acked events should leave the backlog according to stream retention. +Validation: -### ClickHouse +- `type` must be one of the supported request types. +- `title` max length should be limited. +- `message` max length should be limited. +- `channel_slug` is required for `channel_request`. +- `channel_slug` should be normalized consistently with Kick profile/channel URL behavior. +- `contact` is optional and length-limited. +- Raw HTML must not be rendered back to admins as HTML. -ClickHouse owns the durable data plane: +### `user_request_events` -- `raw_kick_events` -- `chat_messages` -- raw event failure/terminal ignored audit data, if still needed -- analytics tables/views -- subscription period data +Stores admin workflow actions as append-only events. -ClickHouse is the source for search, user profile pages, channel profile pages, analytics, and admin data size summaries. +Suggested fields: -### SQLite +- `event_id`: stable unique id generated by the API. +- `request_id`: target request id. +- `event_type`: `status_changed`, `note_added`, or `archived`. +- `status`: nullable; used by `status_changed`. +- `note`: nullable admin note. +- `admin_id`: nullable/id of acting admin. +- `created_at`: event timestamp. -SQLite owns control-plane state only: +Supported statuses: -- admin users -- followed channels -- sender profile cache -- retention settings -- worker/processor heartbeat rows -- webhook subscription registry and webhook inbox state -- data migration metadata +- `new` +- `reviewing` +- `approved` +- `rejected` +- `done` +- `duplicate` -SQLite must not be used as the live chat raw-event queue after this plan is complete. +Archive behavior: -Existing `raw_event_queue` and `raw_event_claims` tables may remain for compatibility during migration, but they must not be active ingestion dependencies. +- There is no delete in the first implementation. +- Archiving is represented by an append-only `archived` event. +- Admin queries compute current archive state from the latest event history. -## JetStream Design +## Public Request Page -Initial stream design: - -- 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 - -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. - -Recommended raw event envelope: - -```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": {} -} -``` +Route: -Idempotency key order: +- `/request` -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. +Navigation: -## Listener Capture Behavior +- Add a `Talep` button to the public header. +- Desktop placement: between the existing right-side navigation actions, to the left of `Admin` and + near the GitHub icon according to the current header layout. +- Mobile placement: above the `Admin` action or before it in the mobile action stack. +- Use the same text on desktop and mobile: `Talep`. -The listener should: +Page behavior: -- 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 page contains one form with a two-option mode selector. +- Modes: + - `Kanal Talebi` + - `Geri Bildirim` +- Fields change based on the selected mode. +- Submit success should show a calm confirmation state without redirecting unless the UI naturally + benefits from it. +- Submit failure should show clear inline errors. -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. +Channel request fields: -Listener metrics should include: +- Kick channel name or slug. +- Short title or reason. +- Message/details. +- Optional contact. -- 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. +Feedback fields: -## Processor Behavior +- Title. +- Message/details. +- Optional contact. -The processor should: +UX rules: -- 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. +- Keep it compact and functional. +- Do not create a new landing page. +- Do not use large hero typography. +- Do not use decorative blur/glow/orb styling. +- Use existing form, button, and panel styling patterns. -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. +## Public API -## Idempotency Requirements +Endpoint: -At-least-once delivery means duplicate processing can happen. The visible product must remain stable. +- `POST /requests` -Required behavior: +Responsibilities: -- 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. +- Accept only the public request form payload. +- Validate and normalize fields. +- Apply public route rate limiting. +- Apply lightweight bot/spam checks. +- Insert the immutable request row into ClickHouse. +- Return a minimal success response with `request_id`. -Implementation options: +Suggested response: -- 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`. +```json +{ + "request_id": "..." +} +``` -The chosen approach must be tested with redelivery scenarios. +Security and abuse controls: -## Operations And Admin Visibility +- IP-based rate limiting through the existing rate-limit middleware. +- Honeypot field in the frontend payload. +- Reject submissions where the honeypot field is filled. +- Optional minimum client form lifetime field if implemented cleanly. +- Store hashed IP/user-agent only; do not store raw IP by default. +- Keep error messages useful but not overly detailed for abuse paths. -Admin Operations should show the new ingestion health clearly: +## Admin Request Management -- 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. +Admin route: -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. +- `/admin/requests` -Any event-drop metric should be treated as critical. A non-zero value means a bug or infrastructure failure, not expected operation. +Admin APIs: -## Migration And Cutover +- `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` -Historical ClickHouse data must not be deleted or rewritten as part of the first JetStream cutover. +Admin listing filters: -Existing tables remain queryable: +- request type, +- current status, +- archived/active, +- date range, +- text search across title/message/channel/contact where practical. -- `chat_messages` -- `raw_kick_events` -- `raw_event_attempts` -- subscription-related tables +Admin list row should show: -Cutover strategy: +- type, +- title, +- channel slug/name when present, +- current status, +- archived state, +- created date, +- latest admin action date when available. -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. +Admin detail should show: -Short deployment downtime is acceptable if explicitly chosen, but normal runtime must not intentionally disconnect from Kick on a timer. +- original request content, +- computed current status, +- archive state, +- event timeline, +- status change control, +- note form, +- archive action. -Old `raw_event_queue` rows should be handled one of these ways: +The admin page should be operational and dense, consistent with the rest of the admin dashboard. -- 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. +## Query Design -The chosen path must be documented before production deploy. +Because ClickHouse is append-only, admin reads should compute current request state from +`user_request_events`. -## Earlier Issue 23 Work +Expected computed values: -The earlier branch work remains useful as preliminary hardening: +- latest status per request, +- latest archive event per request, +- latest event time per request, +- note/event timeline for detail view. -- 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. +Implementation should prefer clear query structure over over-optimized SQL in the first MVP. -That work does not replace the JetStream ingestion cutover. The final architecture must remove intentional chat-event drops from the live path. +The first version may use query-time aggregation. If request volume becomes large later, a materialized +view or projection can be introduced in a separate issue. ## Implementation Phases ### Phase 1 - Plan And Context -- 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. +- Replace the active implementation plan with this request form plan. +- Update context docs to mention the new planned feature and storage decision. +- Do not implement runtime code in this phase unless explicitly requested. Exit criteria: -- 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. +- This file contains only the active request form plan. +- Old implementation plan content is removed from the active plan. +- Context docs identify ClickHouse append-only storage as the chosen approach. -### Phase 2 - NATS Foundation +### Phase 2 - Backend Domain And Storage -- 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. +- Add request and request-event domain models. +- Add ClickHouse migrations for `user_requests` and `user_request_events`. +- Add storage ports for creating public requests and reading/administering requests. +- Add ClickHouse repository implementations. +- Add focused repository tests. Exit criteria: -- 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. +- Migrations are deterministic. +- Insert and read queries are covered by tests. +- No SQLite table is introduced for this feature. -### Phase 3 - Raw Event Envelope +### Phase 3 - Public Submit API -- 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. +- Add public request creation use case. +- Add validation and normalization rules. +- Add `POST /requests`. +- Add rate-limit policy for the endpoint. +- Add honeypot handling. +- Add API tests for valid channel request, valid feedback, validation errors, honeypot rejection, and + rate-limit wiring where practical. Exit criteria: -- 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. +- Public requests can be inserted into ClickHouse. +- Invalid/spam-like payloads are rejected. +- Response shape is stable and minimal. -### Phase 4 - Listener Publishes To JetStream +### Phase 4 - Admin APIs -- 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. +- Add admin request listing use case. +- Add admin request detail use case. +- Add status-change, note, and archive use cases. +- Add admin API routes. +- Add tests for filtering, computed current status, archive behavior, and event timeline behavior. Exit criteria: -- 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. +- Admin can list active and archived requests. +- Admin can update workflow state without mutating the original request row. +- Admin can add notes and archive through append-only events. -### Phase 5 - Processor Service +### Phase 5 - Public Frontend -- 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. +- Add `/request` page. +- Add the `Talep` navigation button to public header desktop and mobile layouts. +- Build two-mode form UI. +- Wire submit flow to `POST /requests`. +- Add success and error states. +- Add tests for mode switching and successful submit behavior. Exit criteria: -- Processor tests cover success, transient failure/redelivery, and terminal invalid handling. -- Batch insert behavior remains available under load. -- Processor can run independently from listener. +- Visitors can submit both request types. +- The form matches existing visual rules. +- Header placement works on desktop and mobile. -### Phase 6 - ClickHouse Idempotency +### Phase 6 - Admin Frontend -- 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. +- Add `/admin/requests` page. +- Add request list with filters. +- Add request detail/timeline view. +- Add status, note, and archive controls. +- Add tests for filtering UI and admin actions. 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. +- Admins can review and manage requests from one page. +- Archived requests do not clutter the default active view. +- UI remains consistent with existing admin density and styling. -### Phase 7 - Remove Old Hot Path Usage +### Phase 7 - Documentation And Verification -- 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. +- Update README only if the public feature should be mentioned there. +- Update architecture/context docs. +- Run relevant backend tests. +- Run relevant frontend tests. +- Run typecheck and formatting checks. +- Confirm Docker Compose migrations work. 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 +- CI-relevant checks pass. +- Documentation reflects the new feature. +- No unrelated files or old task-plan content are included. -Status: implemented in this branch. +## Test Plan -- 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. +Backend: -Exit criteria: +- ClickHouse migration smoke test includes the new tables. +- Repository tests cover request insert and admin read projections. +- Use case tests cover validation and append-only status transitions. +- HTTP tests cover public submit and admin management endpoints. -- Admin users can understand whether ingestion is capturing, backlogged, redelivering, or failing. -- The UI does not imply SQLite is still the live chat queue. +Frontend: -### Phase 9 - Documentation And Verification +- Public page tests cover mode switch, field visibility, submit success, and validation display. +- Header tests cover `Talep` navigation visibility. +- Admin page tests cover filters, status changes, notes, archive action, and empty states. -Status: documentation updated; verification passed in this branch. +Manual: -- 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. +- Start Docker Compose. +- Submit a channel request from `/request`. +- Submit feedback from `/request`. +- Verify both appear in `/admin/requests`. +- Change status, add note, archive. +- Verify archive filter behavior. -Exit criteria: +## Open Decisions -- 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. +No blocking product decisions remain. -## Test Plan +Non-blocking copy/details may be adjusted during implementation: -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. +- exact field labels, +- exact status badge copy, +- exact admin filter layout, +- whether README should mention the request form. From d13e7e038b7503f6881fbc3ad4639a4a4c6ebe4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yavuz=20Selim=20=C5=9Een?= Date: Sat, 13 Jun 2026 23:30:18 +0300 Subject: [PATCH 02/20] feat(requests): add backend request workflow --- apps/api-go/cmd/api/main.go | 5 + apps/api-go/internal/domain/models.go | 72 ++++ .../internal/http/middleware/ratelimit.go | 8 + .../internal/http/routes/dependencies.go | 2 + apps/api-go/internal/http/routes/requests.go | 296 ++++++++++++++++ apps/api-go/internal/http/schemas/requests.go | 57 +++ apps/api-go/internal/http/server.go | 1 + .../internal/http/user_request_routes_test.go | 268 ++++++++++++++ .../infra/clickhouse/repositories_test.go | 87 +++++ .../infra/clickhouse/user_requests.go | 291 ++++++++++++++++ .../internal/infra/migrations/clickhouse.go | 38 ++ apps/api-go/internal/ports/storage.go | 8 + .../internal/usecase/requests/service.go | 327 ++++++++++++++++++ .../internal/usecase/requests/service_test.go | 186 ++++++++++ docs/architecture.md | 11 + docs/context/change_log.md | 19 + docs/context/decisions.md | 18 + docs/context/living_brain.md | 32 +- docs/context/recent_changes.md | 24 ++ docs/implementation_plan.md | 12 + docs/project_plan.md | 7 +- 21 files changed, 1767 insertions(+), 2 deletions(-) create mode 100644 apps/api-go/internal/http/routes/requests.go create mode 100644 apps/api-go/internal/http/schemas/requests.go create mode 100644 apps/api-go/internal/http/user_request_routes_test.go create mode 100644 apps/api-go/internal/infra/clickhouse/user_requests.go create mode 100644 apps/api-go/internal/usecase/requests/service.go create mode 100644 apps/api-go/internal/usecase/requests/service_test.go diff --git a/apps/api-go/cmd/api/main.go b/apps/api-go/cmd/api/main.go index 7a0974a..eec3574 100644 --- a/apps/api-go/cmd/api/main.go +++ b/apps/api-go/cmd/api/main.go @@ -31,6 +31,7 @@ import ( 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" ) @@ -147,15 +148,18 @@ func main() { var messageService *messagesusecase.Service var analyticsService *analyticsusecase.Service 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) profileService = profilesusecase.NewService(analyticsRepository, channelRepo, senderRepo) + requestService = requestsusecase.NewService(userRequestRepo) processorSvc := webhookprocessorusecase.NewService( logger, @@ -186,6 +190,7 @@ func main() { Profiles: profileService, Data: dataManagementService, KickSync: kickSyncService, + Requests: requestService, WebhookEvents: webhookEventRepo, WebhookVerifier: webhookVerifier, WebhookEventSubs: eventSubRepo, diff --git a/apps/api-go/internal/domain/models.go b/apps/api-go/internal/domain/models.go index 9e1d0a7..8a6e943 100644 --- a/apps/api-go/internal/domain/models.go +++ b/apps/api-go/internal/domain/models.go @@ -549,3 +549,75 @@ type ChannelSubscriptionSummary struct { ActiveGiftedCount int64 LatestEventAt time.Time } + +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/middleware/ratelimit.go b/apps/api-go/internal/http/middleware/ratelimit.go index f585407..f39e294 100644 --- a/apps/api-go/internal/http/middleware/ratelimit.go +++ b/apps/api-go/internal/http/middleware/ratelimit.go @@ -103,6 +103,14 @@ func DefaultPolicies(trustProxy bool, clientIPHeader string) []RateLimitPolicy { PeriodSeconds: 60, MaxBurst: 10, }, + { + Name: "public-requests", + Match: exactMatch("POST", "/requests"), + Key: ip, + PerPeriod: 5, + PeriodSeconds: 600, + MaxBurst: 2, + }, { Name: "analytics", Match: prefixMatch("GET", "/analytics/"), diff --git a/apps/api-go/internal/http/routes/dependencies.go b/apps/api-go/internal/http/routes/dependencies.go index f8e8a3e..db29369 100644 --- a/apps/api-go/internal/http/routes/dependencies.go +++ b/apps/api-go/internal/http/routes/dependencies.go @@ -14,6 +14,7 @@ import ( 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 { @@ -25,6 +26,7 @@ type Dependencies struct { Profiles *profilesusecase.Service Data *datamanagementusecase.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/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/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/server.go b/apps/api-go/internal/http/server.go index a1cc24a..1f0032d 100644 --- a/apps/api-go/internal/http/server.go +++ b/apps/api-go/internal/http/server.go @@ -22,6 +22,7 @@ func NewRouter(cfg config.Config, logger *slog.Logger, dependencySets ...routes. routes.RegisterMessageRoutes(mux, deps) routes.RegisterAnalyticsRoutes(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/infra/clickhouse/repositories_test.go b/apps/api-go/internal/infra/clickhouse/repositories_test.go index 0fa3612..b57e2b4 100644 --- a/apps/api-go/internal/infra/clickhouse/repositories_test.go +++ b/apps/api-go/internal/infra/clickhouse/repositories_test.go @@ -607,4 +607,91 @@ func TestClickHouseMigrationsAndRepositories(t *testing.T) { if summary.ActiveGiftedCount != 1 { t.Fatalf("ActiveGiftedCount = %d, want 1", summary.ActiveGiftedCount) } + + 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/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/ports/storage.go b/apps/api-go/internal/ports/storage.go index 907afd8..dd08954 100644 --- a/apps/api-go/internal/ports/storage.go +++ b/apps/api-go/internal/ports/storage.go @@ -140,6 +140,14 @@ type SubscriptionPeriodRepository interface { ActiveSummary(ctx context.Context, followedChannelID int64) (domain.ChannelSubscriptionSummary, 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 { Summary(ctx context.Context) (domain.DataManagementSummary, error) GetRetentionSettings(ctx context.Context) (domain.RetentionSettings, error) 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/docs/architecture.md b/docs/architecture.md index 1dceefa..9bfe804 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 @@ -180,6 +190,7 @@ POST /admin/webhooks/sync Public routes: +- `POST /requests` - `/messages` - `/messages/export` - `/analytics/*` diff --git a/docs/context/change_log.md b/docs/context/change_log.md index 701193e..b6b5c11 100644 --- a/docs/context/change_log.md +++ b/docs/context/change_log.md @@ -2,6 +2,25 @@ This is a living implementation log. Add new entries for each meaningful project change. +## 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 diff --git a/docs/context/decisions.md b/docs/context/decisions.md index 7e92ec5..f71c3ec 100644 --- a/docs/context/decisions.md +++ b/docs/context/decisions.md @@ -1,5 +1,23 @@ # Decisions +## 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 diff --git a/docs/context/living_brain.md b/docs/context/living_brain.md index 5dd297e..06248e7 100644 --- a/docs/context/living_brain.md +++ b/docs/context/living_brain.md @@ -5,7 +5,7 @@ implementation details, or working assumptions change. ## Current State -- Branch: `feat/issue-23-storage-hot-path-hardening`. +- Branch: `dev`. - 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 +35,36 @@ implementation details, or working assumptions change. - `raw_kick_events`: 121664 - `raw_event_attempts`: 121664 +## 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` +- The backend is implemented and wired; frontend request/admin screens are still pending. + ## Webhook Subscription Pipeline (issue #22, Phase 6 complete) - `domain.KickWebhookEvent` — webhook inbox model; status: `pending/processed/failed/ignored` diff --git a/docs/context/recent_changes.md b/docs/context/recent_changes.md index e260875..124b8aa 100644 --- a/docs/context/recent_changes.md +++ b/docs/context/recent_changes.md @@ -2,6 +2,30 @@ 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 (public request form backend) + +- Backend request-form foundation is implemented; 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-4 complete, frontend phases 5-7 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/implementation_plan.md b/docs/implementation_plan.md index ed28701..4154cb3 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -15,6 +15,18 @@ will be visible to admins in a single admin page with filters and archive/status The feature should be built as a production-quality MVP: small surface area, clear validation, spam-resistant public endpoint behavior, and no unnecessary CRUD complexity. +## Current Status + +- Phase 1 complete: active plan and context are focused on the public request form. +- Phase 2 complete: backend domain models, ClickHouse migrations, storage port, repository, and + repository coverage are implemented. +- Phase 3 complete: `POST /requests`, validation, normalization, honeypot handling, request metadata + hashing, and public rate limit policy are implemented. +- Phase 4 complete: admin request listing, detail, status, note, and archive APIs are implemented. +- Phase 5 pending: public `/request` frontend page. +- Phase 6 pending: admin request management frontend. +- Phase 7 pending: final frontend-aware docs, smoke, and CI verification. + ## Product Goals - Give visitors a clear way to request new tracked channels. 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 From ee8b03a947ef405eede138d136f16267cb6ff095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yavuz=20Selim=20=C5=9Een?= Date: Sun, 14 Jun 2026 00:21:48 +0300 Subject: [PATCH 03/20] feat(requests): add public request page --- apps/web/src/app/request/page.tsx | 5 + apps/web/src/components/site-header.tsx | 27 +- .../web/src/features/landing/landing-page.tsx | 8 +- apps/web/src/features/requests/api.ts | 9 + .../features/requests/request-page.test.tsx | 101 +++++ .../src/features/requests/request-page.tsx | 348 ++++++++++++++++++ apps/web/src/types/api.ts | 16 + docs/context/change_log.md | 15 + docs/context/decisions.md | 13 + docs/context/living_brain.md | 14 +- docs/context/recent_changes.md | 27 +- docs/design/design.md | 35 +- docs/implementation_plan.md | 3 +- 13 files changed, 606 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/app/request/page.tsx create mode 100644 apps/web/src/features/requests/api.ts create mode 100644 apps/web/src/features/requests/request-page.test.tsx create mode 100644 apps/web/src/features/requests/request-page.tsx 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/site-header.tsx b/apps/web/src/components/site-header.tsx index 3bd661a..6a33651 100644 --- a/apps/web/src/components/site-header.tsx +++ b/apps/web/src/components/site-header.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -type ActiveRoute = "search" | "channels" | "users" | "prediction" | "admin" | null; +type ActiveRoute = "search" | "channels" | "users" | "prediction" | "request" | "admin" | null; type SiteHeaderProps = { activeRoute?: ActiveRoute; @@ -19,7 +19,7 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { return ( <> -
+
@@ -48,6 +48,17 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { > + @@ -87,6 +98,18 @@ export function SiteHeader({ activeRoute = "search" }: SiteHeaderProps) { ))}
+ setIsMenuOpen(false)} + className={cn( + "flex h-9 items-center rounded-md px-3 text-[13px] font-medium transition-colors", + activeRoute === "request" + ? "border border-border bg-panel text-foreground" + : "text-muted-foreground hover:bg-elevated hover:text-foreground" + )} + > + Talep + setIsMenuOpen(false)} diff --git a/apps/web/src/features/landing/landing-page.tsx b/apps/web/src/features/landing/landing-page.tsx index 5021aad..281b60a 100644 --- a/apps/web/src/features/landing/landing-page.tsx +++ b/apps/web/src/features/landing/landing-page.tsx @@ -128,11 +128,17 @@ export function LandingPage() { function Footer() { return (
-
+

{new Date().getFullYear()} kick-logs · Tüm hakları saklıdır.

+ + Talep +
); diff --git a/apps/web/src/features/requests/api.ts b/apps/web/src/features/requests/api.ts new file mode 100644 index 0000000..e7b6cba --- /dev/null +++ b/apps/web/src/features/requests/api.ts @@ -0,0 +1,9 @@ +import { apiClient, type ApiClient } from "@/lib/api-client"; +import type { CreateUserRequestRequest, CreateUserRequestResponse } from "@/types/api"; + +export function createUserRequest( + payload: CreateUserRequestRequest, + client: ApiClient = apiClient +) { + return client.post("/requests", payload); +} 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..ac31ab1 --- /dev/null +++ b/apps/web/src/features/requests/request-page.test.tsx @@ -0,0 +1,101 @@ +import { 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(); + + await user.type(screen.getByLabelText("Kanal adı"), "kick.com/NuriBen"); + await user.type(screen.getByLabelText("Başlık"), "Kanal takip edilsin"); + await user.type(screen.getByLabelText("Mesaj"), "Bu kanal listede olursa iyi olur."); + await user.type(screen.getByLabelText(/İletişim/), "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(); + + await user.type(screen.getByLabelText("Başlık"), "Yeni filtre önerisi"); + await user.type(screen.getByLabelText("Mesaj"), "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 ( +
+ + +
+
+

Talep

+

+ Takip edilmesini istediğin kanalı veya uygulama hakkında iletmek istediğin mesajı + gönder. +

+
+ +
+
{ + event.preventDefault(); + if (canSubmit) { + void submitRequest(); + } + }} + > +
+ Talep tipi + {MODES.map((mode) => ( + + ))} +
+ +
+ {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} + /> + + + } + > +