diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java index ee58941a..69d5be52 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java @@ -22,9 +22,15 @@ public class MessageAdapter extends RecyclerView.Adapter messages; private Long currentUserId; private String token; + private String baseUrl; + private OnAttachmentClickListener attachmentClickListener; public MessageAdapter(List messages, Long currentUserId) { this.messages = messages; @@ -40,6 +46,14 @@ public class MessageAdapter extends RecyclerView.Adapter { + if (listener != null) listener.onAttachmentClick(m); + }; + binding.ivAttachment.setOnClickListener(click); + binding.tvAttachmentName.setOnClickListener(click); } } @@ -88,22 +108,44 @@ public class MessageAdapter extends RecyclerView.Adapter { + 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()); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java b/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java index 13df781f..06cdf354 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java @@ -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 sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request); + + @Multipart + @POST("api/v1/chat/conversations/{id}/attachments") + Call sendMessageWithAttachment( + @Path("id") Long conversationId, + @Part("content") RequestBody content, + @Part MultipartBody.Part attachment + ); + + @GET("api/v1/chat/messages/{id}/attachment") + @Streaming + Call downloadAttachment(@Path("id") Long messageId); } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java index fea4cf66..a75d7812 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java @@ -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; } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java index 1b858b08..7ad652ac 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java @@ -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 diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Message.java b/android/app/src/main/java/com/example/petstoremobile/models/Message.java index bf76b4c4..082baa58 100644 --- a/android/app/src/main/java/com/example/petstoremobile/models/Message.java +++ b/android/app/src/main/java/com/example/petstoremobile/models/Message.java @@ -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; } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java index c3a31ad4..b962c9dc 100644 --- a/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java @@ -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> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) { + return executeCall(messageApi.sendMessageWithAttachment(conversationId, content, attachment)); + } + + /** + * Downloads an attachment for a specific message. + */ + public LiveData> downloadAttachment(Long messageId) { + return executeCall(messageApi.downloadAttachment(messageId)); + } + /** * Fetches a paginated list of customers. */ diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java index dcdc1bd7..bf8c8770 100644 --- a/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java +++ b/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java @@ -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; + } } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatListViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatListViewModel.java index 0aa8021f..cac5636d 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatListViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatListViewModel.java @@ -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> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) { + return chatRepository.sendMessageWithAttachment(conversationId, content, attachment); + } + + public LiveData> downloadAttachment(Long messageId) { + return chatRepository.downloadAttachment(messageId); + } + public void addMessageLocally(MessageDTO dto) { List 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; } diff --git a/android/app/src/main/res/layout/dialog_full_screen_image.xml b/android/app/src/main/res/layout/dialog_full_screen_image.xml new file mode 100644 index 00000000..c85b491e --- /dev/null +++ b/android/app/src/main/res/layout/dialog_full_screen_image.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index e56e353f..33e209c3 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -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 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 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> getMessages(@PathVariable Long id) { diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java index fba1c7c8..2c39e8f1 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java @@ -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()); diff --git a/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java new file mode 100644 index 00000000..720c8079 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java @@ -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; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index e076daed..e17a5972 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -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)