ChatController.java
package com.ecommerce.chat.interfaces.rest;
import com.ecommerce.chat.application.model.ChatModels.Actor;
import com.ecommerce.chat.application.model.ChatModels.AttachmentDownloadView;
import com.ecommerce.chat.application.model.ChatModels.AttachmentScanRetryAuditView;
import com.ecommerce.chat.application.model.ChatModels.AttachmentUploadView;
import com.ecommerce.chat.application.model.ChatModels.ConversationView;
import com.ecommerce.chat.application.model.ChatModels.CreateAttachmentUploadCommand;
import com.ecommerce.chat.application.model.ChatModels.CreateConversationCommand;
import com.ecommerce.chat.application.model.ChatModels.MessagePage;
import com.ecommerce.chat.application.model.ChatModels.MessageView;
import com.ecommerce.chat.application.model.ChatModels.ReadView;
import com.ecommerce.chat.application.model.ChatModels.RetryAttachmentScanCommand;
import com.ecommerce.chat.application.model.ChatModels.SendMessageCommand;
import com.ecommerce.chat.application.model.ChatModels.WebSocketTicketView;
import com.ecommerce.chat.application.port.ChatWebSocketTicketIssuer;
import com.ecommerce.chat.application.service.ChatApplicationService;
import com.ecommerce.chat.application.service.ChatAttachmentService;
import com.ecommerce.chat.application.service.ChatAttachmentScanService;
import com.ecommerce.platform.common.api.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final ChatApplicationService chatService;
private final ChatAttachmentService attachmentService;
private final ChatAttachmentScanService attachmentScanService;
private final ChatWebSocketTicketIssuer webSocketTicketIssuer;
public ChatController(
ChatApplicationService chatService,
ChatAttachmentService attachmentService,
ChatAttachmentScanService attachmentScanService,
ChatWebSocketTicketIssuer webSocketTicketIssuer) {
this.chatService = chatService;
this.attachmentService = attachmentService;
this.attachmentScanService = attachmentScanService;
this.webSocketTicketIssuer = webSocketTicketIssuer;
}
@PostMapping("/conversations")
public ApiResponse<ConversationView> createConversation(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@Valid @RequestBody CreateConversationRequest request) {
return ApiResponse.success(chatService.createConversation(
actor(jwt, authentication),
request.toCommand()));
}
@GetMapping("/conversations")
public ApiResponse<List<ConversationView>> listConversations(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit) {
return ApiResponse.success(chatService.listConversations(actor(jwt, authentication), limit));
}
@PostMapping("/websocket-tickets")
public ResponseEntity<ApiResponse<WebSocketTicketView>> createWebSocketTicket(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication) {
Actor actor = actor(jwt, authentication);
List<String> authorities = authentication.getAuthorities().stream()
.map(authority -> authority.getAuthority())
.toList();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.body(ApiResponse.success(webSocketTicketIssuer.issue(
actor.userId(),
authorities)));
}
@GetMapping("/conversations/{conversationId}")
public ApiResponse<ConversationView> getConversation(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId) {
return ApiResponse.success(chatService.getConversation(
actor(jwt, authentication),
conversationId));
}
@PostMapping("/conversations/{conversationId}/claim")
public ApiResponse<ConversationView> claimConversation(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId) {
return ApiResponse.success(chatService.claimConversation(
actor(jwt, authentication),
conversationId));
}
@PostMapping("/conversations/{conversationId}/close")
public ApiResponse<ConversationView> closeConversation(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId) {
return ApiResponse.success(chatService.closeConversation(
actor(jwt, authentication),
conversationId));
}
@PostMapping("/conversations/{conversationId}/messages")
public ApiResponse<MessageView> sendMessage(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@Valid @RequestBody SendMessageRequest request) {
return ApiResponse.success(chatService.sendMessage(
actor(jwt, authentication),
conversationId,
request.toCommand()));
}
@PostMapping("/conversations/{conversationId}/attachments/upload-intents")
public ApiResponse<AttachmentUploadView> createAttachmentUploadIntent(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@Valid @RequestBody CreateAttachmentUploadRequest request) {
return ApiResponse.success(attachmentService.createUploadIntent(
actor(jwt, authentication),
conversationId,
request.toCommand()));
}
@PostMapping("/conversations/{conversationId}/attachments/{uploadId}/confirm")
public ApiResponse<AttachmentUploadView> confirmAttachmentUpload(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@PathVariable @Positive Long uploadId) {
return ApiResponse.success(attachmentService.confirmUpload(
actor(jwt, authentication),
conversationId,
uploadId));
}
@GetMapping("/conversations/{conversationId}/attachments/{uploadId}")
public ApiResponse<AttachmentUploadView> getAttachmentUpload(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@PathVariable @Positive Long uploadId) {
return ApiResponse.success(attachmentService.getUpload(
actor(jwt, authentication),
conversationId,
uploadId));
}
@PostMapping("/admin/attachments/{uploadId}/scan-retries")
public ApiResponse<AttachmentUploadView> retryAttachmentScan(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long uploadId,
@Valid @RequestBody RetryAttachmentScanRequest request) {
Actor actor = actor(jwt, authentication);
return ApiResponse.success(attachmentScanService.retryScan(
actor,
new RetryAttachmentScanCommand(
request.commandId(),
uploadId,
actor.userId(),
request.reason())));
}
@GetMapping("/admin/attachments/{uploadId}/scan-retries")
public ApiResponse<List<AttachmentScanRetryAuditView>> listAttachmentScanRetries(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long uploadId,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit) {
return ApiResponse.success(attachmentScanService.listRetryAudits(
actor(jwt, authentication),
uploadId,
limit));
}
@GetMapping(
"/conversations/{conversationId}/messages/{messageId}/attachments/{attachmentId}/download")
public ApiResponse<AttachmentDownloadView> createAttachmentDownload(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@PathVariable @Positive Long messageId,
@PathVariable @Positive Long attachmentId) {
return ApiResponse.success(attachmentService.createDownload(
actor(jwt, authentication),
conversationId,
messageId,
attachmentId));
}
@GetMapping("/conversations/{conversationId}/messages")
public ApiResponse<MessagePage> listMessages(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@RequestParam(required = false) @Positive Long beforeSequence,
@RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
return ApiResponse.success(chatService.listMessages(
actor(jwt, authentication),
conversationId,
beforeSequence,
size));
}
@PostMapping("/conversations/{conversationId}/read")
public ApiResponse<ReadView> markRead(
@AuthenticationPrincipal Jwt jwt,
Authentication authentication,
@PathVariable @Positive Long conversationId,
@Valid @RequestBody ReadRequest request) {
return ApiResponse.success(chatService.markRead(
actor(jwt, authentication),
conversationId,
request.lastReadMessageId()));
}
private Actor actor(Jwt jwt, Authentication authentication) {
try {
Long userId = Long.valueOf(jwt.getSubject());
boolean supportAgent = authentication.getAuthorities().stream()
.map(authority -> authority.getAuthority())
.anyMatch(authority -> "ROLE_ADMIN".equals(authority) || "ROLE_OPERATOR".equals(authority));
return new Actor(userId, supportAgent);
} catch (NumberFormatException exception) {
throw new BadCredentialsException("JWT subject must be a numeric user ID", exception);
}
}
public record CreateConversationRequest(
@NotBlank
@Size(max = 64)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
String clientConversationId,
@NotBlank @Size(max = 160) String subject,
@Size(max = 32) String contextType,
@Size(max = 80) String contextId
) {
private CreateConversationCommand toCommand() {
return new CreateConversationCommand(
clientConversationId,
subject,
contextType,
contextId);
}
}
public record SendMessageRequest(
@NotBlank
@Size(max = 64)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
String clientMessageId,
@NotBlank @Size(max = 24) String messageType,
@Size(max = 4000) String content,
@Size(max = 5) List<@Positive Long> attachmentUploadIds
) {
public SendMessageRequest {
attachmentUploadIds = attachmentUploadIds == null
? List.of()
: List.copyOf(attachmentUploadIds);
}
private SendMessageCommand toCommand() {
return new SendMessageCommand(
clientMessageId,
messageType,
content,
attachmentUploadIds);
}
}
public record CreateAttachmentUploadRequest(
@NotBlank
@Size(max = 64)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
String clientUploadId,
@NotBlank @Size(max = 255) String fileName,
@NotBlank @Size(max = 120) String mimeType,
@Positive long sizeBytes
) {
private CreateAttachmentUploadCommand toCommand() {
return new CreateAttachmentUploadCommand(
clientUploadId,
fileName,
mimeType,
sizeBytes);
}
}
public record RetryAttachmentScanRequest(
@NotBlank
@Size(max = 64)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
String commandId,
@NotBlank @Size(min = 8, max = 500) String reason
) {
}
public record ReadRequest(
@NotNull @Positive Long lastReadMessageId
) {
}
}