From 662c5dbc5bee639ec8a7c024e3191e9267c389fb Mon Sep 17 00:00:00 2001 From: POLICH Date: Thu, 27 Aug 2026 12:48:59 +0300 Subject: [PATCH 01/17] Create LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..6663a746 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 POLICH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7a6d7ff0cc0eb43860d00df9e15c1b629d073146 Mon Sep 17 00:00:00 2001 From: POLICH Date: Thu, 27 Aug 2026 12:51:55 +0300 Subject: [PATCH 02/17] Revise architecture details in README.md Updated backend and frontend architecture sections with enhanced descriptions and new features. --- README.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 88ffc599..af6fe9e3 100644 --- a/README.md +++ b/README.md @@ -101,19 +101,28 @@ Full specification & feature matrix: [docs/FEATURES.md](docs/FEATURES.md) This platform follows a decoupled Client-Server architecture. ### Backend Architecture -- **Clean Architecture Monolith**: Structured as a modular package-by-feature codebase (`admin`, `ai`, `bot`, `crm`, `billing`, `broadcast`, `auth`, `integration`, `common`). -- **Transactional Outbox Pattern**: Decouples database state mutations from asynchronous event streaming and external webhooks. -- **Resilient AI Router**: Automated fallback engine preventing downtime across external AI inference providers. -- **JSONB Graph Persistence & Deep Entity Graphs**: High-speed graph serialization and N+1 query elimination under heavy concurrent read load. -- **Automatic JDBC Batching**: Optimized batch size (50) and insert/update ordering for bulk writes and high-volume CRM ingestion. -- **Hermetic Integration Tests**: Real PostgreSQL Testcontainers ensuring zero disparity between local development, CI, and production. +- **Clean Architecture Monolith**: Modular, package-by-feature layout strictly separating core domain logic, infrastructure adapters, and web controllers. +- **Project Loom Virtual Threads**: Java 21 virtual threads enabled across the entire servlet container for massive concurrent I/O throughput (Telegram webhooks, AI generation streams, and external API connectors). +- **Transactional Outbox Pattern**: Guaranteed at-least-once asynchronous event dispatching (CRM actions, external webhooks, Google Sheets exports) with a Dead Letter Queue (DLQ) retry mechanism. +- **Distributed Rate Limiting & Tier Throttling**: Bucket4j integrated with Redis for dynamic token-bucket rate limiting based on subscription tiers (Free, Pro, Enterprise) and IP reputations. +- **Idempotency & Distributed Locking**: Custom `@Idempotent` annotation backed by Redis distributed locks, eliminating duplicate execution risks for payment webhooks and critical state mutations. +- **JSONB Graph Persistence & Deep Entity Graphs**: High-speed conversational graph serialization in PostgreSQL with JPA `@EntityGraph` fetching to eliminate N+1 query overhead. +- **Optimistic Concurrency Control**: Hibernate `@Version` locking to protect concurrent schema updates and multi-operator CRM interactions from race conditions. +- **Automatic JDBC Batching**: Tuned batch sizing (`50`) and insert/update statement ordering for high-throughput CRM lead ingestion and bulk broadcasts. +- **Enterprise PII Masking**: Contextual Logback converters automatically sanitizing Telegram bot tokens, credentials, and customer emails across application log outputs. +- **Hermetic Integration Testing**: Containerized PostgreSQL Testcontainers ensuring exact production parity during automated CI/CD test runs. Detailed backend architecture & ADRs: [docs/backend/ARCHITECTURE.md](docs/backend/ARCHITECTURE.md) | [docs/backend/decisions/README.md](docs/backend/decisions/README.md) ### Frontend Architecture -- **Layer-Based & Feature-Sliced**: Clear separation between API clients, shared UI primitives, custom hooks, and route views. -- **Strict State Separation**: Server state managed exclusively via TanStack Query; client state managed via Zustand. -- **Silent JWT Refresh Interceptor**: Axios queue mechanism ensuring transparent token rotation on HTTP 401 without user disruption. +- **Layer-Based & Feature-Sliced**: Strict modular hierarchy dividing low-level UI primitives, REST API clients, custom React hooks, and domain-specific route views. +- **Interactive Visual Flow Builder**: Custom node-graph editor powered by `@xyflow/react` (React Flow), featuring real-time node validation, connection rules, and seamless JSON schema serialization. +- **Strict State Separation**: Server state cached and invalidated via TanStack Query v5; UI/client state (modals, auth sessions, canvas selection) isolated in Zustand stores. +- **Real-Time Live Chat (STOMP / SockJS)**: Low-latency bidirectional WebSocket connection for live CRM messaging, operator typing indicators, and instant lead status updates. +- **Optimistic UI Updates**: Immediate client-side reflection of CRM lead state changes, tag assignments, and message delivery with automatic rollback on network failure. +- **Silent JWT Refresh Interceptor**: Axios queue mechanism ensuring transparent access token rotation on HTTP 401 without disrupting in-flight operations or prompting re-login. +- **Type-Safe Validation & Form Pipelines**: Unified runtime and compile-time validation powered by Zod v4 and React Hook Form across all bot configurations and integration settings. +- **Role & Tier-Aware Route Protection**: Granular client-side route guards restricting capabilities based on active user roles (`OWNER`, `OPERATOR`, `ADMIN`) and subscription tier limits. Detailed frontend architecture & ADRs: [docs/frontend/ARCHITECTURE.md](docs/frontend/ARCHITECTURE.md) | [docs/frontend/decisions/README.md](docs/frontend/decisions/README.md) From 6f290bd363e73a32dfa609b454da60a7e3d0ecdb Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 12:41:34 +0300 Subject: [PATCH 03/17] refactor(api): enforce strict clean REST URL conventions across backend controllers and frontend clients --- .../admin/controller/AdminAutomationController.java | 2 +- .../launchly/admin/controller/AdminBroadcastController.java | 2 +- .../com/launchly/admin/controller/AdminUserController.java | 2 +- .../java/com/launchly/auth/controller/AuthController.java | 2 +- .../com/launchly/bot/controller/TemplateController.java | 2 +- .../launchly/admin/controller/AdminUserControllerTest.java | 4 ++-- .../com/launchly/auth/controller/AuthControllerTest.java | 4 ++-- .../com/launchly/auth/integration/AuthIntegrationTest.java | 2 +- .../com/launchly/bot/controller/TemplateControllerTest.java | 4 ++-- .../com/launchly/bot/integration/BotIntegrationTest.java | 2 +- frontend/src/api/admin.ts | 6 +++--- frontend/src/api/auth.ts | 2 +- frontend/src/api/templateApi.ts | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java index cf41eeb4..e798053b 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java @@ -49,7 +49,7 @@ public ResponseEntity> getAutomations( @ApiResponse(responseCode = "404", description = "Automation flow not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{automationId}/details") + @GetMapping("/{automationId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getAutomationDetails( @Parameter(description = "Automation schema ID") @PathVariable Long automationId, diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java index 927eaf0a..8e1b649a 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java @@ -49,7 +49,7 @@ public ResponseEntity> getBroadcasts( @ApiResponse(responseCode = "404", description = "Broadcast campaign not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{broadcastId}/details") + @GetMapping("/{broadcastId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getBroadcastDetails( @Parameter(description = "Broadcast campaign ID") @PathVariable Long broadcastId, diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java b/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java index 512149b3..f474225b 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java @@ -85,7 +85,7 @@ public ResponseEntity toggleUserStatus( @ApiResponse(responseCode = "404", description = "User not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{userId}/details") + @GetMapping("/{userId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getUserDetails( @Parameter(description = "Target user ID") @PathVariable Long userId, diff --git a/backend/src/main/java/com/launchly/auth/controller/AuthController.java b/backend/src/main/java/com/launchly/auth/controller/AuthController.java index 908419ec..561654cc 100644 --- a/backend/src/main/java/com/launchly/auth/controller/AuthController.java +++ b/backend/src/main/java/com/launchly/auth/controller/AuthController.java @@ -108,7 +108,7 @@ public ResponseEntity updateProfile(@Valid @RequestBody UpdateProf @ApiResponse(responseCode = "204", description = "Account deleted successfully"), @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @DeleteMapping("/delete-account") + @DeleteMapping("/account") public ResponseEntity deleteAccount(Authentication authentication) { UserResponse currentUser = authService.getCurrentUser(authentication.getName()); authService.deleteUserAccount(currentUser.id()); diff --git a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java index 86555c5e..5a2bb234 100644 --- a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java +++ b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java @@ -39,7 +39,7 @@ public class TemplateController { @ApiResponse(responseCode = "400", description = "Validation error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "404", description = "Source bot not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @PostMapping("/create") + @PostMapping @Idempotent public ResponseEntity createTemplate( @RequestBody CreateTemplateRequest request, diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java index b74ca241..0fa46e69 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java @@ -106,12 +106,12 @@ void updateUserRole_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/users/{userId}/details - Should return user details") + @DisplayName("GET /api/v1/admin/users/{userId} - Should return user details") void getUserDetails_Success() throws Exception { AdminUserDetailDto dto = new AdminUserDetailDto(); when(adminUserService.getUserDetails(eq(5L), eq("all"), eq("all"), eq(0), eq(20))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/users/5/details")) + mockMvc.perform(get("/api/v1/admin/users/5")) .andExpect(status().isOk()); } } diff --git a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java index 83c16481..c096a168 100644 --- a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java @@ -186,7 +186,7 @@ void updateProfile_ValidRequest_ReturnsOk() throws Exception { } @Test - @DisplayName("DELETE /api/v1/auth/delete-account - Should delete account and return 204 No Content") + @DisplayName("DELETE /api/v1/auth/account - Should delete account and return 204 No Content") void deleteAccount_Success() throws Exception { UserResponse userResponse = mock(UserResponse.class); when(userResponse.id()).thenReturn(1L); @@ -194,7 +194,7 @@ void deleteAccount_Success() throws Exception { UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken("user@launchly.pro", null, Collections.emptyList()); - mockMvc.perform(delete("/api/v1/auth/delete-account").principal(auth)) + mockMvc.perform(delete("/api/v1/auth/account").principal(auth)) .andExpect(status().isNoContent()); verify(authService, times(1)).deleteUserAccount(1L); diff --git a/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java b/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java index b494eb67..fe6f53aa 100644 --- a/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java +++ b/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java @@ -157,7 +157,7 @@ void updatePassword_Success() throws Exception { void deleteAccount_Success() throws Exception { User user = createTestUser("delacc", Role.ROLE_OWNER); - mockMvc.perform(delete("/api/v1/auth/delete-account") + mockMvc.perform(delete("/api/v1/auth/account") .header("Authorization", getAuthHeader(user))) .andExpect(status().isNoContent()); diff --git a/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java b/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java index 74454478..28de69f0 100644 --- a/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java +++ b/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java @@ -80,14 +80,14 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m } @Test - @DisplayName("POST /api/v1/templates/create - Should create template and return 201 Created") + @DisplayName("POST /api/v1/templates - Should create template and return 201 Created") void createTemplate_Success() throws Exception { CreateTemplateRequest request = mock(CreateTemplateRequest.class); TemplateResponse response = mock(TemplateResponse.class); when(response.shareCode()).thenReturn("tpl_abc"); when(templateService.createTemplate(any(CreateTemplateRequest.class), eq(1L))).thenReturn(response); - mockMvc.perform(post("/api/v1/templates/create") + mockMvc.perform(post("/api/v1/templates") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) diff --git a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java index 3c145273..7b725fdb 100644 --- a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java +++ b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java @@ -166,7 +166,7 @@ void createAndGetTemplate_Success() throws Exception { List.of() ); - mockMvc.perform(post("/api/v1/templates/create") + mockMvc.perform(post("/api/v1/templates") .header("Authorization", getAuthHeader(user)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 8b3f7541..136f264a 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -197,7 +197,7 @@ export const fetchAdminBroadcastDetailsApi = async ( size = 10 ): Promise => { const params: Record = { period, page, size }; - const response = await apiClient.get(`/admin/broadcasts/${broadcastId}/details`, { params }); + const response = await apiClient.get(`/admin/broadcasts/${broadcastId}`, { params }); return response.data; }; @@ -327,7 +327,7 @@ export const fetchAdminAutomationDetailsApi = async ( size = 20 ): Promise => { const params: Record = { period, page, size }; - const response = await apiClient.get(`/admin/automations/${automationId}/details`, { params }); + const response = await apiClient.get(`/admin/automations/${automationId}`, { params }); return response.data; }; @@ -453,7 +453,7 @@ export const fetchAdminUserDetailsApi = async ( size = 20 ): Promise => { const params: Record = { period, category, page, size }; - const response = await apiClient.get(`/admin/users/${userId}/details`, { params }); + const response = await apiClient.get(`/admin/users/${userId}`, { params }); return response.data; }; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 2f602a9a..2ab16bcc 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -34,7 +34,7 @@ export const updateProfileApi = async (data: UpdateProfileRequest): Promise => { - await apiClient.delete('/auth/delete-account'); + await apiClient.delete('/auth/account'); }; export interface TelegramSessionResponse { diff --git a/frontend/src/api/templateApi.ts b/frontend/src/api/templateApi.ts index 9480432e..e98371b3 100644 --- a/frontend/src/api/templateApi.ts +++ b/frontend/src/api/templateApi.ts @@ -67,7 +67,7 @@ export interface TemplateResponse { } export const createTemplateApi = async (payload: CreateTemplatePayload): Promise => { - const response = await apiClient.post('/templates/create', payload); + const response = await apiClient.post('/templates', payload); return response.data; }; From 8f3d8517df9ef325c048d28c6d10f06661944a85 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:03:50 +0300 Subject: [PATCH 04/17] feat(frontend): add automatic idempotency key header to axios mutating requests --- frontend/src/api/axios.test.ts | 132 +++++++++++++++++++++++++ frontend/src/api/axios.ts | 20 ++++ frontend/src/utils/idempotency.test.ts | 66 +++++++++++++ frontend/src/utils/idempotency.ts | 23 +++++ 4 files changed, 241 insertions(+) create mode 100644 frontend/src/api/axios.test.ts create mode 100644 frontend/src/utils/idempotency.test.ts create mode 100644 frontend/src/utils/idempotency.ts diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts new file mode 100644 index 00000000..000a2969 --- /dev/null +++ b/frontend/src/api/axios.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import apiClient from './axios'; +import { useAuthStore } from '../store/useAuthStore'; +import { IDEMPOTENCY_HEADER_NAME } from '../utils/idempotency'; + +describe('apiClient request interceptor', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ accessToken: null, refreshToken: null, user: null }); + }); + + it('should automatically attach Idempotency-Key on POST requests', async () => { + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: { success: true }, status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.post('/test-endpoint', { name: 'Launchly' }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + expect(typeof idempotencyKey).toBe('string'); + expect(idempotencyKey.length).toBeGreaterThan(10); + }); + + it('should automatically attach Idempotency-Key on DELETE requests', async () => { + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: {}, status: 204, statusText: 'No Content', headers: {}, config }; + }); + + await apiClient.delete('/test-endpoint/123'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + expect(typeof idempotencyKey).toBe('string'); + }); + + it('should automatically attach Idempotency-Key on PATCH requests', async () => { + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: {}, status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.patch('/test-endpoint/123', { active: true }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + }); + + it('should NOT attach Idempotency-Key on GET requests', async () => { + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: [], status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.get('/test-endpoint'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeUndefined(); + }); + + it('should preserve manual Idempotency-Key if already provided', async () => { + let capturedConfig: any = null; + const customKey = 'custom-user-provided-key-999'; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: { success: true }, status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.post('/test-endpoint', {}, { + headers: { [IDEMPOTENCY_HEADER_NAME]: customKey }, + }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBe(customKey); + }); + + it('should attach Bearer token when accessToken is present in auth store', async () => { + useAuthStore.setState({ accessToken: 'sample-jwt-token', refreshToken: null, user: null }); + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: [], status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.get('/secure-data'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const authHeader = typeof headers.get === 'function' + ? headers.get('Authorization') + : headers['Authorization']; + + expect(authHeader).toBe('Bearer sample-jwt-token'); + }); +}); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 2969662e..3e89a6f6 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,5 +1,10 @@ import axios from 'axios'; import { useAuthStore } from '../store/useAuthStore'; +import { + IDEMPOTENCY_HEADER_NAME, + generateIdempotencyKey, + shouldAttachIdempotencyKey, +} from '../utils/idempotency'; const apiClient = axios.create({ baseURL: '/api/v1', @@ -14,6 +19,21 @@ apiClient.interceptors.request.use( if (token) { config.headers.Authorization = `Bearer ${token}`; } + + if (shouldAttachIdempotencyKey(config.method)) { + const existingKey = typeof config.headers.get === 'function' + ? config.headers.get(IDEMPOTENCY_HEADER_NAME) + : config.headers[IDEMPOTENCY_HEADER_NAME]; + + if (!existingKey) { + if (typeof config.headers.set === 'function') { + config.headers.set(IDEMPOTENCY_HEADER_NAME, generateIdempotencyKey()); + } else { + config.headers[IDEMPOTENCY_HEADER_NAME] = generateIdempotencyKey(); + } + } + } + return config; }, (error) => Promise.reject(error) diff --git a/frontend/src/utils/idempotency.test.ts b/frontend/src/utils/idempotency.test.ts new file mode 100644 index 00000000..5d604d5f --- /dev/null +++ b/frontend/src/utils/idempotency.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + IDEMPOTENCY_HEADER_NAME, + IDEMPOTENT_MUTATION_METHODS, + generateIdempotencyKey, + shouldAttachIdempotencyKey, +} from './idempotency'; + +describe('idempotency utility', () => { + const originalCrypto = globalThis.crypto; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: originalCrypto, + writable: true, + configurable: true, + }); + }); + + it('should define the standard header name and mutation methods', () => { + expect(IDEMPOTENCY_HEADER_NAME).toBe('Idempotency-Key'); + expect(IDEMPOTENT_MUTATION_METHODS).toEqual(['POST', 'DELETE', 'PATCH']); + }); + + it('should generate valid UUID v4 string using crypto.randomUUID when available', () => { + const mockUuid = '123e4567-e89b-42d3-a456-426614174000'; + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: vi.fn(() => mockUuid) }, + writable: true, + configurable: true, + }); + + const key = generateIdempotencyKey(); + expect(key).toBe(mockUuid); + }); + + it('should generate valid UUID v4 string format when crypto is undefined', () => { + Object.defineProperty(globalThis, 'crypto', { + value: undefined, + writable: true, + configurable: true, + }); + + const key = generateIdempotencyKey(); + const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(uuidV4Regex.test(key)).toBe(true); + }); + + it('should correctly identify when idempotency key should be attached', () => { + expect(shouldAttachIdempotencyKey('post')).toBe(true); + expect(shouldAttachIdempotencyKey('POST')).toBe(true); + expect(shouldAttachIdempotencyKey('delete')).toBe(true); + expect(shouldAttachIdempotencyKey('DELETE')).toBe(true); + expect(shouldAttachIdempotencyKey('patch')).toBe(true); + expect(shouldAttachIdempotencyKey('PATCH')).toBe(true); + expect(shouldAttachIdempotencyKey('get')).toBe(false); + expect(shouldAttachIdempotencyKey('GET')).toBe(false); + expect(shouldAttachIdempotencyKey('put')).toBe(false); + expect(shouldAttachIdempotencyKey('PUT')).toBe(false); + expect(shouldAttachIdempotencyKey(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/utils/idempotency.ts b/frontend/src/utils/idempotency.ts new file mode 100644 index 00000000..c14a212c --- /dev/null +++ b/frontend/src/utils/idempotency.ts @@ -0,0 +1,23 @@ +export const IDEMPOTENCY_HEADER_NAME = 'Idempotency-Key'; + +export const IDEMPOTENT_MUTATION_METHODS = ['POST', 'DELETE', 'PATCH'] as const; + +export const generateIdempotencyKey = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => { + const randomNibble = (Math.random() * 16) | 0; + const value = character === 'x' ? randomNibble : (randomNibble & 0x3) | 0x8; + return value.toString(16); + }); +}; + +export const shouldAttachIdempotencyKey = (method?: string): boolean => { + if (!method) { + return false; + } + const upperMethod = method.toUpperCase(); + return upperMethod === 'POST' || upperMethod === 'DELETE' || upperMethod === 'PATCH'; +}; From 9cee5f88148e179d451d27dd3e6ad842d25a2d6c Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:07:29 +0300 Subject: [PATCH 05/17] feat(frontend): implement high performance virtualization for crm chat messages and contacts --- frontend/package.json | 1 + frontend/src/hooks/useVirtualList.test.ts | 66 ++++++ frontend/src/hooks/useVirtualList.ts | 201 ++++++++++++++++++ .../owner/Chat/components/ContactsTable.tsx | 29 ++- .../Chat/components/ConversationList.tsx | 150 +++++++------ .../owner/Chat/components/MessageArea.tsx | 152 ++++++++----- 6 files changed, 477 insertions(+), 122 deletions(-) create mode 100644 frontend/src/hooks/useVirtualList.test.ts create mode 100644 frontend/src/hooks/useVirtualList.ts diff --git a/frontend/package.json b/frontend/package.json index f151df60..615bf91d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@icons-pack/react-simple-icons": "^13.13.0", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.13.1", "@xyflow/react": "^12.11.0", "axios": "^1.17.0", "emoji-mart": "^5.6.0", diff --git a/frontend/src/hooks/useVirtualList.test.ts b/frontend/src/hooks/useVirtualList.test.ts new file mode 100644 index 00000000..4689cec1 --- /dev/null +++ b/frontend/src/hooks/useVirtualList.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useVirtualList } from './useVirtualList'; + +describe('useVirtualList', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should return empty items when count is 0', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 0, + itemHeight: 50, + }) + ); + + expect(result.current.virtualItems).toEqual([]); + expect(result.current.totalHeight).toBe(0); + expect(result.current.parentRef.current).toBeNull(); + }); + + it('should calculate total height correctly for fixed height items', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 100, + itemHeight: 60, + }) + ); + + expect(result.current.totalHeight).toBe(6000); + }); + + it('should calculate total height correctly for dynamic height items', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 10, + itemHeight: (index) => (index % 2 === 0 ? 50 : 100), + }) + ); + + expect(result.current.totalHeight).toBe(750); + }); + + it('should render correct virtual items when viewport height and scrollTop are updated', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 1000, + itemHeight: 50, + overscan: 2, + }) + ); + + const mockElement = document.createElement('div'); + Object.defineProperty(mockElement, 'clientHeight', { value: 500, configurable: true }); + Object.defineProperty(mockElement, 'scrollTop', { value: 200, writable: true, configurable: true }); + + (result.current.parentRef as any).current = mockElement; + + act(() => { + mockElement.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.totalHeight).toBe(50000); + }); +}); diff --git a/frontend/src/hooks/useVirtualList.ts b/frontend/src/hooks/useVirtualList.ts new file mode 100644 index 00000000..a3562243 --- /dev/null +++ b/frontend/src/hooks/useVirtualList.ts @@ -0,0 +1,201 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; + +export interface UseVirtualListOptions { + count: number; + itemHeight: number | ((index: number) => number); + overscan?: number; +} + +export interface VirtualItem { + index: number; + offsetTop: number; + size: number; +} + +export interface UseVirtualListResult { + parentRef: React.RefObject; + virtualItems: VirtualItem[]; + totalHeight: number; + scrollToIndex: (index: number, align?: 'start' | 'center' | 'end' | 'auto') => void; +} + +export const useVirtualList = ({ + count, + itemHeight, + overscan = 3, +}: UseVirtualListOptions): UseVirtualListResult => { + const parentRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + + const getItemHeight = useCallback( + (index: number): number => { + return typeof itemHeight === 'function' ? itemHeight(index) : itemHeight; + }, + [itemHeight] + ); + + const { offsets, totalHeight } = useMemo(() => { + const calculatedOffsets: number[] = new Array(count); + let currentOffset = 0; + + for (let i = 0; i < count; i++) { + calculatedOffsets[i] = currentOffset; + currentOffset += getItemHeight(i); + } + + return { + offsets: calculatedOffsets, + totalHeight: currentOffset, + }; + }, [count, getItemHeight]); + + useEffect(() => { + const element = parentRef.current; + if (!element) return; + + let frameId: number | null = null; + + const handleScroll = () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + frameId = requestAnimationFrame(() => { + setScrollTop(element.scrollTop); + }); + }; + + const updateHeight = () => { + setViewportHeight(element.clientHeight); + }; + + updateHeight(); + setScrollTop(element.scrollTop); + + element.addEventListener('scroll', handleScroll, { passive: true }); + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target === element) { + setViewportHeight(entry.contentRect.height); + } + } + }); + + resizeObserver.observe(element); + + return () => { + element.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, []); + + const virtualItems = useMemo(() => { + if (count === 0) { + return []; + } + + if (viewportHeight === 0) { + const initialCount = Math.min(count, Math.max(10, overscan * 2)); + const initialItems: VirtualItem[] = []; + for (let i = 0; i < initialCount; i++) { + initialItems.push({ + index: i, + offsetTop: offsets[i], + size: getItemHeight(i), + }); + } + return initialItems; + } + + const scrollBottom = scrollTop + viewportHeight; + + let startIndex = 0; + let low = 0; + let high = count - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const midOffset = offsets[mid]; + const midSize = getItemHeight(mid); + + if (midOffset + midSize >= scrollTop) { + startIndex = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + let endIndex = startIndex; + low = startIndex; + high = count - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const midOffset = offsets[mid]; + + if (midOffset <= scrollBottom) { + endIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + startIndex = Math.max(0, startIndex - overscan); + endIndex = Math.min(count - 1, endIndex + overscan); + + const items: VirtualItem[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push({ + index: i, + offsetTop: offsets[i], + size: getItemHeight(i), + }); + } + + return items; + }, [count, viewportHeight, scrollTop, offsets, overscan, getItemHeight]); + + const scrollToIndex = useCallback( + (index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') => { + const element = parentRef.current; + if (!element || index < 0 || index >= count) return; + + const itemOffset = offsets[index]; + const itemSize = getItemHeight(index); + const currentScroll = element.scrollTop; + const currentHeight = element.clientHeight; + + let targetScroll = itemOffset; + + if (align === 'center') { + targetScroll = itemOffset - currentHeight / 2 + itemSize / 2; + } else if (align === 'end') { + targetScroll = itemOffset - currentHeight + itemSize; + } else if (align === 'auto') { + if (itemOffset < currentScroll) { + targetScroll = itemOffset; + } else if (itemOffset + itemSize > currentScroll + currentHeight) { + targetScroll = itemOffset - currentHeight + itemSize; + } else { + return; + } + } + + element.scrollTop = Math.max(0, Math.min(totalHeight - currentHeight, targetScroll)); + }, + [count, offsets, getItemHeight, totalHeight] + ); + + return { + parentRef, + virtualItems, + totalHeight, + scrollToIndex, + }; +}; diff --git a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx index 270bab3c..25933c63 100644 --- a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx +++ b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx @@ -4,6 +4,7 @@ import { Loader2, AlertCircle, Plus } from 'lucide-react'; import type { BotUserResponse } from '../../../../types/bot'; import { ContactAvatar } from './ContactAvatar'; import { t } from '../../../../i18n/config'; +import { useVirtualList } from '../../../../hooks/useVirtualList'; interface ContactsTableProps { botId: number; @@ -26,6 +27,17 @@ export const ContactsTable: React.FC = ({ }) => { const navigate = useNavigate(); + const { parentRef, virtualItems, totalHeight } = useVirtualList({ + count: filteredContacts.length, + itemHeight: 64, + overscan: 4, + }); + + const firstItem = virtualItems[0]; + const lastItem = virtualItems[virtualItems.length - 1]; + const paddingTop = firstItem ? firstItem.offsetTop : 0; + const paddingBottom = lastItem ? Math.max(0, totalHeight - (lastItem.offsetTop + lastItem.size)) : 0; + const parseMetadata = (metaStr: string | null) => { try { return metaStr ? JSON.parse(metaStr) : {}; @@ -89,7 +101,7 @@ export const ContactsTable: React.FC = ({ } return ( -
+
@@ -112,7 +124,15 @@ export const ContactsTable: React.FC = ({ - {filteredContacts.map((c) => { + {paddingTop > 0 && ( + + + )} + {virtualItems.map(({ index }) => { + const c = filteredContacts[index]; + if (!c) return null; + const isSelected = selectedContactIds.has(c.id); const meta = parseMetadata(c.metadata); const isPaused = meta.paused; @@ -171,6 +191,11 @@ export const ContactsTable: React.FC = ({ ); })} + {paddingBottom > 0 && ( + + + )}
+
+
diff --git a/frontend/src/pages/owner/Chat/components/ConversationList.tsx b/frontend/src/pages/owner/Chat/components/ConversationList.tsx index 0a7fdc1d..28b0a871 100644 --- a/frontend/src/pages/owner/Chat/components/ConversationList.tsx +++ b/frontend/src/pages/owner/Chat/components/ConversationList.tsx @@ -4,6 +4,7 @@ import type { ConversationResponse } from '../../../../types/crm'; import { UserAvatar } from './UserAvatar'; import { timeAgo } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; +import { useVirtualList } from '../../../../hooks/useVirtualList'; interface ConversationListProps { conversations: ConversationResponse[]; @@ -27,69 +28,90 @@ export const ConversationList: React.FC = ({ unreadConvIds, searchQuery, chatFilter, -}) => ( -
-
- {isLoading ? ( -
- ) : conversations.length === 0 ? ( -
- {searchQuery - ? t('crm.list.no_conversations_found') - : chatFilter === 'open' - ? t('crm.list.no_open_conversations') - : chatFilter === 'closed' - ? t('crm.list.no_closed_conversations') - : t('crm.list.no_conversations')} -
- ) : ( - conversations.map(c => { - const isSel = c.id === selectedConvId; - const isFav = favorites.includes(c.id); - const isUnrd = c.unread || unreadConvIds.includes(c.id); - return ( -
onSelect(c.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(c.id); - } - }} - className={`w-full text-left px-4 py-3 flex items-start gap-2.5 transition-all cursor-pointer border-b-2 border-[#0A0A0A] group ${isSel ? 'bg-white font-black' : 'bg-[#F2EBDD] hover:bg-white'}`} - > -
- - {isUnrd && } -
-
-
- {c.botUserName} - {timeAgo(c.lastMessageAt)} -
-
-

{c.lastMessage || 'No messages'}

- {c.botName && ( - - {c.botName} - - )} +}) => { + const { parentRef, virtualItems, totalHeight } = useVirtualList({ + count: conversations.length, + itemHeight: 68, + overscan: 4, + }); + + return ( +
+
+ {isLoading ? ( +
+ ) : conversations.length === 0 ? ( +
+ {searchQuery + ? t('crm.list.no_conversations_found') + : chatFilter === 'open' + ? t('crm.list.no_open_conversations') + : chatFilter === 'closed' + ? t('crm.list.no_closed_conversations') + : t('crm.list.no_conversations')} +
+ ) : ( +
+ {virtualItems.map(({ index, offsetTop, size }) => { + const c = conversations[index]; + if (!c) return null; + const isSel = c.id === selectedConvId; + const isFav = favorites.includes(c.id); + const isUnrd = c.unread || unreadConvIds.includes(c.id); + + return ( +
onSelect(c.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(c.id); + } + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${size}px`, + transform: `translateY(${offsetTop}px)`, + }} + className={`w-full text-left px-4 py-3 flex items-start gap-2.5 transition-all cursor-pointer border-b-2 border-[#0A0A0A] group ${isSel ? 'bg-white font-black' : 'bg-[#F2EBDD] hover:bg-white'}`} + > +
+ + {isUnrd && } +
+
+
+ {c.botUserName} + {timeAgo(c.lastMessageAt)} +
+
+

{c.lastMessage || 'No messages'}

+ {c.botName && ( + + {c.botName} + + )} +
+
+
-
- -
- ); - }) - )} + ); + })} +
+ )} +
-
-); + ); +}; diff --git a/frontend/src/pages/owner/Chat/components/MessageArea.tsx b/frontend/src/pages/owner/Chat/components/MessageArea.tsx index c6b758c0..77954149 100644 --- a/frontend/src/pages/owner/Chat/components/MessageArea.tsx +++ b/frontend/src/pages/owner/Chat/components/MessageArea.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useEffect } from 'react'; +import React, { useMemo, useEffect, useCallback } from 'react'; import { Loader2, MessageSquare, @@ -12,6 +12,7 @@ import { MessageBubble } from './MessageBubble'; import { ChatToolbar } from './ChatToolbar'; import { formatDateSeparator, getDateKey } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; +import { useVirtualList } from '../../../../hooks/useVirtualList'; interface MessageAreaProps { conversation: ConversationResponse | null; @@ -36,6 +37,10 @@ interface MessageAreaProps { meta?: Record; } +type FlatChatMessage = + | { type: 'date'; key: string; date: string } + | { type: 'message'; key: string; message: MessageResponse }; + export const MessageArea: React.FC = ({ conversation, botUser, @@ -58,47 +63,54 @@ export const MessageArea: React.FC = ({ onDeleteGlobalLabel = () => {}, meta = {}, }) => { - const messagesEndRef = useRef(null); - - const scrollToBottom = (behavior: 'auto' | 'smooth' = 'auto') => { - messagesEndRef.current?.scrollIntoView({ behavior }); - }; - - useEffect(() => { - if (!conversation) return; - - const behavior = 'auto'; - scrollToBottom(behavior); - - const t1 = setTimeout(() => scrollToBottom(behavior), 50); - const t2 = setTimeout(() => scrollToBottom(behavior), 150); - - return () => { - clearTimeout(t1); - clearTimeout(t2); - }; - }, [messages, conversation?.id]); - - const handleImageLoad = () => { - scrollToBottom('auto'); - }; - - const groupedMessages = useMemo(() => { - const groups: { date: string; msgs: MessageResponse[] }[] = []; + const flatItems = useMemo(() => { + const items: FlatChatMessage[] = []; let currentDate = ''; const visibleMessages = messages.filter(m => !(m.content && m.content.startsWith('🖱️ '))); visibleMessages.forEach(m => { const dk = getDateKey(m.createdAt); if (dk !== currentDate) { currentDate = dk; - groups.push({ date: m.createdAt, msgs: [m] }); - } else { - groups[groups.length - 1].msgs.push(m); + items.push({ type: 'date', key: `date-${dk}`, date: m.createdAt }); } + items.push({ type: 'message', key: `msg-${m.id}`, message: m }); }); - return groups; + return items; }, [messages]); + const getItemHeight = useCallback((index: number): number => { + const item = flatItems[index]; + if (!item) return 72; + if (item.type === 'date') return 48; + const length = item.message.content?.length || 0; + if (length > 250) return 160; + if (length > 120) return 110; + return 72; + }, [flatItems]); + + const { parentRef, virtualItems, totalHeight, scrollToIndex } = useVirtualList({ + count: flatItems.length, + itemHeight: getItemHeight, + overscan: 6, + }); + + const handleImageLoad = () => { + if (flatItems.length > 0) { + scrollToIndex(flatItems.length - 1, 'end'); + } + }; + + useEffect(() => { + if (!conversation) return; + if (flatItems.length > 0) { + scrollToIndex(flatItems.length - 1, 'end'); + const timer = setTimeout(() => { + scrollToIndex(flatItems.length - 1, 'end'); + }, 60); + return () => clearTimeout(timer); + } + }, [flatItems.length, conversation?.id, scrollToIndex]); + if (!conversation) { return (
@@ -148,35 +160,63 @@ export const MessageArea: React.FC = ({ />
-
+
{isMsgLoading ? (
- ) : messages.length === 0 ? ( + ) : flatItems.length === 0 ? (
No messages in this conversation.
) : ( - groupedMessages.map((group, gi) => ( -
-
- - {formatDateSeparator(group.date)} - -
- {group.msgs.map(m => ( - } - userAvatar={} - allMessages={messages} - onButtonClick={onButtonClick} - onImageLoad={handleImageLoad} - /> - ))} -
- )) +
+ {virtualItems.map(({ index, offsetTop, size }) => { + const item = flatItems[index]; + if (!item) return null; + + if (item.type === 'date') { + return ( +
+ + {formatDateSeparator(item.date)} + +
+ ); + } + + return ( +
+ } + userAvatar={} + allMessages={messages} + onButtonClick={onButtonClick} + onImageLoad={handleImageLoad} + /> +
+ ); + })} +
)} - {messages.length > 0 &&
}
); From b8b78b73720f34321cfcf01a9e209cb6789c5680 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:10:48 +0300 Subject: [PATCH 06/17] feat(frontend): implement optimistic ui updates for crm and support operations --- frontend/src/hooks/crm/useCrmQueries.test.ts | 44 +++-- frontend/src/hooks/crm/useCrmQueries.ts | 171 +++++++++++++++++- .../src/hooks/support/useSupportQueries.ts | 61 ++++++- 3 files changed, 247 insertions(+), 29 deletions(-) diff --git a/frontend/src/hooks/crm/useCrmQueries.test.ts b/frontend/src/hooks/crm/useCrmQueries.test.ts index 8fd9d9dc..8c9ec61c 100644 --- a/frontend/src/hooks/crm/useCrmQueries.test.ts +++ b/frontend/src/hooks/crm/useCrmQueries.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { useOrdersQuery, useLeadsQuery, @@ -13,12 +13,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; vi.mock('../../api/crm', () => ({ - getOrdersApi: vi.fn().mockResolvedValue([{ id: 1, total: 100 }]), + getOrdersApi: vi.fn().mockResolvedValue([{ id: 1, orderNumber: 'ORD-1', status: 'NEW', totalAmount: 100 }]), getLeadsApi: vi.fn().mockResolvedValue([{ id: 1, name: 'Lead 1' }]), - getConversationsApi: vi.fn().mockResolvedValue([{ id: 1, botUserName: 'Alice' }]), + getConversationsApi: vi.fn().mockResolvedValue([{ id: 1, botUserName: 'Alice', lastMessage: 'Old' }]), getConversationApi: vi.fn().mockResolvedValue({ id: 1, botUserName: 'Alice' }), - getMessagesApi: vi.fn().mockResolvedValue([{ id: 1, content: 'Hello' }]), - sendOwnerMessageApi: vi.fn().mockResolvedValue({ id: 2, content: 'Reply' }), + getMessagesApi: vi.fn().mockResolvedValue([{ id: 1, content: 'Hello', senderType: 'USER' }]), + sendOwnerMessageApi: vi.fn().mockResolvedValue({ id: 2, content: 'Reply', senderType: 'OWNER' }), updateOrderApi: vi.fn().mockResolvedValue({ id: 1, status: 'COMPLETED' }), })); @@ -26,8 +26,8 @@ vi.mock('../bot/useBotsQuery', () => ({ useBotsQuery: () => ({ data: [{ id: 1, name: 'Bot 1' }], isLoading: false }), })); -const createWrapper = () => { - const queryClient = new QueryClient({ +const createWrapper = (client?: QueryClient) => { + const queryClient = client || new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return ({ children }: { children: React.ReactNode }) => @@ -49,21 +49,33 @@ describe('useCrmQueries', () => { await waitFor(() => expect(convRes.current.isSuccess).toBe(true)); await waitFor(() => expect(msgsRes.current.isSuccess).toBe(true)); - expect(ordersRes.current.data).toEqual([{ id: 1, total: 100 }]); + expect(ordersRes.current.data).toEqual([{ id: 1, orderNumber: 'ORD-1', status: 'NEW', totalAmount: 100 }]); expect(leadsRes.current.data).toEqual([{ id: 1, name: 'Lead 1' }]); - expect(convsRes.current.data).toEqual([{ id: 1, botUserName: 'Alice' }]); - expect(msgsRes.current.data).toEqual([{ id: 1, content: 'Hello' }]); + expect(convsRes.current.data).toEqual([{ id: 1, botUserName: 'Alice', lastMessage: 'Old' }]); + expect(msgsRes.current.data).toEqual([{ id: 1, content: 'Hello', senderType: 'USER' }]); }); - it('runs CRM mutations', async () => { - const wrapper = createWrapper(); + it('runs CRM mutations and performs optimistic updates', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = createWrapper(queryClient); + + queryClient.setQueryData(['messages', 1], [{ id: 1, content: 'Hello', senderType: 'USER' }]); + queryClient.setQueryData(['orders', 1], [{ id: 1, orderNumber: 'ORD-1', status: 'NEW', notes: '' }]); + const { result: sendRes } = renderHook(() => useSendMessageMutation(1, 1), { wrapper }); const { result: orderRes } = renderHook(() => useUpdateOrderMutation(1), { wrapper }); - const sent = await sendRes.current.mutateAsync({ content: 'Reply' }); - const upd = await orderRes.current.mutateAsync({ orderId: 1, status: 'COMPLETED' as unknown as never, notes: 'Done' }); + await act(async () => { + await sendRes.current.mutateAsync({ content: 'Optimistic Reply' }); + }); + + await act(async () => { + await orderRes.current.mutateAsync({ orderId: 1, status: 'COMPLETED' as unknown as never, notes: 'Done' }); + }); - expect(sent).toEqual({ id: 2, content: 'Reply' }); - expect(upd).toEqual({ id: 1, status: 'COMPLETED' }); + expect(sendRes.current.data).toEqual({ id: 2, content: 'Reply', senderType: 'OWNER' }); + expect(orderRes.current.data).toEqual({ id: 1, status: 'COMPLETED' }); }); }); diff --git a/frontend/src/hooks/crm/useCrmQueries.ts b/frontend/src/hooks/crm/useCrmQueries.ts index 16202790..4eaa16fa 100644 --- a/frontend/src/hooks/crm/useCrmQueries.ts +++ b/frontend/src/hooks/crm/useCrmQueries.ts @@ -19,9 +19,15 @@ import { updateBotUserApi, createBotUserApi, deleteBotUserApi, -} from '../../api/bot'; -import type { OrderStatus, LeadStatus, ConversationStatus } from '../../types/crm'; -import type { BotUserUpdateRequest, BotUserCreateRequest, BotUserResponse } from '../../types/bot'; +import type { + OrderStatus, + LeadStatus, + ConversationStatus, + MessageResponse, + OrderResponse, + LeadResponse, + ConversationResponse, +} from '../../types/crm'; export const useOrdersQuery = (botId: number, enabled: boolean = true) => { return useQuery({ @@ -76,7 +82,22 @@ export const useUpdateOrderMutation = (botId: number) => { return useMutation({ mutationFn: ({ orderId, status, notes }: { orderId: number; status: OrderStatus; notes: string }) => updateOrderApi(orderId, status, notes), - onSuccess: () => { + onMutate: async ({ orderId, status, notes }) => { + await queryClient.cancelQueries({ queryKey: ['orders', botId] }); + const previousOrders = queryClient.getQueryData(['orders', botId]); + if (previousOrders) { + queryClient.setQueryData(['orders', botId], (old = []) => + old.map((order) => (order.id === orderId ? { ...order, status, notes } : order)) + ); + } + return { previousOrders }; + }, + onError: (_err, _variables, context) => { + if (context?.previousOrders) { + queryClient.setQueryData(['orders', botId], context.previousOrders); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['orders', botId] }); }, }); @@ -87,7 +108,22 @@ export const useUpdateLeadMutation = (botId: number) => { return useMutation({ mutationFn: ({ leadId, status, notes }: { leadId: number; status: LeadStatus; notes: string }) => updateLeadApi(leadId, status, notes), - onSuccess: () => { + onMutate: async ({ leadId, status, notes }) => { + await queryClient.cancelQueries({ queryKey: ['leads', botId] }); + const previousLeads = queryClient.getQueryData(['leads', botId]); + if (previousLeads) { + queryClient.setQueryData(['leads', botId], (old = []) => + old.map((lead) => (lead.id === leadId ? { ...lead, status, notes } : lead)) + ); + } + return { previousLeads }; + }, + onError: (_err, _variables, context) => { + if (context?.previousLeads) { + queryClient.setQueryData(['leads', botId], context.previousLeads); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['leads', botId] }); }, }); @@ -98,7 +134,57 @@ export const useSendMessageMutation = (conversationId: number, botId: number) => return useMutation({ mutationFn: ({ content, mediaUrl, mediaType, scheduledAt }: { content: string; mediaUrl?: string; mediaType?: string; scheduledAt?: string }) => sendOwnerMessageApi(conversationId, content, mediaUrl, mediaType, scheduledAt), - onSuccess: () => { + onMutate: async ({ content, mediaUrl, mediaType, scheduledAt }) => { + await queryClient.cancelQueries({ queryKey: ['messages', conversationId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', botId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', 'all'] }); + + const previousMessages = queryClient.getQueryData(['messages', conversationId]); + const previousBotConversations = queryClient.getQueryData(['conversations', botId]); + const previousAllConversations = queryClient.getQueryData(['conversations', 'all']); + + const optimisticMessage: MessageResponse = { + id: -Date.now(), + conversationId, + content, + senderType: 'OWNER', + mediaUrl: mediaUrl || null, + mediaType: mediaType || null, + createdAt: new Date().toISOString(), + scheduledAt, + sent: true, + }; + + queryClient.setQueryData(['messages', conversationId], (old = []) => [...old, optimisticMessage]); + + const updateConversationsPreview = (list?: ConversationResponse[]) => + list?.map((conv) => + conv.id === conversationId + ? { ...conv, lastMessage: content, lastMessageAt: new Date().toISOString() } + : conv + ); + + if (previousBotConversations) { + queryClient.setQueryData(['conversations', botId], updateConversationsPreview(previousBotConversations)); + } + if (previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], updateConversationsPreview(previousAllConversations)); + } + + return { previousMessages, previousBotConversations, previousAllConversations }; + }, + onError: (_err, _variables, context) => { + if (context?.previousMessages) { + queryClient.setQueryData(['messages', conversationId], context.previousMessages); + } + if (context?.previousBotConversations) { + queryClient.setQueryData(['conversations', botId], context.previousBotConversations); + } + if (context?.previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], context.previousAllConversations); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); @@ -110,7 +196,30 @@ export const useSendNoteMutation = (conversationId: number, botId: number) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (content: string) => sendNoteApi(conversationId, content), - onSuccess: () => { + onMutate: async (content: string) => { + await queryClient.cancelQueries({ queryKey: ['messages', conversationId] }); + const previousMessages = queryClient.getQueryData(['messages', conversationId]); + + const optimisticNote: MessageResponse = { + id: -Date.now(), + conversationId, + content: `Note: ${content}`, + senderType: 'OWNER', + mediaUrl: null, + mediaType: null, + createdAt: new Date().toISOString(), + sent: true, + }; + + queryClient.setQueryData(['messages', conversationId], (old = []) => [...old, optimisticNote]); + return { previousMessages }; + }, + onError: (_err, _content, context) => { + if (context?.previousMessages) { + queryClient.setQueryData(['messages', conversationId], context.previousMessages); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); @@ -198,8 +307,52 @@ export const useUpdateConversationMutation = (botId: number) => { return useMutation({ mutationFn: ({ conversationId, status, unread }: { conversationId: number; status?: ConversationStatus; unread?: boolean }) => updateConversationApi(conversationId, { status, unread }), - onSuccess: (updated) => { - queryClient.invalidateQueries({ queryKey: ['conversation', updated.id] }); + onMutate: async ({ conversationId, status, unread }) => { + await queryClient.cancelQueries({ queryKey: ['conversation', conversationId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', botId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', 'all'] }); + + const previousConversation = queryClient.getQueryData(['conversation', conversationId]); + const previousBotConversations = queryClient.getQueryData(['conversations', botId]); + const previousAllConversations = queryClient.getQueryData(['conversations', 'all']); + + if (previousConversation) { + queryClient.setQueryData(['conversation', conversationId], { + ...previousConversation, + status: status ?? previousConversation.status, + unread: unread ?? previousConversation.unread, + }); + } + + const updateList = (list?: ConversationResponse[]) => + list?.map((conv) => + conv.id === conversationId + ? { ...conv, status: status ?? conv.status, unread: unread ?? conv.unread } + : conv + ); + + if (previousBotConversations) { + queryClient.setQueryData(['conversations', botId], updateList(previousBotConversations)); + } + if (previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], updateList(previousAllConversations)); + } + + return { previousConversation, previousBotConversations, previousAllConversations }; + }, + onError: (_err, { conversationId }, context) => { + if (context?.previousConversation) { + queryClient.setQueryData(['conversation', conversationId], context.previousConversation); + } + if (context?.previousBotConversations) { + queryClient.setQueryData(['conversations', botId], context.previousBotConversations); + } + if (context?.previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], context.previousAllConversations); + } + }, + onSettled: (updated, _err, { conversationId }) => { + queryClient.invalidateQueries({ queryKey: ['conversation', updated?.id || conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); }, diff --git a/frontend/src/hooks/support/useSupportQueries.ts b/frontend/src/hooks/support/useSupportQueries.ts index 3742b21d..69bb454c 100644 --- a/frontend/src/hooks/support/useSupportQueries.ts +++ b/frontend/src/hooks/support/useSupportQueries.ts @@ -42,8 +42,40 @@ export const useSendTicketMessageMutation = (ticketId: number | string | null) = if (!ticketId) throw new Error('No ticket selected'); return sendTicketMessageApi(ticketId, text); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + onMutate: async (text: string) => { + if (!ticketId) return; + await queryClient.cancelQueries({ queryKey: ['user-support-ticket', ticketId] }); + const previousTicket = queryClient.getQueryData(['user-support-ticket', ticketId]); + + if (previousTicket) { + const optimisticMsg: SupportMessageItem = { + id: -Date.now(), + ticketId: Number(ticketId), + sender: 'USER', + senderName: 'Ви', + text, + timestamp: new Date().toISOString(), + }; + + queryClient.setQueryData(['user-support-ticket', ticketId], { + ...previousTicket, + messages: [...(previousTicket.messages || []), optimisticMsg], + lastMessage: text, + lastMessageTime: new Date().toISOString(), + }); + } + + return { previousTicket }; + }, + onError: (_err, _text, context) => { + if (ticketId && context?.previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], context.previousTicket); + } + }, + onSettled: () => { + if (ticketId) { + queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + } queryClient.invalidateQueries({ queryKey: ['user-support-tickets'] }); }, }); @@ -56,8 +88,29 @@ export const useUpdateTicketStatusMutation = (ticketId: number | string | null) if (!ticketId) throw new Error('No ticket selected'); return updateTicketStatusApi(ticketId, status); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + onMutate: async (status: string) => { + if (!ticketId) return; + await queryClient.cancelQueries({ queryKey: ['user-support-ticket', ticketId] }); + const previousTicket = queryClient.getQueryData(['user-support-ticket', ticketId]); + + if (previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], { + ...previousTicket, + status: status as SupportTicketItem['status'], + }); + } + + return { previousTicket }; + }, + onError: (_err, _status, context) => { + if (ticketId && context?.previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], context.previousTicket); + } + }, + onSettled: () => { + if (ticketId) { + queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + } queryClient.invalidateQueries({ queryKey: ['user-support-tickets'] }); }, }); From 60422d298bd4b25583bdac0b9dbb558b5ed0b41f Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:12:45 +0300 Subject: [PATCH 07/17] feat(frontend): implement offline and websocket reconnecting network status banner --- frontend/src/App.tsx | 2 + .../common/NetworkStatusBanner.test.tsx | 71 ++++++++ .../components/common/NetworkStatusBanner.tsx | 153 ++++++++++++++++++ frontend/src/hooks/crm/useCrmWebSocket.ts | 17 +- frontend/src/store/useNetworkStore.test.ts | 50 ++++++ frontend/src/store/useNetworkStore.ts | 35 ++++ 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/common/NetworkStatusBanner.test.tsx create mode 100644 frontend/src/components/common/NetworkStatusBanner.tsx create mode 100644 frontend/src/store/useNetworkStore.test.ts create mode 100644 frontend/src/store/useNetworkStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33b77041..b5cdf5c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,14 @@ import { queryClient } from './api/queryClient'; import { AppRouter } from './routes'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { LanguageProvider } from './i18n/config'; +import { NetworkStatusBanner } from './components/common/NetworkStatusBanner'; const App: React.FC = () => { return ( + diff --git a/frontend/src/components/common/NetworkStatusBanner.test.tsx b/frontend/src/components/common/NetworkStatusBanner.test.tsx new file mode 100644 index 00000000..929515ca --- /dev/null +++ b/frontend/src/components/common/NetworkStatusBanner.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { NetworkStatusBanner } from './NetworkStatusBanner'; +import { useNetworkStore } from '../../store/useNetworkStore'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +vi.mock('../../i18n/config', () => ({ + t: (_k: string, fallback?: string) => fallback || _k, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe('NetworkStatusBanner', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'connected', + hasBeenOffline: false, + }); + }); + + it('renders nothing when online and connected', () => { + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); + + it('renders offline alert when browser is offline', () => { + useNetworkStore.setState({ isOnline: false }); + render(, { wrapper: createWrapper() }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/Відсутнє підключення до Інтернету/i) + ).toBeInTheDocument(); + }); + + it('renders reconnecting alert when webSocketStatus is reconnecting after being offline', () => { + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'reconnecting', + hasBeenOffline: true, + }); + render(, { wrapper: createWrapper() }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/З'єднання в реальному часі втрачено/i) + ).toBeInTheDocument(); + }); + + it('shows connection restored status on online event', () => { + render(, { wrapper: createWrapper() }); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect( + screen.getByText(/Підключення відновлено/i) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/NetworkStatusBanner.tsx b/frontend/src/components/common/NetworkStatusBanner.tsx new file mode 100644 index 00000000..44bf895c --- /dev/null +++ b/frontend/src/components/common/NetworkStatusBanner.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { WifiOff, RefreshCw, CheckCircle2, X } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNetworkStore } from '../../store/useNetworkStore'; +import { t } from '../../i18n/config'; + +export const NetworkStatusBanner: React.FC = () => { + const queryClient = useQueryClient(); + const { isOnline, webSocketStatus, hasBeenOffline, setOnline, setHasBeenOffline } = useNetworkStore(); + const [showRestored, setShowRestored] = useState(false); + const [isRetrying, setIsRetrying] = useState(false); + + const handleOnline = useCallback(() => { + setOnline(true); + setShowRestored(true); + queryClient.invalidateQueries(); + + const timer = setTimeout(() => { + setShowRestored(false); + setHasBeenOffline(false); + }, 4000); + + return () => clearTimeout(timer); + }, [setOnline, setHasBeenOffline, queryClient]); + + const handleOffline = useCallback(() => { + setOnline(false); + setShowRestored(false); + }, [setOnline]); + + useEffect(() => { + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [handleOnline, handleOffline]); + + const handleManualRetry = async () => { + setIsRetrying(true); + try { + if (typeof navigator !== 'undefined' && navigator.onLine) { + setOnline(true); + await queryClient.invalidateQueries(); + setShowRestored(true); + setTimeout(() => { + setShowRestored(false); + setHasBeenOffline(false); + }, 3000); + } + } finally { + setIsRetrying(false); + } + }; + + if (!isOnline) { + return ( +
+
+
+
+ +
+ + {t( + 'network.offline_message', + 'Відсутнє підключення до Інтернету. Перейдіть в онлайн для синхронізації змін.' + )} + +
+ +
+
+ ); + } + + if ( + isOnline && + (webSocketStatus === 'reconnecting' || webSocketStatus === 'disconnected') && + hasBeenOffline + ) { + return ( +
+
+
+ + + {t( + 'network.reconnecting_message', + "З'єднання в реальному часі втрачено. Відновлення зв'язку із сервером..." + )} + +
+ +
+
+ ); + } + + if (showRestored) { + return ( +
+
+
+ + + {t('network.restored_message', "Підключення відновлено. Дані успішно синхронізовані.")} + +
+ +
+
+ ); + } + + return null; +}; diff --git a/frontend/src/hooks/crm/useCrmWebSocket.ts b/frontend/src/hooks/crm/useCrmWebSocket.ts index 6cad74ad..6af59f31 100644 --- a/frontend/src/hooks/crm/useCrmWebSocket.ts +++ b/frontend/src/hooks/crm/useCrmWebSocket.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import { useQueryClient } from '@tanstack/react-query'; +import { useNetworkStore } from '../../store/useNetworkStore'; export const useCrmWebSocket = (botId: number) => { const queryClient = useQueryClient(); @@ -13,17 +14,27 @@ export const useCrmWebSocket = (botId: number) => { const socket = new SockJS('/ws'); const client = new Client({ webSocketFactory: () => socket, - reconnectDelay: 10000, - connectionTimeout: 3000, + reconnectDelay: 5000, + connectionTimeout: 4000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, + onWebSocketClose: () => { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + }, onWebSocketError: () => { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); }, onStompError: () => { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + }, + onDisconnect: () => { + useNetworkStore.getState().setWebSocketStatus('disconnected'); }, }); client.onConnect = () => { + useNetworkStore.getState().setWebSocketStatus('connected'); + client.subscribe(`/topic/crm/${botId}/messages`, (msg) => { try { const body = JSON.parse(msg.body); @@ -45,6 +56,7 @@ export const useCrmWebSocket = (botId: number) => { }); }; + useNetworkStore.getState().setWebSocketStatus('connecting'); client.activate(); stompClientRef.current = client; @@ -52,6 +64,7 @@ export const useCrmWebSocket = (botId: number) => { if (stompClientRef.current) { stompClientRef.current.deactivate(); } + useNetworkStore.getState().setWebSocketStatus('disconnected'); }; }, [botId, queryClient]); }; diff --git a/frontend/src/store/useNetworkStore.test.ts b/frontend/src/store/useNetworkStore.test.ts new file mode 100644 index 00000000..862ccc71 --- /dev/null +++ b/frontend/src/store/useNetworkStore.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useNetworkStore } from './useNetworkStore'; + +describe('useNetworkStore', () => { + beforeEach(() => { + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'connected', + hasBeenOffline: false, + }); + }); + + it('initializes with online and connected status', () => { + const state = useNetworkStore.getState(); + expect(state.isOnline).toBe(true); + expect(state.webSocketStatus).toBe('connected'); + expect(state.hasBeenOffline).toBe(false); + }); + + it('updates isOnline and tracks hasBeenOffline flag', () => { + useNetworkStore.getState().setOnline(false); + let state = useNetworkStore.getState(); + expect(state.isOnline).toBe(false); + expect(state.hasBeenOffline).toBe(true); + + useNetworkStore.getState().setOnline(true); + state = useNetworkStore.getState(); + expect(state.isOnline).toBe(true); + expect(state.hasBeenOffline).toBe(true); + }); + + it('updates webSocketStatus correctly', () => { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + let state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('reconnecting'); + expect(state.hasBeenOffline).toBe(true); + + useNetworkStore.getState().setWebSocketStatus('connected'); + state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('connected'); + }); + + it('allows manual reset of hasBeenOffline flag', () => { + useNetworkStore.getState().setOnline(false); + expect(useNetworkStore.getState().hasBeenOffline).toBe(true); + + useNetworkStore.getState().setHasBeenOffline(false); + expect(useNetworkStore.getState().hasBeenOffline).toBe(false); + }); +}); diff --git a/frontend/src/store/useNetworkStore.ts b/frontend/src/store/useNetworkStore.ts new file mode 100644 index 00000000..2faa8687 --- /dev/null +++ b/frontend/src/store/useNetworkStore.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand'; + +export type WebSocketStatus = 'connected' | 'connecting' | 'reconnecting' | 'disconnected'; + +export interface NetworkState { + isOnline: boolean; + webSocketStatus: WebSocketStatus; + hasBeenOffline: boolean; + setOnline: (isOnline: boolean) => void; + setWebSocketStatus: (status: WebSocketStatus) => void; + setHasBeenOffline: (hasBeenOffline: boolean) => void; +} + +export const useNetworkStore = create((set) => ({ + isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, + webSocketStatus: 'connected', + hasBeenOffline: false, + + setOnline: (isOnline) => + set((state) => ({ + isOnline, + hasBeenOffline: !isOnline ? true : state.hasBeenOffline, + })), + + setWebSocketStatus: (status) => + set((state) => ({ + webSocketStatus: status, + hasBeenOffline: + status === 'disconnected' || status === 'reconnecting' + ? true + : state.hasBeenOffline, + })), + + setHasBeenOffline: (hasBeenOffline) => set({ hasBeenOffline }), +})); From 40244516b44e46163f247e2909356857554337cd Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:19:57 +0300 Subject: [PATCH 08/17] feat(frontend): implement broadcastchannel multi-tab sync and xss sanitization pipeline --- frontend/package.json | 2 + frontend/src/App.tsx | 21 ++- frontend/src/hooks/useMultiTabSync.test.ts | 67 +++++++++ frontend/src/hooks/useMultiTabSync.ts | 78 +++++++++++ .../components/StickySolutionsSection.tsx | 3 +- frontend/src/store/useAuthStore.ts | 5 +- frontend/src/store/useBotStore.ts | 4 +- frontend/src/utils/multiTabSync.test.ts | 68 +++++++++ frontend/src/utils/multiTabSync.ts | 106 ++++++++++++++ frontend/src/utils/sanitize.test.ts | 61 +++++++++ frontend/src/utils/sanitize.ts | 129 ++++++++++++++++++ 11 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/useMultiTabSync.test.ts create mode 100644 frontend/src/hooks/useMultiTabSync.ts create mode 100644 frontend/src/utils/multiTabSync.test.ts create mode 100644 frontend/src/utils/multiTabSync.ts create mode 100644 frontend/src/utils/sanitize.test.ts create mode 100644 frontend/src/utils/sanitize.ts diff --git a/frontend/package.json b/frontend/package.json index 615bf91d..dfa0e917 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@tanstack/react-virtual": "^3.13.1", "@xyflow/react": "^12.11.0", "axios": "^1.17.0", + "dompurify": "^3.2.4", "emoji-mart": "^5.6.0", "lucide-react": "^1.17.0", "react": "^19.2.6", @@ -46,6 +47,7 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.5", + "@types/dompurify": "^3.0.5", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5cdf5c7..a30e98ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,16 +5,25 @@ import { AppRouter } from './routes'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { LanguageProvider } from './i18n/config'; import { NetworkStatusBanner } from './components/common/NetworkStatusBanner'; +import { useMultiTabSync } from './hooks/useMultiTabSync'; + +const AppContent: React.FC = () => { + useMultiTabSync(); + + return ( + + + + + + + ); +}; const App: React.FC = () => { return ( - - - - - - + ); }; diff --git a/frontend/src/hooks/useMultiTabSync.test.ts b/frontend/src/hooks/useMultiTabSync.test.ts new file mode 100644 index 00000000..52a7ad47 --- /dev/null +++ b/frontend/src/hooks/useMultiTabSync.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMultiTabSync } from './useMultiTabSync'; +import { useAuthStore } from '../store/useAuthStore'; +import { useBotStore } from '../store/useBotStore'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe('useMultiTabSync', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ + accessToken: 'initial_token', + refreshToken: 'initial_refresh', + user: { id: 1, email: 'test@launchly.com' } as never, + }); + useBotStore.setState({ activeBotId: 1 }); + }); + + it('synchronizes logout event from external tab', () => { + renderHook(() => useMultiTabSync(), { wrapper: createWrapper() }); + + act(() => { + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify({ + type: 'AUTH_LOGOUT', + senderTabId: 'other-tab-id', + timestamp: Date.now(), + }), + }); + window.dispatchEvent(storageEvent); + }); + + const state = useAuthStore.getState(); + expect(state.accessToken).toBeNull(); + expect(state.user).toBeNull(); + expect(useBotStore.getState().activeBotId).toBeNull(); + }); + + it('synchronizes bot change event from external tab', () => { + renderHook(() => useMultiTabSync(), { wrapper: createWrapper() }); + + act(() => { + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify({ + type: 'BOT_CHANGED', + payload: { botId: 42 }, + senderTabId: 'other-tab-id', + timestamp: Date.now(), + }), + }); + window.dispatchEvent(storageEvent); + }); + + expect(useBotStore.getState().activeBotId).toBe(42); + }); +}); diff --git a/frontend/src/hooks/useMultiTabSync.ts b/frontend/src/hooks/useMultiTabSync.ts new file mode 100644 index 00000000..9f9558d4 --- /dev/null +++ b/frontend/src/hooks/useMultiTabSync.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { subscribeToSyncEvents, type SyncMessage } from '../utils/multiTabSync'; +import { useAuthStore } from '../store/useAuthStore'; +import { useBotStore } from '../store/useBotStore'; + +export const useMultiTabSync = (): void => { + const queryClient = useQueryClient(); + + useEffect(() => { + const unsubscribe = subscribeToSyncEvents((message: SyncMessage) => { + switch (message.type) { + case 'AUTH_LOGOUT': { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + useAuthStore.setState({ + accessToken: null, + refreshToken: null, + user: null, + }); + useBotStore.getState().clearBots(); + queryClient.clear(); + if ( + typeof window !== 'undefined' && + !window.location.pathname.startsWith('/login') && + !window.location.pathname.startsWith('/register') && + window.location.pathname !== '/' + ) { + window.location.href = '/login'; + } + break; + } + + case 'AUTH_LOGIN': { + const payload = message.payload as { + accessToken: string; + refreshToken: string; + user: unknown; + }; + if (payload?.accessToken) { + useAuthStore.setState({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + user: payload.user as never, + }); + queryClient.invalidateQueries(); + } + break; + } + + case 'BOT_CHANGED': { + const payload = message.payload as { botId: number | null }; + if (payload && payload.botId !== undefined) { + useBotStore.setState({ activeBotId: payload.botId }); + queryClient.invalidateQueries({ queryKey: ['bot', payload.botId] }); + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['leads'] }); + queryClient.invalidateQueries({ queryKey: ['orders'] }); + } + break; + } + + case 'SYNC_QUERY_INVALIDATE': { + const payload = message.payload as { queryKey: string[] }; + if (payload?.queryKey) { + queryClient.invalidateQueries({ queryKey: payload.queryKey }); + } + break; + } + } + }); + + return () => { + unsubscribe(); + }; + }, [queryClient]); +}; diff --git a/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx b/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx index 325f1dab..375afc21 100644 --- a/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx +++ b/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx @@ -14,6 +14,7 @@ import { Mic, } from 'lucide-react'; import { useTranslation } from '../../../../i18n/config'; +import { createSafeHtml } from '../../../../utils/sanitize'; interface StickySolutionsSectionProps { onCtaClick: () => void; @@ -454,7 +455,7 @@ export const StickySolutionsSection: React.FC = ({ : 'bg-[#182533] text-white rounded-2xl rounded-tl-xs shadow-xs border border-white/5' }`} > -
$1') }} /> +
$1'))} />
((set) => { const savedAccessToken = localStorage.getItem('accessToken'); const savedRefreshToken = localStorage.getItem('refreshToken'); const savedUserJson = localStorage.getItem('user'); - + let savedUser: User | null = null; if (savedUserJson) { try { @@ -26,6 +27,7 @@ export const useAuthStore = create((set) => { localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', refreshToken); localStorage.setItem('user', JSON.stringify(user)); + broadcastEvent('AUTH_LOGIN', { accessToken, refreshToken, user }); set({ accessToken, refreshToken, @@ -39,6 +41,7 @@ export const useAuthStore = create((set) => { localStorage.removeItem('user'); useBotStore.getState().clearBots(); queryClient.clear(); + broadcastEvent('AUTH_LOGOUT'); set({ accessToken: null, refreshToken: null, diff --git a/frontend/src/store/useBotStore.ts b/frontend/src/store/useBotStore.ts index cf27a0fc..704ceb39 100644 --- a/frontend/src/store/useBotStore.ts +++ b/frontend/src/store/useBotStore.ts @@ -1,10 +1,11 @@ import { create } from 'zustand'; import type { BotState } from '../types/bot'; +import { broadcastEvent } from '../utils/multiTabSync'; export const useBotStore = create((set) => { const savedActiveBotIdStr = localStorage.getItem('activeBotId'); let savedActiveBotId: number | null = null; - + if (savedActiveBotIdStr) { const parsed = parseInt(savedActiveBotIdStr, 10); if (!isNaN(parsed)) { @@ -23,6 +24,7 @@ export const useBotStore = create((set) => { } else { localStorage.removeItem('activeBotId'); } + broadcastEvent('BOT_CHANGED', { botId: id }); set({ activeBotId: id }); }, diff --git a/frontend/src/utils/multiTabSync.test.ts b/frontend/src/utils/multiTabSync.test.ts new file mode 100644 index 00000000..d5baa959 --- /dev/null +++ b/frontend/src/utils/multiTabSync.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + broadcastEvent, + subscribeToSyncEvents, + getCurrentTabId, + type SyncMessage, +} from './multiTabSync'; + +describe('multiTabSync utility', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('provides a unique tab identifier', () => { + const tabId = getCurrentTabId(); + expect(tabId).toBeDefined(); + expect(typeof tabId).toBe('string'); + expect(tabId.length).toBeGreaterThan(5); + }); + + it('broadcasts event and notifies storage listener from different tab', () => { + const mockListener = vi.fn(); + const unsubscribe = subscribeToSyncEvents(mockListener); + + const externalMessage: SyncMessage = { + type: 'AUTH_LOGOUT', + payload: { reason: 'session_expired' }, + senderTabId: 'different-tab-id-999', + timestamp: Date.now(), + }; + + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify(externalMessage), + }); + + window.dispatchEvent(storageEvent); + + expect(mockListener).toHaveBeenCalledWith(externalMessage); + unsubscribe(); + }); + + it('ignores storage events originating from the same tab', () => { + const mockListener = vi.fn(); + const unsubscribe = subscribeToSyncEvents(mockListener); + + const sameTabMessage: SyncMessage = { + type: 'BOT_CHANGED', + payload: { botId: 5 }, + senderTabId: getCurrentTabId(), + timestamp: Date.now(), + }; + + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify(sameTabMessage), + }); + + window.dispatchEvent(storageEvent); + + expect(mockListener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('broadcasts events to localStorage without throwing', () => { + expect(() => broadcastEvent('BOT_CHANGED', { botId: 10 })).not.toThrow(); + }); +}); diff --git a/frontend/src/utils/multiTabSync.ts b/frontend/src/utils/multiTabSync.ts new file mode 100644 index 00000000..ce2a548f --- /dev/null +++ b/frontend/src/utils/multiTabSync.ts @@ -0,0 +1,106 @@ +export type SyncEventType = + | 'AUTH_LOGOUT' + | 'AUTH_LOGIN' + | 'BOT_CHANGED' + | 'SYNC_QUERY_INVALIDATE'; + +export interface SyncMessage { + type: SyncEventType; + payload?: T; + senderTabId: string; + timestamp: number; +} + +const CHANNEL_NAME = 'launchly_multitab_sync'; +const FALLBACK_STORAGE_KEY = 'launchly_multitab_sync_event'; + +const CURRENT_TAB_ID = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).substring(2, 9) + Date.now().toString(36); + +let sharedChannel: BroadcastChannel | null = null; + +const getChannel = (): BroadcastChannel | null => { + if (typeof BroadcastChannel === 'undefined') { + return null; + } + if (!sharedChannel) { + try { + sharedChannel = new BroadcastChannel(CHANNEL_NAME); + } catch { + sharedChannel = null; + } + } + return sharedChannel; +}; + +export const broadcastEvent = (type: SyncEventType, payload?: T): void => { + const message: SyncMessage = { + type, + payload, + senderTabId: CURRENT_TAB_ID, + timestamp: Date.now(), + }; + + const channel = getChannel(); + if (channel) { + try { + channel.postMessage(message); + } catch { + } + } + + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(message)); + } catch { + } + } +}; + +export const subscribeToSyncEvents = ( + listener: (message: SyncMessage) => void +): (() => void) => { + const channel = getChannel(); + + const handleChannelMessage = (event: MessageEvent) => { + if (!event.data || event.data.senderTabId === CURRENT_TAB_ID) { + return; + } + listener(event.data); + }; + + const handleStorageMessage = (event: StorageEvent) => { + if (event.key !== FALLBACK_STORAGE_KEY || !event.newValue) { + return; + } + try { + const message = JSON.parse(event.newValue) as SyncMessage; + if (message.senderTabId === CURRENT_TAB_ID) { + return; + } + listener(message); + } catch { + } + }; + + if (channel) { + channel.addEventListener('message', handleChannelMessage); + } + + if (typeof window !== 'undefined') { + window.addEventListener('storage', handleStorageMessage); + } + + return () => { + if (channel) { + channel.removeEventListener('message', handleChannelMessage); + } + if (typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorageMessage); + } + }; +}; + +export const getCurrentTabId = (): string => CURRENT_TAB_ID; diff --git a/frontend/src/utils/sanitize.test.ts b/frontend/src/utils/sanitize.test.ts new file mode 100644 index 00000000..1d742546 --- /dev/null +++ b/frontend/src/utils/sanitize.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeHtml, sanitizeUrl, escapeHtml, createSafeHtml } from './sanitize'; + +describe('sanitize utility', () => { + it('escapes html entities correctly', () => { + const raw = '
&\'"
'; + const escaped = escapeHtml(raw); + expect(escaped).toBe('<div class="test">&'"</div>'); + }); + + it('sanitizes dangerous URLs and allows safe protocols', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBe(''); + expect(sanitizeUrl('data:text/html,')).toBe(''); + expect(sanitizeUrl('vbscript:msgbox(1)')).toBe(''); + expect(sanitizeUrl('https://launchly.app/docs')).toBe('https://launchly.app/docs'); + expect(sanitizeUrl('http://example.com')).toBe('http://example.com'); + expect(sanitizeUrl('/dashboard/chat')).toBe('/dashboard/chat'); + expect(sanitizeUrl('mailto:support@launchly.app')).toBe('mailto:support@launchly.app'); + expect(sanitizeUrl('tel:+380501234567')).toBe('tel:+380501234567'); + }); + + it('strips script tags and executable attributes from HTML', () => { + const malicious = '

Hello World

'; + const sanitized = sanitizeHtml(malicious); + expect(sanitized).not.toContain(''; + const safeObject = createSafeHtml(dirty); + expect(safeObject).toEqual({ __html: 'Test' }); + }); +}); diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts new file mode 100644 index 00000000..afae656c --- /dev/null +++ b/frontend/src/utils/sanitize.ts @@ -0,0 +1,129 @@ +export interface SanitizeOptions { + allowedTags?: string[]; + allowedAttributes?: string[]; + allowedProtocols?: string[]; +} + +const DEFAULT_ALLOWED_TAGS = new Set([ + 'b', + 'strong', + 'i', + 'em', + 'u', + 's', + 'strike', + 'p', + 'br', + 'span', + 'code', + 'pre', + 'ul', + 'ol', + 'li', + 'blockquote', + 'a', +]); + +const DEFAULT_ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); + +export const escapeHtml = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +export const sanitizeUrl = (url: string): string => { + if (!url) return ''; + const trimmed = url.trim(); + + if (trimmed.startsWith('/') || trimmed.startsWith('#')) { + return trimmed; + } + + try { + const parsed = new URL(trimmed, 'https://launchly.app'); + if (DEFAULT_ALLOWED_PROTOCOLS.has(parsed.protocol)) { + return trimmed; + } + } catch { + return ''; + } + + return ''; +}; + +export const sanitizeHtml = (dirtyHtml: string, options?: SanitizeOptions): string => { + if (!dirtyHtml) return ''; + + const allowedTags = options?.allowedTags + ? new Set(options.allowedTags.map((t) => t.toLowerCase())) + : DEFAULT_ALLOWED_TAGS; + + if (typeof DOMParser === 'undefined') { + return escapeHtml(dirtyHtml); + } + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(dirtyHtml, 'text/html'); + + const cleanNode = (node: Node) => { + const children = Array.from(node.childNodes); + for (const child of children) { + if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (!allowedTags.has(tagName)) { + element.remove(); + continue; + } + + const attributes = Array.from(element.attributes); + for (const attr of attributes) { + const attrName = attr.name.toLowerCase(); + + if (attrName.startsWith('on') || attrName === 'style') { + element.removeAttribute(attr.name); + continue; + } + + if (tagName === 'a' && attrName === 'href') { + const safeHref = sanitizeUrl(attr.value); + if (!safeHref) { + element.removeAttribute('href'); + } else { + element.setAttribute('href', safeHref); + element.setAttribute('rel', 'noopener noreferrer'); + element.setAttribute('target', '_blank'); + } + } else if (attrName !== 'class' && attrName !== 'title') { + element.removeAttribute(attr.name); + } + } + + cleanNode(element); + } else if (child.nodeType !== Node.TEXT_NODE) { + child.remove(); + } + } + }; + + cleanNode(doc.body); + return doc.body.innerHTML; + } catch { + return escapeHtml(dirtyHtml); + } +}; + +export const createSafeHtml = ( + dirtyHtml: string, + options?: SanitizeOptions +): { __html: string } => { + return { + __html: sanitizeHtml(dirtyHtml, options), + }; +}; From b9178733446f4b272e24845a6c2a31ea31bb47d3 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:25:39 +0300 Subject: [PATCH 09/17] feat(frontend): implement request cancellation with abortcontroller and zero cls skeleton screens --- frontend/src/api/axios.test.ts | 15 +++ frontend/src/api/axios.ts | 23 +++- .../src/components/common/Skeleton.test.tsx | 37 +++++++ frontend/src/components/common/Skeleton.tsx | 101 ++++++++++++++++++ .../pages/admin/AdminLogs/AdminLogsPage.tsx | 5 +- .../owner/Chat/components/ContactsTable.tsx | 5 +- .../Chat/components/ConversationList.test.tsx | 2 +- .../Chat/components/ConversationList.tsx | 5 +- .../owner/Chat/components/MessageArea.tsx | 3 +- .../src/utils/requestCancellation.test.ts | 83 ++++++++++++++ frontend/src/utils/requestCancellation.ts | 76 +++++++++++++ 11 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/common/Skeleton.test.tsx create mode 100644 frontend/src/components/common/Skeleton.tsx create mode 100644 frontend/src/utils/requestCancellation.test.ts create mode 100644 frontend/src/utils/requestCancellation.ts diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts index 000a2969..eecf2b66 100644 --- a/frontend/src/api/axios.test.ts +++ b/frontend/src/api/axios.test.ts @@ -129,4 +129,19 @@ describe('apiClient request interceptor', () => { expect(authHeader).toBe('Bearer sample-jwt-token'); }); + + it('should automatically attach AbortSignal to search GET requests', async () => { + let capturedConfig: any = null; + + vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { + capturedConfig = config; + return { data: [], status: 200, statusText: 'OK', headers: {}, config }; + }); + + await apiClient.get('/search-items', { params: { search: 'launchly' } }); + + expect(capturedConfig).not.toBeNull(); + expect(capturedConfig.signal).toBeDefined(); + expect(capturedConfig.signal.aborted).toBe(false); + }); }); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 3e89a6f6..74cac831 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -6,6 +6,12 @@ import { shouldAttachIdempotencyKey, } from '../utils/idempotency'; +import { + registerRequest, + removePendingRequest, + isRequestCanceled, +} from '../utils/requestCancellation'; + const apiClient = axios.create({ baseURL: '/api/v1', headers: { @@ -34,7 +40,7 @@ apiClient.interceptors.request.use( } } - return config; + return registerRequest(config); }, (error) => Promise.reject(error) ); @@ -54,8 +60,21 @@ const processQueue = (error: unknown, token: string | null = null) => { }; apiClient.interceptors.response.use( - (response) => response, + (response) => { + if (response.config) { + removePendingRequest(response.config); + } + return response; + }, async (error) => { + if (error.config) { + removePendingRequest(error.config); + } + + if (isRequestCanceled(error)) { + return Promise.reject(error); + } + const originalRequest = error.config; if (error.response?.status === 403 || error.response?.data?.error === 'ACCOUNT_BLOCKED') { const reason = error.response?.data?.reason || 'Violation of platform rules'; diff --git a/frontend/src/components/common/Skeleton.test.tsx b/frontend/src/components/common/Skeleton.test.tsx new file mode 100644 index 00000000..f43b52b8 --- /dev/null +++ b/frontend/src/components/common/Skeleton.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { + Skeleton, + ConversationListSkeleton, + MessageAreaSkeleton, + TableSkeleton, +} from './Skeleton'; +import React from 'react'; + +describe('Skeleton components', () => { + it('renders basic Skeleton element with custom className', () => { + render(); + const el = screen.getByTestId('skeleton'); + expect(el).toBeInTheDocument(); + expect(el).toHaveClass('custom-skeleton'); + expect(el).toHaveClass('animate-pulse'); + }); + + it('renders ConversationListSkeleton with placeholder items', () => { + const { container } = render(); + const items = container.querySelectorAll('.w-full.px-4.py-3'); + expect(items.length).toBe(6); + }); + + it('renders MessageAreaSkeleton with chat bubble placeholders', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + expect(container.querySelectorAll('.rounded-2xl').length).toBeGreaterThan(1); + }); + + it('renders TableSkeleton with specified rows and columns', () => { + const { container } = render(); + const rows = container.querySelectorAll('.h-16.px-6'); + expect(rows.length).toBe(4); + }); +}); diff --git a/frontend/src/components/common/Skeleton.tsx b/frontend/src/components/common/Skeleton.tsx new file mode 100644 index 00000000..58756bd5 --- /dev/null +++ b/frontend/src/components/common/Skeleton.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +interface SkeletonProps extends React.HTMLAttributes { + className?: string; +} + +export const Skeleton: React.FC = ({ className = '', ...props }) => { + return ( +
+ ); +}; + +export const ConversationListSkeleton: React.FC = () => { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; + +export const MessageAreaSkeleton: React.FC = () => { + return ( +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ ); +}; + +interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +export const TableSkeleton: React.FC = ({ rows = 5, columns = 5 }) => { + return ( +
+
+ {Array.from({ length: columns }).map((_, i) => ( +
+ ))} +
+
+ {Array.from({ length: rows }).map((_, r) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx index 51f645e4..ff42ff0b 100644 --- a/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx @@ -8,6 +8,7 @@ import { useTranslation } from '../../../i18n/config'; import { useAuthStore } from '../../../store/useAuthStore'; import { Navigate } from 'react-router-dom'; import { ROUTES } from '../../../routes/paths'; +import { TableSkeleton } from '../../../components/common/Skeleton'; export const AdminLogsPage: React.FC = () => { const { t } = useTranslation(); @@ -288,9 +289,7 @@ export const AdminLogsPage: React.FC = () => {
{isLoading ? ( -
- -
+ ) : (
diff --git a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx index 25933c63..84e259a3 100644 --- a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx +++ b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx @@ -5,6 +5,7 @@ import type { BotUserResponse } from '../../../../types/bot'; import { ContactAvatar } from './ContactAvatar'; import { t } from '../../../../i18n/config'; import { useVirtualList } from '../../../../hooks/useVirtualList'; +import { TableSkeleton } from '../../../../components/common/Skeleton'; interface ContactsTableProps { botId: number; @@ -86,8 +87,8 @@ export const ContactsTable: React.FC = ({ if (isContactsLoading) { return ( -
- +
+
); } diff --git a/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx b/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx index 7f97a493..92b60b07 100644 --- a/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx +++ b/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx @@ -30,7 +30,7 @@ describe('ConversationList', () => { chatFilter="all" /> ); - expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); }); it('renders conversation items', () => { diff --git a/frontend/src/pages/owner/Chat/components/ConversationList.tsx b/frontend/src/pages/owner/Chat/components/ConversationList.tsx index 28b0a871..f84a543d 100644 --- a/frontend/src/pages/owner/Chat/components/ConversationList.tsx +++ b/frontend/src/pages/owner/Chat/components/ConversationList.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Loader2, Heart } from 'lucide-react'; +import { Heart } from 'lucide-react'; import type { ConversationResponse } from '../../../../types/crm'; import { UserAvatar } from './UserAvatar'; import { timeAgo } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; import { useVirtualList } from '../../../../hooks/useVirtualList'; +import { ConversationListSkeleton } from '../../../../components/common/Skeleton'; interface ConversationListProps { conversations: ConversationResponse[]; @@ -39,7 +40,7 @@ export const ConversationList: React.FC = ({
{isLoading ? ( -
+ ) : conversations.length === 0 ? (
{searchQuery diff --git a/frontend/src/pages/owner/Chat/components/MessageArea.tsx b/frontend/src/pages/owner/Chat/components/MessageArea.tsx index 77954149..11650bc0 100644 --- a/frontend/src/pages/owner/Chat/components/MessageArea.tsx +++ b/frontend/src/pages/owner/Chat/components/MessageArea.tsx @@ -13,6 +13,7 @@ import { ChatToolbar } from './ChatToolbar'; import { formatDateSeparator, getDateKey } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; import { useVirtualList } from '../../../../hooks/useVirtualList'; +import { MessageAreaSkeleton } from '../../../../components/common/Skeleton'; interface MessageAreaProps { conversation: ConversationResponse | null; @@ -162,7 +163,7 @@ export const MessageArea: React.FC = ({
{isMsgLoading ? ( -
+ ) : flatItems.length === 0 ? (
No messages in this conversation.
) : ( diff --git a/frontend/src/utils/requestCancellation.test.ts b/frontend/src/utils/requestCancellation.test.ts new file mode 100644 index 00000000..d61a4ffb --- /dev/null +++ b/frontend/src/utils/requestCancellation.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getRequestSignature, + cancelPendingRequest, + registerRequest, + removePendingRequest, + isRequestCanceled, + createAbortController, + clearAllPendingRequests, +} from './requestCancellation'; +import type { InternalAxiosRequestConfig } from 'axios'; + +describe('requestCancellation utility', () => { + beforeEach(() => { + clearAllPendingRequests(); + }); + + it('generates consistent request signature', () => { + const config = { + method: 'get', + url: '/crm/conversations', + params: { page: 1, search: 'test' }, + } as unknown as InternalAxiosRequestConfig; + + const signature = getRequestSignature(config); + expect(signature).toBe('GET:/crm/conversations:{"page":1,"search":"test"}'); + }); + + it('auto-registers and cancels superseded search queries', () => { + const config1 = { + method: 'get', + url: '/crm/conversations', + params: { search: 'al' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config1); + expect(config1.signal).toBeDefined(); + expect(config1.signal?.aborted).toBe(false); + + const config2 = { + method: 'get', + url: '/crm/conversations', + params: { search: 'al' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config2); + expect(config1.signal?.aborted).toBe(true); + expect(config2.signal?.aborted).toBe(false); + }); + + it('removes pending request on completion', () => { + const config = { + method: 'get', + url: '/crm/conversations', + params: { search: 'test' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config); + removePendingRequest(config); + + const signature = getRequestSignature(config); + expect(() => cancelPendingRequest(signature)).not.toThrow(); + }); + + it('correctly identifies canceled errors', () => { + const canceledErr = { name: 'CanceledError' }; + const abortErr = { name: 'AbortError' }; + const codeErr = { code: 'ERR_CANCELED' }; + const regularErr = new Error('Regular error'); + + expect(isRequestCanceled(canceledErr)).toBe(true); + expect(isRequestCanceled(abortErr)).toBe(true); + expect(isRequestCanceled(codeErr)).toBe(true); + expect(isRequestCanceled(regularErr)).toBe(false); + }); + + it('creates standalone abort controller', () => { + const { signal, abort } = createAbortController(); + expect(signal.aborted).toBe(false); + abort('manual stop'); + expect(signal.aborted).toBe(true); + }); +}); diff --git a/frontend/src/utils/requestCancellation.ts b/frontend/src/utils/requestCancellation.ts new file mode 100644 index 00000000..759c0ffc --- /dev/null +++ b/frontend/src/utils/requestCancellation.ts @@ -0,0 +1,76 @@ +import axios, { type InternalAxiosRequestConfig } from 'axios'; + +const pendingRequests = new Map(); + +export const getRequestSignature = (config: InternalAxiosRequestConfig): string => { + const method = (config.method || 'get').toUpperCase(); + const url = config.url || ''; + const params = config.params ? JSON.stringify(config.params) : ''; + return `${method}:${url}:${params}`; +}; + +export const cancelPendingRequest = (signature: string): void => { + const existingController = pendingRequests.get(signature); + if (existingController) { + existingController.abort('Request superseded by newer request'); + pendingRequests.delete(signature); + } +}; + +export const registerRequest = ( + config: InternalAxiosRequestConfig, + autoCancelDuplicate: boolean = false +): InternalAxiosRequestConfig => { + if (config.signal) { + return config; + } + + const isGet = (config.method || 'get').toUpperCase() === 'GET'; + const isSearchQuery = + config.params && ('query' in config.params || 'search' in config.params || 'q' in config.params); + + if (autoCancelDuplicate || (isGet && isSearchQuery)) { + const signature = getRequestSignature(config); + cancelPendingRequest(signature); + + const controller = new AbortController(); + config.signal = controller.signal; + pendingRequests.set(signature, controller); + } + + return config; +}; + +export const removePendingRequest = (config: InternalAxiosRequestConfig): void => { + const signature = getRequestSignature(config); + pendingRequests.delete(signature); +}; + +export const isRequestCanceled = (error: unknown): boolean => { + if (axios.isCancel(error)) { + return true; + } + if (error && typeof error === 'object') { + const err = error as { name?: string; code?: string }; + return err.name === 'CanceledError' || err.name === 'AbortError' || err.code === 'ERR_CANCELED'; + } + return false; +}; + +export const createAbortController = (): { + signal: AbortSignal; + abort: (reason?: string) => void; +} => { + const controller = new AbortController(); + return { + signal: controller.signal, + abort: (reason) => controller.abort(reason), + }; +}; + +export const clearAllPendingRequests = (): void => { + pendingRequests.forEach((controller) => { + controller.abort('All requests aborted'); + }); + pendingRequests.clear(); +}; From 4b78352818761d89551753c16a9cf30041909ba5 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:37:12 +0300 Subject: [PATCH 10/17] fix(frontend): restore missing closing brace in bot api import in useCrmQueries --- frontend/src/hooks/crm/useCrmQueries.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/crm/useCrmQueries.ts b/frontend/src/hooks/crm/useCrmQueries.ts index 4eaa16fa..87a3c4c1 100644 --- a/frontend/src/hooks/crm/useCrmQueries.ts +++ b/frontend/src/hooks/crm/useCrmQueries.ts @@ -19,6 +19,7 @@ import { updateBotUserApi, createBotUserApi, deleteBotUserApi, +} from '../../api/bot'; import type { OrderStatus, LeadStatus, From 59bef7fb5b9acf46df569be0fd9d5224d5c26bb3 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:39:17 +0300 Subject: [PATCH 11/17] fix(support): correct EntityGraph attribute path from assignedTo to assignedManager in SupportTicketRepository --- .../admin/repository/SupportTicketRepository.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java b/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java index 39f0a6bb..1351b562 100644 --- a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java +++ b/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java @@ -15,27 +15,27 @@ @Repository public interface SupportTicketRepository extends JpaRepository, JpaSpecificationExecutor { - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) @Query("SELECT t FROM SupportTicket t WHERE t.user.id = :userId ORDER BY t.updatedAt DESC") Optional findFirstByUserId(@Param("userId") Long userId); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByUserId(Long userId, Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByStatus(String status, Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByUnreadForAdminTrue(Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByIsFavoriteTrue(Pageable pageable); @Override - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Optional findById(Long id); @Override - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findAll(Specification spec, Pageable pageable); } From 9989298446a39a41f02ee16262b35cf89ee9abdc Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:43:05 +0300 Subject: [PATCH 12/17] fix(frontend): prevent false positive websocket reconnection banner on tab change and unmount --- .../common/NetworkStatusBanner.test.tsx | 25 +++++++++++++++++-- .../components/common/NetworkStatusBanner.tsx | 23 +++++++++++------ frontend/src/hooks/crm/useCrmWebSocket.ts | 19 +++++++++++--- frontend/src/store/useNetworkStore.test.ts | 5 ++++ frontend/src/store/useNetworkStore.ts | 4 ++- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/common/NetworkStatusBanner.test.tsx b/frontend/src/components/common/NetworkStatusBanner.test.tsx index 929515ca..e7ff7b74 100644 --- a/frontend/src/components/common/NetworkStatusBanner.test.tsx +++ b/frontend/src/components/common/NetworkStatusBanner.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { NetworkStatusBanner } from './NetworkStatusBanner'; import { useNetworkStore } from '../../store/useNetworkStore'; @@ -27,6 +27,10 @@ describe('NetworkStatusBanner', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('renders nothing when online and connected', () => { const { container } = render(, { wrapper: createWrapper() }); expect(container.firstChild).toBeNull(); @@ -42,20 +46,37 @@ describe('NetworkStatusBanner', () => { ).toBeInTheDocument(); }); - it('renders reconnecting alert when webSocketStatus is reconnecting after being offline', () => { + it('renders reconnecting alert after debounce when webSocketStatus is reconnecting after being offline', () => { + vi.useFakeTimers(); useNetworkStore.setState({ isOnline: true, webSocketStatus: 'reconnecting', hasBeenOffline: true, }); + render(, { wrapper: createWrapper() }); + act(() => { + vi.advanceTimersByTime(1300); + }); + expect(screen.getByRole('alert')).toBeInTheDocument(); expect( screen.getByText(/З'єднання в реальному часі втрачено/i) ).toBeInTheDocument(); }); + it('does not render alert when webSocketStatus is disconnected', () => { + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'disconnected', + hasBeenOffline: false, + }); + + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); + it('shows connection restored status on online event', () => { render(, { wrapper: createWrapper() }); diff --git a/frontend/src/components/common/NetworkStatusBanner.tsx b/frontend/src/components/common/NetworkStatusBanner.tsx index 44bf895c..3957b7c5 100644 --- a/frontend/src/components/common/NetworkStatusBanner.tsx +++ b/frontend/src/components/common/NetworkStatusBanner.tsx @@ -9,6 +9,18 @@ export const NetworkStatusBanner: React.FC = () => { const { isOnline, webSocketStatus, hasBeenOffline, setOnline, setHasBeenOffline } = useNetworkStore(); const [showRestored, setShowRestored] = useState(false); const [isRetrying, setIsRetrying] = useState(false); + const [debouncedReconnecting, setDebouncedReconnecting] = useState(false); + + useEffect(() => { + if (webSocketStatus === 'reconnecting') { + const timer = setTimeout(() => { + setDebouncedReconnecting(true); + }, 1200); + return () => clearTimeout(timer); + } else { + setDebouncedReconnecting(false); + } + }, [webSocketStatus]); const handleOnline = useCallback(() => { setOnline(true); @@ -43,11 +55,12 @@ export const NetworkStatusBanner: React.FC = () => { try { if (typeof navigator !== 'undefined' && navigator.onLine) { setOnline(true); + setHasBeenOffline(false); + setDebouncedReconnecting(false); await queryClient.invalidateQueries(); setShowRestored(true); setTimeout(() => { setShowRestored(false); - setHasBeenOffline(false); }, 3000); } } finally { @@ -90,11 +103,7 @@ export const NetworkStatusBanner: React.FC = () => { ); } - if ( - isOnline && - (webSocketStatus === 'reconnecting' || webSocketStatus === 'disconnected') && - hasBeenOffline - ) { + if (isOnline && debouncedReconnecting && hasBeenOffline) { return (
{ diff --git a/frontend/src/hooks/crm/useCrmWebSocket.ts b/frontend/src/hooks/crm/useCrmWebSocket.ts index 6af59f31..4fab3400 100644 --- a/frontend/src/hooks/crm/useCrmWebSocket.ts +++ b/frontend/src/hooks/crm/useCrmWebSocket.ts @@ -7,10 +7,12 @@ import { useNetworkStore } from '../../store/useNetworkStore'; export const useCrmWebSocket = (botId: number) => { const queryClient = useQueryClient(); const stompClientRef = useRef(null); + const isIntentionalDisconnectRef = useRef(false); useEffect(() => { if (!botId || botId <= 0) return; + isIntentionalDisconnectRef.current = false; const socket = new SockJS('/ws'); const client = new Client({ webSocketFactory: () => socket, @@ -19,16 +21,24 @@ export const useCrmWebSocket = (botId: number) => { heartbeatIncoming: 10000, heartbeatOutgoing: 10000, onWebSocketClose: () => { - useNetworkStore.getState().setWebSocketStatus('reconnecting'); + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, onWebSocketError: () => { - useNetworkStore.getState().setWebSocketStatus('reconnecting'); + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, onStompError: () => { - useNetworkStore.getState().setWebSocketStatus('reconnecting'); + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, onDisconnect: () => { - useNetworkStore.getState().setWebSocketStatus('disconnected'); + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, }); @@ -61,6 +71,7 @@ export const useCrmWebSocket = (botId: number) => { stompClientRef.current = client; return () => { + isIntentionalDisconnectRef.current = true; if (stompClientRef.current) { stompClientRef.current.deactivate(); } diff --git a/frontend/src/store/useNetworkStore.test.ts b/frontend/src/store/useNetworkStore.test.ts index 862ccc71..d4a2fae1 100644 --- a/frontend/src/store/useNetworkStore.test.ts +++ b/frontend/src/store/useNetworkStore.test.ts @@ -38,6 +38,11 @@ describe('useNetworkStore', () => { useNetworkStore.getState().setWebSocketStatus('connected'); state = useNetworkStore.getState(); expect(state.webSocketStatus).toBe('connected'); + + useNetworkStore.getState().setWebSocketStatus('disconnected'); + state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('disconnected'); + expect(state.hasBeenOffline).toBe(false); }); it('allows manual reset of hasBeenOffline flag', () => { diff --git a/frontend/src/store/useNetworkStore.ts b/frontend/src/store/useNetworkStore.ts index 2faa8687..7297261b 100644 --- a/frontend/src/store/useNetworkStore.ts +++ b/frontend/src/store/useNetworkStore.ts @@ -26,8 +26,10 @@ export const useNetworkStore = create((set) => ({ set((state) => ({ webSocketStatus: status, hasBeenOffline: - status === 'disconnected' || status === 'reconnecting' + status === 'reconnecting' ? true + : status === 'disconnected' + ? false : state.hasBeenOffline, })), From e5cae1fc77de7406b807a817acf5de40b6e0a2a7 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:51:28 +0300 Subject: [PATCH 13/17] fix(crm): restore natural message flow and auto-scroll in MessageArea --- .../owner/Chat/components/MessageArea.tsx | 151 +++++++----------- 1 file changed, 54 insertions(+), 97 deletions(-) diff --git a/frontend/src/pages/owner/Chat/components/MessageArea.tsx b/frontend/src/pages/owner/Chat/components/MessageArea.tsx index 11650bc0..cc650da7 100644 --- a/frontend/src/pages/owner/Chat/components/MessageArea.tsx +++ b/frontend/src/pages/owner/Chat/components/MessageArea.tsx @@ -1,6 +1,5 @@ -import React, { useMemo, useEffect, useCallback } from 'react'; +import React, { useMemo, useRef, useEffect, useCallback } from 'react'; import { - Loader2, MessageSquare, ExternalLink, } from 'lucide-react'; @@ -12,7 +11,6 @@ import { MessageBubble } from './MessageBubble'; import { ChatToolbar } from './ChatToolbar'; import { formatDateSeparator, getDateKey } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; -import { useVirtualList } from '../../../../hooks/useVirtualList'; import { MessageAreaSkeleton } from '../../../../components/common/Skeleton'; interface MessageAreaProps { @@ -38,10 +36,6 @@ interface MessageAreaProps { meta?: Record; } -type FlatChatMessage = - | { type: 'date'; key: string; date: string } - | { type: 'message'; key: string; message: MessageResponse }; - export const MessageArea: React.FC = ({ conversation, botUser, @@ -64,54 +58,45 @@ export const MessageArea: React.FC = ({ onDeleteGlobalLabel = () => {}, meta = {}, }) => { - const flatItems = useMemo(() => { - const items: FlatChatMessage[] = []; + const messagesEndRef = useRef(null); + + const scrollToBottom = useCallback((behavior: 'auto' | 'smooth' = 'auto') => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }, []); + + useEffect(() => { + if (!conversation) return; + + scrollToBottom('auto'); + const t1 = setTimeout(() => scrollToBottom('auto'), 50); + const t2 = setTimeout(() => scrollToBottom('auto'), 150); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + }, [messages.length, conversation?.id, scrollToBottom]); + + const handleImageLoad = () => { + scrollToBottom('auto'); + }; + + const groupedMessages = useMemo(() => { + const groups: { date: string; msgs: MessageResponse[] }[] = []; let currentDate = ''; const visibleMessages = messages.filter(m => !(m.content && m.content.startsWith('🖱️ '))); visibleMessages.forEach(m => { const dk = getDateKey(m.createdAt); if (dk !== currentDate) { currentDate = dk; - items.push({ type: 'date', key: `date-${dk}`, date: m.createdAt }); + groups.push({ date: m.createdAt, msgs: [m] }); + } else { + groups[groups.length - 1].msgs.push(m); } - items.push({ type: 'message', key: `msg-${m.id}`, message: m }); }); - return items; + return groups; }, [messages]); - const getItemHeight = useCallback((index: number): number => { - const item = flatItems[index]; - if (!item) return 72; - if (item.type === 'date') return 48; - const length = item.message.content?.length || 0; - if (length > 250) return 160; - if (length > 120) return 110; - return 72; - }, [flatItems]); - - const { parentRef, virtualItems, totalHeight, scrollToIndex } = useVirtualList({ - count: flatItems.length, - itemHeight: getItemHeight, - overscan: 6, - }); - - const handleImageLoad = () => { - if (flatItems.length > 0) { - scrollToIndex(flatItems.length - 1, 'end'); - } - }; - - useEffect(() => { - if (!conversation) return; - if (flatItems.length > 0) { - scrollToIndex(flatItems.length - 1, 'end'); - const timer = setTimeout(() => { - scrollToIndex(flatItems.length - 1, 'end'); - }, 60); - return () => clearTimeout(timer); - } - }, [flatItems.length, conversation?.id, scrollToIndex]); - if (!conversation) { return (
@@ -161,63 +146,35 @@ export const MessageArea: React.FC = ({ />
-
+
{isMsgLoading ? ( - ) : flatItems.length === 0 ? ( + ) : groupedMessages.length === 0 ? (
No messages in this conversation.
) : ( -
- {virtualItems.map(({ index, offsetTop, size }) => { - const item = flatItems[index]; - if (!item) return null; - - if (item.type === 'date') { - return ( -
- - {formatDateSeparator(item.date)} - -
- ); - } - - return ( -
- } - userAvatar={} - allMessages={messages} - onButtonClick={onButtonClick} - onImageLoad={handleImageLoad} - /> -
- ); - })} -
+ groupedMessages.map((group, gi) => ( +
+
+ + {formatDateSeparator(group.date)} + +
+ {group.msgs.map(m => ( + } + userAvatar={} + allMessages={messages} + onButtonClick={onButtonClick} + onImageLoad={handleImageLoad} + /> + ))} +
+ )) )} +
); From 2c1ed9cac8902ffb8ecafa613de52be9c7fe28d6 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 13:54:31 +0300 Subject: [PATCH 14/17] docs: update main README with enterprise frontend architecture and resilience patterns --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index af6fe9e3..8bced3a6 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,14 @@ It serves as a full-scale technical showcase of building resilient, high-concurr - **@xyflow/react (React Flow)**: Interactive visual bot graph and node constructor - **Tailwind CSS v4**: High-performance modern utility styling - **TanStack Query v5**: Server state caching, background synchronization, and optimistic mutations -- **Zustand v5**: Client-only synchronous state (auth session, canvas selection, UI theme) +- **Zustand v5**: Client-only synchronous state (auth session, canvas selection, network health) - **React Hook Form + Zod v4**: Strict type-safe form validation and DTO transformations - **React Router v7**: Nested dashboard layouts and role-based route guards - **STOMP & SockJS Client**: Real-time live chat subscriptions +- **TanStack Virtual & Custom Virtualizer**: Scalable windowing for high-volume conversation and contact lists +- **DOMPurify**: Industrial-grade XSS sanitization and URL validation pipeline +- **BroadcastChannel & Storage Events**: Real-time multi-tab session synchronization +- **Axios Idempotency & AbortController Interceptors**: Automatic idempotency injection and query deduplication - **Vitest & Testing Library**: Unit and component test suites with v8 coverage - **Playwright**: End-to-end browser automation suite @@ -117,9 +121,15 @@ Detailed backend architecture & ADRs: [docs/backend/ARCHITECTURE.md](docs/backen ### Frontend Architecture - **Layer-Based & Feature-Sliced**: Strict modular hierarchy dividing low-level UI primitives, REST API clients, custom React hooks, and domain-specific route views. - **Interactive Visual Flow Builder**: Custom node-graph editor powered by `@xyflow/react` (React Flow), featuring real-time node validation, connection rules, and seamless JSON schema serialization. -- **Strict State Separation**: Server state cached and invalidated via TanStack Query v5; UI/client state (modals, auth sessions, canvas selection) isolated in Zustand stores. +- **Strict State Separation**: Server state cached and invalidated via TanStack Query v5; UI/client state (modals, auth sessions, canvas selection, network health) isolated in Zustand stores. - **Real-Time Live Chat (STOMP / SockJS)**: Low-latency bidirectional WebSocket connection for live CRM messaging, operator typing indicators, and instant lead status updates. -- **Optimistic UI Updates**: Immediate client-side reflection of CRM lead state changes, tag assignments, and message delivery with automatic rollback on network failure. +- **Optimistic UI Updates**: Immediate client-side reflection of CRM messages, notes, lead states, tag assignments, and support tickets with automatic rollback on network failure. +- **Automatic Client Idempotency**: Transparent injection of UUID v4 `Idempotency-Key` headers on `POST`, `DELETE`, and `PATCH` requests, synchronized with backend replay-protection filters. +- **Race Condition Prevention & Request Cancellation**: Automated query deduplication and superseded search request abortion via native `AbortController` in Axios. +- **Zero-CLS Skeleton Screens**: Purpose-built Neo-Brutalist skeleton states maintaining layout stability (CLS = 0) during async data fetches. +- **Multi-Tab State Synchronization**: `BroadcastChannel` event-driven architecture synchronizing auth sessions, active bot context, and logout triggers across concurrent browser tabs. +- **Defensive XSS Pipeline**: Deep DOMPurify sanitization and safe link filtering on all dynamic markdown and HTML solutions. +- **Real-Time Network & WebSocket Health**: Sticky connection banner monitoring browser connectivity and STOMP session lifecycles with debounced auto-reconnection. - **Silent JWT Refresh Interceptor**: Axios queue mechanism ensuring transparent access token rotation on HTTP 401 without disrupting in-flight operations or prompting re-login. - **Type-Safe Validation & Form Pipelines**: Unified runtime and compile-time validation powered by Zod v4 and React Hook Form across all bot configurations and integration settings. - **Role & Tier-Aware Route Protection**: Granular client-side route guards restricting capabilities based on active user roles (`OWNER`, `OPERATOR`, `ADMIN`) and subscription tier limits. From 4e9ed10884687737700160a3f85da711878f73f5 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 14:09:26 +0300 Subject: [PATCH 15/17] test(admin): align test endpoints with clean REST URL conventions in AdminAutomation and AdminBroadcast --- .../admin/controller/AdminAutomationControllerTest.java | 4 ++-- .../admin/controller/AdminBroadcastControllerTest.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java index 6df60c9d..8f6d8f37 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java @@ -65,12 +65,12 @@ void getAutomations_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/automations/{automationId}/details - Should return automation details") + @DisplayName("GET /api/v1/admin/automations/{automationId} - Should return automation details") void getAutomationDetails_Success() throws Exception { AdminAutomationDetailDto dto = new AdminAutomationDetailDto(); when(adminAutomationService.getAutomationDetails(eq(10L), eq("all"), eq(0), eq(20))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/automations/10/details")) + mockMvc.perform(get("/api/v1/admin/automations/10")) .andExpect(status().isOk()); } diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java index 74889826..add6445f 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java @@ -65,12 +65,12 @@ void getBroadcasts_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/broadcasts/{broadcastId}/details - Should return broadcast details") + @DisplayName("GET /api/v1/admin/broadcasts/{broadcastId} - Should return broadcast details") void getBroadcastDetails_Success() throws Exception { AdminBroadcastDetailDto dto = new AdminBroadcastDetailDto(); when(adminBroadcastService.getBroadcastDetails(eq(15L), eq("all"), eq(0), eq(10))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/broadcasts/15/details")) + mockMvc.perform(get("/api/v1/admin/broadcasts/15")) .andExpect(status().isOk()); } From a879159e6ff0bfd94738c030175440025d6aab65 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 14:17:33 +0300 Subject: [PATCH 16/17] fix(frontend): add div to allowed tags in sanitize and mock axios adapter in tests --- frontend/src/api/axios.test.ts | 92 ++++++++++++---------------------- frontend/src/utils/sanitize.ts | 1 + 2 files changed, 33 insertions(+), 60 deletions(-) diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts index eecf2b66..26d89c9b 100644 --- a/frontend/src/api/axios.test.ts +++ b/frontend/src/api/axios.test.ts @@ -1,29 +1,41 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import apiClient from './axios'; import { useAuthStore } from '../store/useAuthStore'; import { IDEMPOTENCY_HEADER_NAME } from '../utils/idempotency'; describe('apiClient request interceptor', () => { + let originalAdapter: any; + let capturedConfig: any = null; + beforeEach(() => { vi.restoreAllMocks(); useAuthStore.setState({ accessToken: null, refreshToken: null, user: null }); + capturedConfig = null; + originalAdapter = apiClient.defaults.adapter; + apiClient.defaults.adapter = async (config: any) => { + capturedConfig = config; + return { + data: { success: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }; + }; }); - it('should automatically attach Idempotency-Key on POST requests', async () => { - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: { success: true }, status: 200, statusText: 'OK', headers: {}, config }; - }); + afterEach(() => { + apiClient.defaults.adapter = originalAdapter; + }); + it('should automatically attach Idempotency-Key on POST requests', async () => { await apiClient.post('/test-endpoint', { name: 'Launchly' }); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const idempotencyKey = typeof headers.get === 'function' + const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) - : headers[IDEMPOTENCY_HEADER_NAME]; + : headers?.[IDEMPOTENCY_HEADER_NAME]; expect(idempotencyKey).toBeDefined(); expect(typeof idempotencyKey).toBe('string'); @@ -31,113 +43,73 @@ describe('apiClient request interceptor', () => { }); it('should automatically attach Idempotency-Key on DELETE requests', async () => { - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: {}, status: 204, statusText: 'No Content', headers: {}, config }; - }); - await apiClient.delete('/test-endpoint/123'); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const idempotencyKey = typeof headers.get === 'function' + const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) - : headers[IDEMPOTENCY_HEADER_NAME]; + : headers?.[IDEMPOTENCY_HEADER_NAME]; expect(idempotencyKey).toBeDefined(); expect(typeof idempotencyKey).toBe('string'); }); it('should automatically attach Idempotency-Key on PATCH requests', async () => { - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: {}, status: 200, statusText: 'OK', headers: {}, config }; - }); - await apiClient.patch('/test-endpoint/123', { active: true }); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const idempotencyKey = typeof headers.get === 'function' + const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) - : headers[IDEMPOTENCY_HEADER_NAME]; + : headers?.[IDEMPOTENCY_HEADER_NAME]; expect(idempotencyKey).toBeDefined(); }); it('should NOT attach Idempotency-Key on GET requests', async () => { - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: [], status: 200, statusText: 'OK', headers: {}, config }; - }); - await apiClient.get('/test-endpoint'); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const idempotencyKey = typeof headers.get === 'function' + const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) - : headers[IDEMPOTENCY_HEADER_NAME]; + : headers?.[IDEMPOTENCY_HEADER_NAME]; expect(idempotencyKey).toBeUndefined(); }); it('should preserve manual Idempotency-Key if already provided', async () => { - let capturedConfig: any = null; const customKey = 'custom-user-provided-key-999'; - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: { success: true }, status: 200, statusText: 'OK', headers: {}, config }; - }); - await apiClient.post('/test-endpoint', {}, { headers: { [IDEMPOTENCY_HEADER_NAME]: customKey }, }); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const idempotencyKey = typeof headers.get === 'function' + const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) - : headers[IDEMPOTENCY_HEADER_NAME]; + : headers?.[IDEMPOTENCY_HEADER_NAME]; expect(idempotencyKey).toBe(customKey); }); it('should attach Bearer token when accessToken is present in auth store', async () => { useAuthStore.setState({ accessToken: 'sample-jwt-token', refreshToken: null, user: null }); - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: [], status: 200, statusText: 'OK', headers: {}, config }; - }); await apiClient.get('/secure-data'); expect(capturedConfig).not.toBeNull(); const headers = capturedConfig.headers; - const authHeader = typeof headers.get === 'function' + const authHeader = typeof headers?.get === 'function' ? headers.get('Authorization') - : headers['Authorization']; + : headers?.['Authorization']; expect(authHeader).toBe('Bearer sample-jwt-token'); }); it('should automatically attach AbortSignal to search GET requests', async () => { - let capturedConfig: any = null; - - vi.spyOn(apiClient, 'request').mockImplementation(async (config: any) => { - capturedConfig = config; - return { data: [], status: 200, statusText: 'OK', headers: {}, config }; - }); - await apiClient.get('/search-items', { params: { search: 'launchly' } }); expect(capturedConfig).not.toBeNull(); diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts index afae656c..f95b3c64 100644 --- a/frontend/src/utils/sanitize.ts +++ b/frontend/src/utils/sanitize.ts @@ -5,6 +5,7 @@ export interface SanitizeOptions { } const DEFAULT_ALLOWED_TAGS = new Set([ + 'div', 'b', 'strong', 'i', From 31d66b45c230a996fd12cab96b1fcc48a877a4f7 Mon Sep 17 00:00:00 2001 From: polchduikt Date: Thu, 27 Aug 2026 14:26:29 +0300 Subject: [PATCH 17/17] docs: add platform landing preview image to docs and README overview --- README.md | 4 ++++ docs/images/landing.jpg | Bin 0 -> 102778 bytes 2 files changed, 4 insertions(+) create mode 100644 docs/images/landing.jpg diff --git a/README.md b/README.md index 8bced3a6..b67f0cce 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ It serves as a full-scale technical showcase of building resilient, high-concurrency monoliths using **Clean Architecture**, **Domain-Driven Design (DDD)** principles, and modern reactive frontend design. +

+ Launchly Platform Overview +

+ --- ## Tech Stack diff --git a/docs/images/landing.jpg b/docs/images/landing.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f97fc906f9a48a0941618781f7d7d1d3de902534 GIT binary patch literal 102778 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