Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
662c5db
Create LICENSE
polichdev Aug 27, 2026
7a6d7ff
Revise architecture details in README.md
polichdev Aug 27, 2026
6f290bd
refactor(api): enforce strict clean REST URL conventions across backe…
polichdev Aug 27, 2026
8f3d851
feat(frontend): add automatic idempotency key header to axios mutatin…
polichdev Aug 27, 2026
9cee5f8
feat(frontend): implement high performance virtualization for crm cha…
polichdev Aug 27, 2026
b8b78b7
feat(frontend): implement optimistic ui updates for crm and support o…
polichdev Aug 27, 2026
60422d2
feat(frontend): implement offline and websocket reconnecting network …
polichdev Aug 27, 2026
4024451
feat(frontend): implement broadcastchannel multi-tab sync and xss san…
polichdev Aug 27, 2026
b917873
feat(frontend): implement request cancellation with abortcontroller a…
polichdev Aug 27, 2026
4b78352
fix(frontend): restore missing closing brace in bot api import in use…
polichdev Aug 27, 2026
59bef7f
fix(support): correct EntityGraph attribute path from assignedTo to a…
polichdev Aug 27, 2026
9989298
fix(frontend): prevent false positive websocket reconnection banner o…
polichdev Aug 27, 2026
e5cae1f
fix(crm): restore natural message flow and auto-scroll in MessageArea
polichdev Aug 27, 2026
2c1ed9c
docs: update main README with enterprise frontend architecture and re…
polichdev Aug 27, 2026
4e9ed10
test(admin): align test endpoints with clean REST URL conventions in …
polichdev Aug 27, 2026
a879159
fix(frontend): add div to allowed tags in sanitize and mock axios ada…
polichdev Aug 27, 2026
31d66b4
docs: add platform landing preview image to docs and README overview
polichdev Aug 27, 2026
0bbe022
Merge branch 'main' into dev
polichdev Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="docs/images/landing.jpg" alt="Launchly Platform Overview" width="100%" />
</p>

---

## Tech Stack
Expand Down Expand Up @@ -65,10 +69,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

Expand Down Expand Up @@ -117,9 +125,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public ResponseEntity<Page<AdminAutomationDto>> 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<AdminAutomationDetailDto> getAutomationDetails(
@Parameter(description = "Automation schema ID") @PathVariable Long automationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public ResponseEntity<Page<AdminBroadcastDto>> 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<AdminBroadcastDetailDto> getBroadcastDetails(
@Parameter(description = "Broadcast campaign ID") @PathVariable Long broadcastId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public ResponseEntity<AdminUserDto> 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<AdminUserDetailDto> getUserDetails(
@Parameter(description = "Target user ID") @PathVariable Long userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,27 @@
@Repository
public interface SupportTicketRepository extends JpaRepository<SupportTicket, Long>, JpaSpecificationExecutor<SupportTicket> {

@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<SupportTicket> findFirstByUserId(@Param("userId") Long userId);

@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Page<SupportTicket> findByUserId(Long userId, Pageable pageable);

@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Page<SupportTicket> findByStatus(String status, Pageable pageable);

@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Page<SupportTicket> findByUnreadForAdminTrue(Pageable pageable);

@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Page<SupportTicket> findByIsFavoriteTrue(Pageable pageable);

@Override
@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Optional<SupportTicket> findById(Long id);

@Override
@EntityGraph(attributePaths = {"user", "assignedTo"})
@EntityGraph(attributePaths = {"user", "assignedManager"})
Page<SupportTicket> findAll(Specification<SupportTicket> spec, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public ResponseEntity<UserResponse> 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<Void> deleteAccount(Authentication authentication) {
UserResponse currentUser = authService.getCurrentUser(authentication.getName());
authService.deleteUserAccount(currentUser.id());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TemplateResponse> createTemplate(
@RequestBody CreateTemplateRequest request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,15 @@ 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);
when(authService.getCurrentUser("user@launchly.pro")).thenReturn(userResponse);

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Binary file added docs/images/landing.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
"@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",
"dompurify": "^3.2.4",
"emoji-mart": "^5.6.0",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
Expand All @@ -45,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",
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@ 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';
import { useMultiTabSync } from './hooks/useMultiTabSync';

const AppContent: React.FC = () => {
useMultiTabSync();

return (
<ErrorBoundary>
<LanguageProvider>
<NetworkStatusBanner />
<AppRouter />
</LanguageProvider>
</ErrorBoundary>
);
};

const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<LanguageProvider>
<AppRouter />
</LanguageProvider>
</ErrorBoundary>
<AppContent />
</QueryClientProvider>
);
};
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export const fetchAdminBroadcastDetailsApi = async (
size = 10
): Promise<AdminBroadcastDetail> => {
const params: Record<string, string | number> = { period, page, size };
const response = await apiClient.get<AdminBroadcastDetail>(`/admin/broadcasts/${broadcastId}/details`, { params });
const response = await apiClient.get<AdminBroadcastDetail>(`/admin/broadcasts/${broadcastId}`, { params });
return response.data;
};

Expand Down Expand Up @@ -327,7 +327,7 @@ export const fetchAdminAutomationDetailsApi = async (
size = 20
): Promise<AdminAutomationDetail> => {
const params: Record<string, string | number> = { period, page, size };
const response = await apiClient.get<AdminAutomationDetail>(`/admin/automations/${automationId}/details`, { params });
const response = await apiClient.get<AdminAutomationDetail>(`/admin/automations/${automationId}`, { params });
return response.data;
};

Expand Down Expand Up @@ -453,7 +453,7 @@ export const fetchAdminUserDetailsApi = async (
size = 20
): Promise<AdminUserDetail> => {
const params: Record<string, string | number> = { period, category, page, size };
const response = await apiClient.get<AdminUserDetail>(`/admin/users/${userId}/details`, { params });
const response = await apiClient.get<AdminUserDetail>(`/admin/users/${userId}`, { params });
return response.data;
};

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const updateProfileApi = async (data: UpdateProfileRequest): Promise<User
};

export const deleteAccountApi = async (): Promise<void> => {
await apiClient.delete('/auth/delete-account');
await apiClient.delete('/auth/account');
};

export interface TelegramSessionResponse {
Expand Down
Loading
Loading