Close chat #169

Closed
RecentRunner wants to merge 291 commits from close-chat into main
13 changed files with 485 additions and 44 deletions
Showing only changes of commit f3932b226d - Show all commits

View File

@@ -22,9 +22,15 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
private static final int TYPE_SENT = 1;
private static final int TYPE_RECEIVED = 2;
public interface OnAttachmentClickListener {
void onAttachmentClick(Message message);
}
private final List<Message> messages;
private Long currentUserId;
private String token;
private String baseUrl;
private OnAttachmentClickListener attachmentClickListener;
public MessageAdapter(List<Message> messages, Long currentUserId) {
this.messages = messages;
@@ -40,6 +46,14 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
this.token = token;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public void setOnAttachmentClickListener(OnAttachmentClickListener listener) {
this.attachmentClickListener = listener;
}
@Override
public int getItemViewType(int position) {
Message m = messages.get(position);
@@ -64,8 +78,8 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Message m = messages.get(position);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
}
@Override public int getItemCount() { return messages.size(); }
@@ -76,9 +90,15 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
super(binding.getRoot());
this.binding = binding;
}
void bind(Message m, String token) {
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
binding.tvMessageContent.setText(m.getContent());
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token);
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
if (listener != null) listener.onAttachmentClick(m);
};
binding.ivAttachment.setOnClickListener(click);
binding.tvAttachmentName.setOnClickListener(click);
}
}
@@ -88,22 +108,44 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
super(binding.getRoot());
this.binding = binding;
}
void bind(Message m, String token) {
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
binding.tvMessageContent.setText(m.getContent());
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token);
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
if (listener != null) listener.onAttachmentClick(m);
};
binding.ivAttachment.setOnClickListener(click);
binding.tvAttachmentName.setOnClickListener(click);
}
}
// helper function to display the attachment to the chat bubble
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token) {
if (m.getAttachmentUrl() != null) {
if (m.getAttachmentType() != null && m.getAttachmentType().startsWith("image/")) {
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token, String baseUrl) {
// Check if there's an attachment by looking at name or mime type
if (m.getAttachmentName() != null || m.getAttachmentMimeType() != null) {
// Construct the download URL using the message ID
String url;
if (baseUrl != null) {
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
url = cleanBase + "/api/v1/chat/messages/" + m.getId() + "/attachment";
} else {
url = m.getAttachmentUrl(); // Fallback
}
if (url == null) {
iv.setVisibility(View.GONE);
tvName.setVisibility(View.GONE);
return;
}
if (m.getAttachmentMimeType() != null && m.getAttachmentMimeType().startsWith("image/")) {
iv.setVisibility(View.VISIBLE);
tvName.setVisibility(View.GONE);
Object loadTarget = m.getAttachmentUrl();
if (token != null && m.getAttachmentUrl().startsWith("http")) {
loadTarget = new GlideUrl(m.getAttachmentUrl(), new LazyHeaders.Builder()
Object loadTarget = url;
if (token != null) {
loadTarget = new GlideUrl(url, new LazyHeaders.Builder()
.addHeader("Authorization", "Bearer " + token)
.build());
}

View File

@@ -3,11 +3,18 @@ package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.SendMessageRequest;
import java.util.List;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.Part;
import retrofit2.http.Path;
import retrofit2.http.Streaming;
//api calls to get and send messages
public interface MessageApi {
@@ -17,4 +24,16 @@ public interface MessageApi {
@POST("api/v1/chat/conversations/{id}/messages")
Call<MessageDTO> sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request);
@Multipart
@POST("api/v1/chat/conversations/{id}/attachments")
Call<MessageDTO> sendMessageWithAttachment(
@Path("id") Long conversationId,
@Part("content") RequestBody content,
@Part MultipartBody.Part attachment
);
@GET("api/v1/chat/messages/{id}/attachment")
@Streaming
Call<ResponseBody> downloadAttachment(@Path("id") Long messageId);
}

View File

@@ -28,8 +28,11 @@ public class MessageDTO {
@SerializedName("attachmentName")
private String attachmentName;
@SerializedName("attachmentType")
private String attachmentType;
@SerializedName("attachmentMimeType")
private String attachmentMimeType;
@SerializedName("attachmentSizeBytes")
private Long attachmentSizeBytes;
public MessageDTO() {}
@@ -57,6 +60,9 @@ public class MessageDTO {
public String getAttachmentName() { return attachmentName; }
public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; }
public String getAttachmentType() { return attachmentType; }
public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; }
public String getAttachmentMimeType() { return attachmentMimeType; }
public void setAttachmentMimeType(String attachmentMimeType) { this.attachmentMimeType = attachmentMimeType; }
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
}

View File

@@ -1,14 +1,21 @@
package com.example.petstoremobile.fragments;
import android.app.Activity;
import android.app.Dialog;
import android.content.ContentValues;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Log;
import android.view.*;
import android.view.inputmethod.EditorInfo;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@@ -18,6 +25,9 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.load.model.LazyHeaders;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.ChatAdapter;
import com.example.petstoremobile.adapters.MessageAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
@@ -27,16 +37,25 @@ import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.models.Chat;
import com.example.petstoremobile.models.Message;
import com.example.petstoremobile.services.ChatNotificationService;
import com.example.petstoremobile.utils.FileUtils;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.viewmodels.ChatListViewModel;
import com.example.petstoremobile.websocket.StompChatManager;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.*;
import javax.inject.Inject;
import javax.inject.Named;
import dagger.hilt.android.AndroidEntryPoint;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
@AndroidEntryPoint
public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener, StompChatManager.MessageListener,
@@ -112,6 +131,16 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
binding.rvChatList.setAdapter(chatAdapter);
messageAdapter = new MessageAdapter(messageList, null);
messageAdapter.setBaseUrl(baseUrl);
messageAdapter.setOnAttachmentClickListener(message -> {
if (message.getAttachmentMimeType() != null && message.getAttachmentMimeType().startsWith("image/")) {
showFullScreenImage(message);
} else {
downloadFile(message);
}
});
LinearLayoutManager lm = new LinearLayoutManager(getContext());
lm.setStackFromEnd(true);
binding.rvMessages.setLayoutManager(lm);
@@ -119,6 +148,85 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
setConversationActive(false);
}
private void showFullScreenImage(Message message) {
if (baseUrl == null || message.getId() == null) return;
Dialog dialog = new Dialog(requireContext(), android.R.style.Theme_Black_NoTitleBar_Fullscreen);
dialog.setContentView(R.layout.dialog_full_screen_image);
ImageView imageView = dialog.findViewById(R.id.ivFullScreen);
ImageButton closeButton = dialog.findViewById(R.id.btnClose);
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String downloadUrl = cleanBase + "/api/v1/chat/messages/" + message.getId() + "/attachment";
GlideUrl glideUrl = new GlideUrl(downloadUrl, new LazyHeaders.Builder()
.addHeader("Authorization", "Bearer " + tokenManager.getToken())
.build());
Glide.with(this)
.load(glideUrl)
.into(imageView);
closeButton.setOnClickListener(v -> dialog.dismiss());
imageView.setOnClickListener(v -> dialog.dismiss());
dialog.show();
}
private void downloadFile(Message message) {
if (message.getId() == null) return;
Toast.makeText(requireContext(), "Downloading " + message.getAttachmentName() + "...", Toast.LENGTH_SHORT).show();
viewModel.downloadAttachment(message.getId()).observe(getViewLifecycleOwner(), resource -> {
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
saveFileToDownloads(resource.data, message.getAttachmentName(), message.getAttachmentMimeType());
} else if (resource != null && resource.status == Resource.Status.ERROR) {
Toast.makeText(requireContext(), "Download failed: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
private void saveFileToDownloads(ResponseBody body, String fileName, String mimeType) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
Uri uri = requireContext().getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (OutputStream outputStream = requireContext().getContentResolver().openOutputStream(uri);
InputStream inputStream = body.byteStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
Toast.makeText(requireContext(), "File saved to Downloads", Toast.LENGTH_SHORT).show();
}
}
} else {
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(downloadsDir, fileName);
try (OutputStream outputStream = new FileOutputStream(file);
InputStream inputStream = body.byteStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
Toast.makeText(requireContext(), "File saved to Downloads: " + file.getAbsolutePath(), Toast.LENGTH_LONG).show();
}
}
body.close();
} catch (Exception e) {
Log.e(TAG, "Error saving file", e);
Toast.makeText(requireContext(), "Error saving file", Toast.LENGTH_SHORT).show();
}
}
private void observeViewModel() {
viewModel.getChatList().observe(getViewLifecycleOwner(), list -> {
chatList.clear();
@@ -214,7 +322,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
pendingAttachmentUri = uri;
binding.layoutAttachmentPreview.setVisibility(View.VISIBLE);
String mimeType = requireContext().getContentResolver().getType(uri);
binding.tvPreviewName.setText(getFileName(uri));
binding.tvPreviewName.setText(FileUtils.getFileName(requireContext(), uri));
if (mimeType != null && mimeType.startsWith("image/")) {
binding.ivPreview.setVisibility(View.VISIBLE);
Glide.with(this).load(uri).into(binding.ivPreview);
@@ -228,27 +336,35 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
binding.layoutAttachmentPreview.setVisibility(View.GONE);
}
private String getFileName(Uri uri) {
String result = null;
if (uri.getScheme().equals("content")) {
try (Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (index != -1) result = cursor.getString(index);
}
}
}
if (result == null) {
result = uri.getPath();
int cut = result.lastIndexOf('/');
if (cut != -1) result = result.substring(cut + 1);
}
return result;
}
private void sendWithAttachment(Uri uri) {
Toast.makeText(requireContext(), "File attachments are not supported", Toast.LENGTH_SHORT).show();
if (activeConversationId == null) return;
File file = FileUtils.getFileFromUri(requireContext(), uri);
if (file == null) {
Toast.makeText(requireContext(), "Failed to prepare file", Toast.LENGTH_SHORT).show();
return;
}
String text = binding.etMessage.getText().toString().trim();
RequestBody contentPart = RequestBody.create(MediaType.parse("text/plain"), text);
String mimeType = requireContext().getContentResolver().getType(uri);
if (mimeType == null) mimeType = "application/octet-stream";
RequestBody filePartBody = RequestBody.create(MediaType.parse(mimeType), file);
MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", file.getName(), filePartBody);
binding.etMessage.setText("");
removeAttachment();
viewModel.sendMessageWithAttachment(activeConversationId, contentPart, filePart).observe(getViewLifecycleOwner(), resource -> {
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
viewModel.addMessageLocally(resource.data);
viewModel.loadConversations();
} else if (resource != null && resource.status == Resource.Status.ERROR) {
Toast.makeText(requireContext(), "Failed to send attachment: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
@Override

View File

@@ -9,7 +9,8 @@ public class Message {
private Boolean isRead;
private String attachmentUrl;
private String attachmentName;
private String attachmentType;
private String attachmentMimeType;
private Long attachmentSizeBytes;
public Message() {}
@@ -43,6 +44,9 @@ public class Message {
public String getAttachmentName() { return attachmentName; }
public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; }
public String getAttachmentType() { return attachmentType; }
public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; }
public String getAttachmentMimeType() { return attachmentMimeType; }
public void setAttachmentMimeType(String attachmentMimeType) { this.attachmentMimeType = attachmentMimeType; }
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
}

View File

@@ -17,6 +17,10 @@ import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
/**
* Repository for handling chat-related data operations.
*/
@@ -55,6 +59,20 @@ public class ChatRepository extends BaseRepository {
return executeCall(messageApi.sendMessage(conversationId, request));
}
/**
* Sends a message with an attachment.
*/
public LiveData<Resource<MessageDTO>> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) {
return executeCall(messageApi.sendMessageWithAttachment(conversationId, content, attachment));
}
/**
* Downloads an attachment for a specific message.
*/
public LiveData<Resource<ResponseBody>> downloadAttachment(Long messageId) {
return executeCall(messageApi.downloadAttachment(messageId));
}
/**
* Fetches a paginated list of customers.
*/

View File

@@ -1,7 +1,9 @@
package com.example.petstoremobile.utils;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
@@ -9,8 +11,11 @@ import java.io.InputStream;
public class FileUtils {
public static File getFileFromUri(Context context, Uri uri) {
try {
String fileName = getFileName(context, uri);
if (fileName == null) fileName = "upload_" + System.currentTimeMillis();
InputStream inputStream = context.getContentResolver().openInputStream(uri);
File tempFile = new File(context.getCacheDir(), "upload_image_" + System.currentTimeMillis() + ".jpg");
File tempFile = new File(context.getCacheDir(), fileName);
FileOutputStream outputStream = new FileOutputStream(tempFile);
byte[] buffer = new byte[1024];
int length;
@@ -24,4 +29,22 @@ public class FileUtils {
return null;
}
}
public static String getFileName(Context context, Uri uri) {
String result = null;
if (uri.getScheme().equals("content")) {
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (index != -1) result = cursor.getString(index);
}
}
}
if (result == null) {
result = uri.getPath();
int cut = result.lastIndexOf('/');
if (cut != -1) result = result.substring(cut + 1);
}
return result;
}
}

View File

@@ -23,6 +23,9 @@ import java.util.Map;
import javax.inject.Inject;
import dagger.hilt.android.lifecycle.HiltViewModel;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
@HiltViewModel
public class ChatListViewModel extends ViewModel {
@@ -88,6 +91,14 @@ public class ChatListViewModel extends ViewModel {
return chatRepository.sendMessage(conversationId, new SendMessageRequest(text));
}
public LiveData<Resource<MessageDTO>> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) {
return chatRepository.sendMessageWithAttachment(conversationId, content, attachment);
}
public LiveData<Resource<ResponseBody>> downloadAttachment(Long messageId) {
return chatRepository.downloadAttachment(messageId);
}
public void addMessageLocally(MessageDTO dto) {
List<Message> current = new ArrayList<>(messageList.getValue());
current.add(dtoToModel(dto));
@@ -122,7 +133,8 @@ public class ChatListViewModel extends ViewModel {
m.setIsRead(dto.getIsRead());
m.setAttachmentUrl(dto.getAttachmentUrl());
m.setAttachmentName(dto.getAttachmentName());
m.setAttachmentType(dto.getAttachmentType());
m.setAttachmentMimeType(dto.getAttachmentMimeType());
m.setAttachmentSizeBytes(dto.getAttachmentSizeBytes());
return m;
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<ImageView
android:id="@+id/ivFullScreen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />
<ImageButton
android:id="@+id/btnClose"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="top|end"
android:layout_margin="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="Close"
android:tint="@android:color/white" />
</FrameLayout>

View File

@@ -5,17 +5,24 @@ import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.ChatAttachmentStorageService;
import com.petshop.backend.service.ChatRealtimeService;
import com.petshop.backend.service.ChatService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -26,11 +33,17 @@ public class ChatController {
private final ChatService chatService;
private final ChatRealtimeService chatRealtimeService;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
private final MessageRepository messageRepository;
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository) {
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
MessageRepository messageRepository) {
this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
this.messageRepository = messageRepository;
}
private User getCurrentUser() {
@@ -78,6 +91,44 @@ public class ChatController {
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
@PostMapping(value = "/conversations/{id}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<MessageResponse> sendMessageWithAttachment(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
User user = getCurrentUser();
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
@GetMapping("/messages/{messageId}/attachment")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<Resource> getMessageAttachment(@PathVariable Long messageId) {
User user = getCurrentUser();
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new RuntimeException("Message not found"));
if (!chatService.hasConversationAccess(message.getConversationId(), user.getId(), user.getRole())) {
throw new AccessDeniedException("Access denied to this message attachment");
}
if (message.getAttachmentUrl() == null) {
return ResponseEntity.notFound().build();
}
try {
Resource resource = attachmentStorageService.loadAttachmentResource(message.getAttachmentUrl());
return ResponseEntity.ok()
.contentType(attachmentStorageService.resolveMediaType(message.getAttachmentUrl()))
.header("Content-Disposition", "attachment; filename=\"" + message.getAttachmentName() + "\"")
.body(resource);
} catch (IllegalArgumentException ex) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/conversations/{id}/messages")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<List<MessageResponse>> getMessages(@PathVariable Long id) {

View File

@@ -36,7 +36,12 @@ public class MessageResponse {
response.setContent(message.getContent());
response.setTimestamp(message.getTimestamp());
response.setIsRead(message.getIsRead());
response.setAttachmentUrl(message.getAttachmentUrl());
if (message.getAttachmentUrl() != null) {
response.setAttachmentUrl("/api/v1/chat/messages/" + message.getId() + "/attachment");
}
response.setAttachmentName(message.getAttachmentName());
response.setAttachmentMimeType(message.getAttachmentMimeType());
response.setAttachmentSizeBytes(message.getAttachmentSizeBytes());

View File

@@ -0,0 +1,71 @@
package com.petshop.backend.service;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Service
public class ChatAttachmentStorageService {
private static final String STORED_PREFIX = "/uploads/chat/";
private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize();
public String storeAttachment(MultipartFile file) throws IOException {
Files.createDirectories(chatDirectory);
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String filename = UUID.randomUUID() + extension;
Path filePath = chatDirectory.resolve(filename).normalize();
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
return STORED_PREFIX + filename;
}
public Resource loadAttachmentResource(String attachmentUrl) {
Path filePath = resolveStoredPath(attachmentUrl);
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new IllegalArgumentException("Attachment file was not found");
}
return new PathResource(filePath);
}
public MediaType resolveMediaType(String attachmentUrl) {
try {
return MediaTypeFactory.getMediaType(loadAttachmentResource(attachmentUrl)).orElse(MediaType.APPLICATION_OCTET_STREAM);
} catch (IllegalArgumentException ex) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
private Path resolveStoredPath(String attachmentUrl) {
if (attachmentUrl == null || attachmentUrl.isBlank() || !attachmentUrl.startsWith(STORED_PREFIX)) {
throw new IllegalArgumentException("Invalid attachment URL");
}
String filename = attachmentUrl.substring(STORED_PREFIX.length());
if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
throw new IllegalArgumentException("Invalid attachment filename");
}
Path resolved = chatDirectory.resolve(filename).normalize();
if (!resolved.startsWith(chatDirectory)) {
throw new IllegalArgumentException("Invalid attachment path");
}
return resolved;
}
}

View File

@@ -15,7 +15,9 @@ import com.petshop.backend.repository.UserRepository;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@@ -26,13 +28,16 @@ public class ChatService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserRepository userRepository) {
UserRepository userRepository,
ChatAttachmentStorageService attachmentStorageService) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
}
@Transactional
@@ -145,6 +150,51 @@ public class ChatService {
return MessageResponse.fromEntity(message);
}
@Transactional
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
}
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations");
}
}
try {
String attachmentUrl = attachmentStorageService.storeAttachment(file);
Message message = new Message();
message.setConversationId(conversationId);
message.setSenderId(userId);
message.setAttachmentUrl(attachmentUrl);
message.setAttachmentName(file.getOriginalFilename());
message.setAttachmentMimeType(file.getContentType());
message.setAttachmentSizeBytes(file.getSize());
message.setIsRead(false);
message = messageRepository.save(message);
if (role == User.Role.STAFF && conversation.getStaffId() == null) {
conversation.setStaffId(userId);
}
if (role == User.Role.STAFF) {
conversation.setMode(Conversation.ConversationMode.HUMAN);
conversationRepository.save(conversation);
}
return MessageResponse.fromEntity(message);
} catch (IOException e) {
throw new RuntimeException("Failed to store attachment", e);
}
}
@Transactional
public ConversationResponse requestHumanTakeover(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)