diff --git a/compose.yaml b/compose.yaml index ce79d1b..57ebce5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,3 +7,18 @@ services: - 'POSTGRES_USER=myuser' ports: - '5432:5432' + + minio: + image: 'minio/minio:latest' + ports: + - '9000:9000' + - '9001:9001' + environment: + - MINIO_ROOT_USER=admin + - MINIO_ROOT_PASSWORD=password + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + +volumes: + minio_data: diff --git a/pom.xml b/pom.xml index bae3cf7..b1a7177 100644 --- a/pom.xml +++ b/pom.xml @@ -143,12 +143,6 @@ testcontainers-postgresql test - - - - - - org.projectlombok lombok @@ -159,6 +153,11 @@ h2 test + + software.amazon.awssdk + s3 + 2.42.20 + diff --git a/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java b/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java new file mode 100644 index 0000000..39509d2 --- /dev/null +++ b/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java @@ -0,0 +1,58 @@ +package org.example.visacasemanagementsystem.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class S3Config { + + @Value("${minio.endpoint}") + private String endpoint; + + @Value("${minio.accessKey}") + private String accessKey; + + @Value("${minio.secretKey}") + private String secretKey; + + @Value("${minio.region}") + private String region; + + + @Bean + public S3Client s3Client () { + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) +// .checksumValidationEnabled(false) + .build()) + .build(); + } + + @Bean + public S3Presigner s3Presigner () { + return S3Presigner.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .build(); + + } +} diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java new file mode 100644 index 0000000..1535214 --- /dev/null +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -0,0 +1,153 @@ +package org.example.visacasemanagementsystem.file; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@Slf4j +public class FileService { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", + "image/jpeg", + "image/png"); + + private static final long MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + + + @Value("${minio.bucketName}") + private String bucketName; + + @Value("${minio.corsAllowedOrigins:http://localhost:8080}") + private String corsAllowedOrigins; + + public FileService(S3Client s3Client, S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + @PostConstruct + public void initializeBucket() { + + if (bucketName == null || bucketName.equals("test-bucket")) { + log.info("Skipping bucket initialization (test profile or missing config)"); + return; + } + try { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket '{}' already exists", bucketName); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket '{}' created successfully", bucketName); + } else { throw e; } + } + } catch (Exception e) { + log.error("Failed to verify/create bucket '{}': {}", bucketName, e.getMessage(), e); + return; + } + + try { + + List origins = Arrays.stream(corsAllowedOrigins.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .toList(); + + if (origins.isEmpty()) { + log.warn("minio.corsAllowedOrigins is empty; skipping CORS configuration for bucket '{}'", bucketName); + return; + } + + CORSRule corsRule = CORSRule.builder() + .allowedOrigins(origins) + .allowedMethods("GET","HEAD") + .allowedHeaders("*") + .build(); + + s3Client.putBucketCors(PutBucketCorsRequest.builder() + .bucket(bucketName) + .corsConfiguration(CORSConfiguration.builder().corsRules(corsRule).build()) + .build()); + log.info("CORS configuration applied to bucket '{}'", bucketName); + } catch (S3Exception e) { + log.warn("Failed to configure CORS to bucket '{}': {}", bucketName, e.getMessage(), e); + } + } + + public String uploadFile(MultipartFile file) throws IOException { + // Validate file size + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IOException("File exceeds maximum allowed size of 10 MB"); + } + + // Validate content type + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new IOException("Unsupported document type: " + contentType + + ". Allowed: PDF, JPEG, PNG"); + } + + // Sanitize filename + String original = file.getOriginalFilename(); + String safeName = (original == null ? "file" + : original.replaceAll("[^A-Za-z0-9._-]", "_")); + + String fileName = UUID.randomUUID() + "_" + safeName; + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(contentType) + .build(); + + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return fileName; + } + + public String getPresignedDownloadUrl(String fileName) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .responseContentDisposition("attachment; filename=\"" + fileName + "\"") + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + public void deleteFile(String s3Key) { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + s3Client.deleteObject(deleteObjectRequest); + } + + +} diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java index 046c037..2fc5086 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java @@ -4,6 +4,7 @@ import jakarta.validation.Valid; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.dto.UserDTO; import org.example.visacasemanagementsystem.user.security.UserPrincipal; @@ -19,6 +20,8 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; @PreAuthorize("isAuthenticated()") @@ -29,11 +32,13 @@ public class VisaViewController { private final VisaService visaService; private final CommentService commentService; private final UserService userService; + private final FileService fileService; - public VisaViewController(VisaService visaService, CommentService commentService, UserService userService) { + public VisaViewController(VisaService visaService, CommentService commentService, UserService userService, FileService fileService) { this.visaService = visaService; this.commentService = commentService; this.userService = userService; + this.fileService = fileService; } @GetMapping("/dashboard") @@ -81,16 +86,30 @@ public String showApplyForm(@AuthenticationPrincipal UserPrincipal principal, Mo public String submitApplication( @Valid @ModelAttribute("createVisaDTO") CreateVisaDTO createVisaDTO, BindingResult bindingResult, + @RequestParam(value = "passportFile", required = false) MultipartFile passportFile, @AuthenticationPrincipal UserPrincipal principal, Model model) { - + if (bindingResult.hasErrors()) { prepareApplyModel(principal.getUserId(), model); return "visa/apply-form"; } try { - visaService.applyForVisa(createVisaDTO, principal.getUserId()); + String s3Key = null; + + if (passportFile != null && !passportFile.isEmpty()) { + s3Key = fileService.uploadFile(passportFile); + } + + visaService.applyForVisa(createVisaDTO, principal.getUserId(), s3Key); + + } catch (java.io.IOException e) { + bindingResult.reject("upload.error", "Failed to upload document: " + e.getMessage()); + prepareApplyModel(principal.getUserId(), model); + + return "visa/apply-form"; + } catch (IllegalArgumentException e) { bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage()); prepareApplyModel(principal.getUserId(), model); @@ -118,10 +137,12 @@ public String showEditForm(@PathVariable Long id, @AuthenticationPrincipal UserP visa.nationality(), visa.passportNumber(), visa.travelDate(), - visa.handlerId() + visa.handlerId(), + visa.statusInformation() ); model.addAttribute("updateVisaDto", updateDto); + model.addAttribute("visa", visa); model.addAttribute("currentUser", userDTO); model.addAttribute("visaTypes", VisaType.values()); model.addAttribute("isEdit", true); @@ -133,38 +154,67 @@ public String showEditForm(@PathVariable Long id, @AuthenticationPrincipal UserP @PostMapping("/{id}/edit") public String processUpdate( @PathVariable Long id, - @Valid @ModelAttribute ("updateVisaDto") UpdateVisaDTO updateVisaDTO, + @Valid @ModelAttribute("updateVisaDto") UpdateVisaDTO updateVisaDTO, BindingResult bindingResult, + @RequestParam(value = "passportFile", required = false) MultipartFile passportFile, @AuthenticationPrincipal UserPrincipal principal, Model model) { if (bindingResult.hasErrors()) { + VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); prepareApplyModel(principal.getUserId(), model); model.addAttribute("isEdit", true); - VisaDTO visa = visaService.findVisaDtoById(id); model.addAttribute("statusInformation", visa.statusInformation()); return "visa/edit-form"; } try { - visaService.updateVisa(id, updateVisaDTO, principal.getUserId()); + String newS3Key = null; + + if (passportFile != null && !passportFile.isEmpty()) { + newS3Key = fileService.uploadFile(passportFile); + } + + visaService.updateVisa(id, updateVisaDTO, principal.getUserId(), newS3Key); + + } catch (java.io.IOException e) { + bindingResult.reject("upload.error", "Failed to upload new document: " + e.getMessage()); + VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); + prepareApplyModel(principal.getUserId(), model); + model.addAttribute("isEdit", true); + return "visa/edit-form"; + } catch (IllegalArgumentException e) { - if (e.getMessage().contains("date")) { + if (e.getMessage() != null && e.getMessage().contains("date")) { bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage()); } else { - bindingResult.reject("globalError",e.getMessage()); + bindingResult.reject("globalError", e.getMessage()); } prepareApplyModel(principal.getUserId(), model); model.addAttribute("isEdit", true); VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); model.addAttribute("statusInformation", visa.statusInformation()); return "visa/edit-form"; } + return "redirect:/visas/" + id; } + @PostMapping("/{id}/documents/delete") + public String deleteVisaDocument(@PathVariable Long id, + @RequestParam String s3Key, + @AuthenticationPrincipal UserPrincipal principal) { + + visaService.removeVisaDocument(id, s3Key, principal.getUserId()); + return "redirect:/visas/" + id + "/edit"; + + } + @PreAuthorize("hasRole('ADMIN')") @PostMapping("/{id}/approve") public String approveVisa(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java b/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java index aaefbba..22f06ad 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java @@ -15,6 +15,7 @@ public record UpdateVisaDTO( @NotBlank(message = "Nationality must be specified") String nationality, @NotBlank(message = "Passport number must be provided") String passportNumber, @NotNull (message = "Travel date must be provided") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate travelDate, - Long handlerId + Long handlerId, + String statusInformation ) { } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java b/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java index c2369bf..0f2adaa 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java @@ -5,6 +5,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; public record VisaDTO( Long id, @@ -19,4 +20,6 @@ public record VisaDTO( String handlerName, LocalDateTime createdAt, LocalDateTime updatedAt, - String statusInformation) {} + String statusInformation, + List downloadUrls, + List s3Keys) {} diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java b/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java index a614f6a..ed31ce9 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java @@ -6,6 +6,8 @@ import org.example.visacasemanagementsystem.visa.entity.Visa; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class VisaMapper { @@ -26,8 +28,9 @@ public VisaDTO toDTO(Visa visa) { visa.getHandler() != null ? visa.getHandler().getFullName() : "Unassigned", visa.getCreatedAt(), visa.getUpdatedAt(), - visa.getStatusInformation() - + visa.getStatusInformation(), + List.of(), + List.copyOf(visa.getS3Keys()) ); } @@ -53,7 +56,8 @@ public void updateEntityFromDTO(UpdateVisaDTO dto, Visa visa) { visa.setPassportNumber(dto.passportNumber()); visa.setTravelDate(dto.travelDate()); + if (dto.statusInformation() != null) { + visa.setStatusInformation(dto.statusInformation()); + } } - - } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 708dbe3..5824764 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -3,6 +3,7 @@ import org.example.visacasemanagementsystem.audit.AuditEventType; import org.example.visacasemanagementsystem.audit.service.AuditService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -33,13 +34,15 @@ public class VisaService { private final UserRepository userRepository; private final VisaMapper visaMapper; private final AuditService auditService; + private final FileService fileService; - public VisaService(VisaRepository visaRepository, UserRepository userRepository ,VisaMapper visaMapper, AuditService auditService) { + public VisaService(VisaRepository visaRepository, UserRepository userRepository , VisaMapper visaMapper, AuditService auditService, FileService fileService) { this.visaRepository = visaRepository; this.userRepository = userRepository; this.visaMapper = visaMapper; this.auditService = auditService; + this.fileService = fileService; } // --- For filtering in Frontend list-view --- @@ -59,7 +62,30 @@ public VisaDTO findVisaDtoById(Long id) { Visa visa = visaRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException(NOT_FOUND_MESSAGE)); - return visaMapper.toDTO(visa); + VisaDTO dto = visaMapper.toDTO(visa); + + List presignedUrls = visa.getS3Keys().stream() + .map(fileService::getPresignedDownloadUrl) + .toList(); + + return new VisaDTO( + dto.id(), + dto.visaType(), + dto.visaStatus(), + dto.nationality(), + dto.passportNumber(), + dto.travelDate(), + dto.applicantId(), + dto.applicantName(), + dto.handlerId(), + dto.handlerName(), + dto.createdAt(), + dto.updatedAt(), + dto.statusInformation(), + presignedUrls, + dto.s3Keys() + ); + } public List findVisasByApplicant(Long applicantId) { @@ -160,7 +186,7 @@ public VisaDTO updateVisaStatus(Long visaId, VisaStatus newStatus, Long adminId) } @Transactional - public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { + public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId, String s3Key) { // Validate travel date validateTravelDate(dto.travelDate()); @@ -173,6 +199,11 @@ public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { visa.setApplicant(applicant); visa.setVisaStatus(VisaStatus.SUBMITTED); + + if (s3Key != null && !s3Key.isBlank()) { + visa.getS3Keys().add(s3Key); + } + Visa savedVisa = visaRepository.save(visa); // Create log in database @@ -180,13 +211,13 @@ public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { userId, savedVisa.getId(), AuditEventType.CREATED, - "Visa application submitted." + "Visa application submitted." + (s3Key != null ? " Document attached." : "") ); return visaMapper.toDTO(savedVisa); } @Transactional - public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { + public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId, String newS3Key) { if (!visaId.equals(dto.id())) { throw new IllegalArgumentException("Mismatched visa id."); } @@ -209,6 +240,10 @@ public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { visa.setPassportNumber(dto.passportNumber()); visa.setTravelDate(dto.travelDate()); + if (newS3Key != null && !newS3Key.isBlank()) { + visa.getS3Keys().add(newS3Key); + } + visa.setVisaStatus(VisaStatus.SUBMITTED); visa.setStatusInformation(null); @@ -223,6 +258,37 @@ public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { return visaMapper.toDTO(savedVisa); } + @Transactional + public void removeVisaDocument(Long visaId, String s3Key, Long userId) { + Visa visa = findVisaById(visaId); + + // Check only the application owner or Admin/sysAdmin can remove files + User user = userRepository.findById(userId).orElseThrow(); + boolean isOwner = visa.getApplicant().getId().equals(user.getId()); + boolean isAdmin = user.getUserAuthorization() != UserAuthorization.USER; + + if (!isOwner && !isAdmin) { + throw new UnauthorizedException("You cannot delete this document."); + } + + if (!visa.getS3Keys().contains(s3Key)) { + throw new IllegalArgumentException("Document does not belong to this visa application."); + } + + if (visa.getS3Keys().remove(s3Key)) { + if (visa.getS3Keys().isEmpty()) { + visa.setVisaStatus(VisaStatus.INCOMPLETE); + } else { + visa.setVisaStatus(VisaStatus.SUBMITTED); + } + visaRepository.save(visa); + + fileService.deleteFile(s3Key); + + auditService.createAuditLog(userId, visaId, AuditEventType.UPDATED, "Removed document: " + s3Key); + } + } + @Transactional public VisaDTO approveVisa(Long visaId, Long adminId) { User admin = validateHandler(adminId); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f981dd9..6391da5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=visa-case-management-system spring.docker.compose.lifecycle-management=start_only spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/visadatabase} -spring.datasource.username=${DB_USER:user} +spring.datasource.username=${DB_USER:myuser} spring.datasource.password=${DB_PASSWORD:secret} # application.properties (default/test/prod) @@ -11,3 +11,14 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=false spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect + +minio.endpoint=${MINIO_ENDPOINT:http://localhost:9000} +minio.accessKey=${MINIO_ACCESS_KEY:admin} +minio.secretKey=${MINIO_SECRET_KEY:password} +minio.bucketName=${MINIO_BUCKET:visa-documents} +minio.region=${MINIO_REGION:eu-north-1} +minio.corsAllowedOrigins=${MINIO_CORS:http://localhost:8080} + + +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB diff --git a/src/main/resources/templates/visa/apply-form.html b/src/main/resources/templates/visa/apply-form.html index ae95a0b..09fdd2e 100644 --- a/src/main/resources/templates/visa/apply-form.html +++ b/src/main/resources/templates/visa/apply-form.html @@ -102,8 +102,34 @@ font-size: 0.75rem; margin-top: 8px; display: block; + } + /* Styling for new Choose file-button */ + input[type="file"] { + width: 105px !important; + height: 32px; + color: transparent; + cursor: pointer; + overflow: hidden; } + + input[type="file"]::-webkit-file-upload-button { + display: none; + } + + input[type="file"]::before { + content: 'Choose File'; + display: inline-block; + background: #222; + border: 1px solid #444; + border-radius: 4px; + padding: 5px 12px; + color: #ccc; + font-weight: 600; + font-size: 13px; + } + + @@ -114,7 +140,7 @@

New Application

Logged in as User

-
+
New Application
+
+ +
+ + + + +
+ +

+ Supported Formats: PDF, JPG or PNG. (Max 10MB). +

+
+ Cancel @@ -171,5 +212,16 @@

New Application

+ + diff --git a/src/main/resources/templates/visa/details.html b/src/main/resources/templates/visa/details.html index 1ff39f5..d99ff55 100644 --- a/src/main/resources/templates/visa/details.html +++ b/src/main/resources/templates/visa/details.html @@ -179,9 +179,29 @@

Case #101

Important Information / Notes - + +
+ Attached Documents + + + +
+ + No documents attached. This may delay processing. +
+
+ -

Action Required

@@ -193,7 +213,7 @@

Action Required

- Edit & Resubmit Application + Edit Application
@@ -203,10 +223,12 @@

Case Management

+
+
@@ -220,22 +242,23 @@

Case Management

+ -
+ -
+
@@ -271,66 +294,95 @@

Comments

diff --git a/src/main/resources/templates/visa/edit-form.html b/src/main/resources/templates/visa/edit-form.html index c6bcce7..f4c5b21 100644 --- a/src/main/resources/templates/visa/edit-form.html +++ b/src/main/resources/templates/visa/edit-form.html @@ -102,6 +102,31 @@ margin-top: 8px; display: block; } + + input[type="file"] { + width: 105px !important; + height: 32px; + color: transparent; + cursor: pointer; + overflow: hidden; + } + + input[type="file"]::-webkit-file-upload-button { + display: none; + } + + input[type="file"]::before { + content: 'Choose File'; + display: inline-block; + background: #222; + border: 1px solid #444; + border-radius: 4px; + padding: 5px 12px; + color: #ccc; + font-weight: 600; + font-size: 13px; + } + @@ -125,7 +150,7 @@

Edit Application

+ method="post" enctype="multipart/form-data">
@@ -162,9 +187,65 @@

Edit Application

+
+ +
+
+ + + + View Attachment + + + +
+
+
+ +
+ +
+ + + + +
+ +

+ Supported Formats: PDF, JPG or PNG. (Max 10MB). +

+
+ Back to Details
+ +
+
+ +
+
+ + + + diff --git a/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java b/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java index 9f601bb..6a6da30 100644 --- a/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java +++ b/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java @@ -3,7 +3,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; -import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) @@ -11,8 +11,11 @@ public class TestcontainersConfiguration { @Bean @ServiceConnection - PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer(DockerImageName.parse("postgres:latest")); + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")) + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); } } diff --git a/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java b/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java index 0e875d6..83dbfa4 100644 --- a/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java +++ b/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java @@ -1,13 +1,19 @@ package org.example.visacasemanagementsystem; +import org.example.visacasemanagementsystem.file.FileService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + -@Import(TestcontainersConfiguration.class) @SpringBootTest +@ActiveProfiles("test") class VisaCaseManagementSystemVisaTests { + @MockitoBean + private FileService fileService; + @Test void contextLoads() { } diff --git a/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java index 8c2fbce..4567a27 100644 --- a/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java @@ -4,6 +4,7 @@ import org.example.visacasemanagementsystem.comment.dto.CreateCommentDTO; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.ResourceNotFoundException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -21,6 +22,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -41,6 +43,9 @@ class CommentServiceIntegrationTest{ @Autowired private UserRepository userRepository; + @MockitoBean + private FileService fileService; + private User testUser; private User nonExistentUser; private Visa testVisa; diff --git a/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java index d7d5546..b3879c3 100644 --- a/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java @@ -1,6 +1,7 @@ package org.example.visacasemanagementsystem.user.service; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.dto.CreateUserDTO; import org.example.visacasemanagementsystem.user.dto.UpdateUserDTO; @@ -14,6 +15,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +33,9 @@ class UserServiceIntegrationTest { @Autowired private UserRepository userRepository; + @MockitoBean + private FileService fileService; + @Test @DisplayName("Checking if createUser persists and returns the new user when all input is valid") void createUser_shouldSaveUser_WhenDataIsValid() { diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java index 03866e7..d9e671f 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java @@ -84,7 +84,8 @@ void shouldUpdateExistingVisaEntityFromUpdateVisaDTO() { "New Nationality", "NEW-456", LocalDate.now().plusMonths(1), - 2L + 2L, + null ); // Act diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java index ad59c6c..7889ed2 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java @@ -2,6 +2,7 @@ import org.example.visacasemanagementsystem.audit.AuditEventType; import org.example.visacasemanagementsystem.audit.service.AuditService; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -14,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -35,10 +37,14 @@ class VisaServiceIntegrationTest { @Autowired private AuditService auditService; + @MockitoBean + private FileService fileService; + @Test void applyForVisa_shouldSaveVisa_WhenDataIsValid() { // Arrange User user = createAndSaveValidUser(); + String testS3Key = "testS3Key"; CreateVisaDTO dto = new CreateVisaDTO( VisaType.STUDY, "Swedish", "PASS-INT-123", @@ -47,12 +53,13 @@ void applyForVisa_shouldSaveVisa_WhenDataIsValid() { ); // Act - visaService.applyForVisa(dto, user.getId()); + visaService.applyForVisa(dto, user.getId(),testS3Key); // Assert var savedVisas = visaRepository.findAll(); assertThat(savedVisas).hasSize(1); assertThat(savedVisas.get(0).getPassportNumber()).isEqualTo("PASS-INT-123"); + assertThat(savedVisas.get(0).getS3Keys()).contains(testS3Key); assertThat(auditService.findAll()).isNotEmpty(); } @@ -75,11 +82,11 @@ void updateVisa_shouldUpdateVisaAndResetStatus_WhenUserIsAuthorized() { UpdateVisaDTO dto = new UpdateVisaDTO( visa.getId(), VisaType.TOURIST, VisaStatus.SUBMITTED, - "Swedish", "NEW-PASS-999", LocalDate.now().plusMonths(1), null + "Swedish", "NEW-PASS-999", LocalDate.now().plusMonths(1), null, null ); // Act - visaService.updateVisa(visa.getId(), dto, user.getId()); + visaService.updateVisa(visa.getId(), dto, user.getId(), null); // Assert Visa updatedVisa = visaRepository.findById(visa.getId()).get(); diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java index 63ec4df..1d62bb7 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java @@ -40,8 +40,6 @@ class VisaServiceTest { @InjectMocks private VisaService visaService; - // Find metoder? - @Test void applyForVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInThePast() { // Arrange @@ -53,7 +51,7 @@ void applyForVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInThePast( ); // Act & Assert - assertThatThrownBy(() -> visaService.applyForVisa(dto, userId)) + assertThatThrownBy(() -> visaService.applyForVisa(dto, userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Travel date cannot be in the past."); @@ -74,7 +72,7 @@ void applyForVisa_shouldThrowException_WhenUserIsNotFoundInDb() { when(userRepository.findById(userId)).thenReturn(Optional.empty()); // Act & Assert - assertThatThrownBy(() -> visaService.applyForVisa(dto, userId)) + assertThatThrownBy(() -> visaService.applyForVisa(dto, userId, null)) .isInstanceOf(EntityNotFoundException.class) .hasMessage("User not found"); } @@ -88,7 +86,7 @@ void updateVisa_shouldThrowUnauthorizedException_WhenUserIsNotTheApplicant() { UpdateVisaDTO dto = new UpdateVisaDTO(100L, VisaType.EMPLOYMENT, VisaStatus.SUBMITTED, "US", "123", - LocalDate.now().plusDays(1), null); + LocalDate.now().plusDays(1), null, null); User actualApplicant = new User(); actualApplicant.setId(actualApplicantId); @@ -99,7 +97,7 @@ void updateVisa_shouldThrowUnauthorizedException_WhenUserIsNotTheApplicant() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,unauthorizedUserId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,unauthorizedUserId, null)) .isInstanceOf(UnauthorizedException.class) .hasMessage("You are not authorized to update this application."); @@ -112,7 +110,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenStatusIsNotEditable() { UpdateVisaDTO dto = new UpdateVisaDTO( visaId, VisaType.STUDY, VisaStatus.SUBMITTED, "SE", "123", - LocalDate.now().plusDays(10), null); + LocalDate.now().plusDays(10), null, null); User user = new User(); user.setId(userId); @@ -124,7 +122,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenStatusIsNotEditable() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,userId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("This application can no longer be edited."); } @@ -137,7 +135,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInPast() { LocalDate pastDate = LocalDate.now().minusDays(1); UpdateVisaDTO dto = new UpdateVisaDTO(100L, VisaType.STUDY, - VisaStatus.SUBMITTED, "Swedish", "CDE789", pastDate, null); + VisaStatus.SUBMITTED, "Swedish", "CDE789", pastDate, null, null); User actualUser = new User(); actualUser.setId(userId); @@ -149,7 +147,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInPast() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto, userId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto, userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Travel date cannot be in the past."); diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java index 95e2436..7c83d1f 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java @@ -3,6 +3,7 @@ import org.example.visacasemanagementsystem.comment.dto.CommentDTO; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.security.UserPrincipal; @@ -34,9 +35,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.mock.web.MockMultipartFile; @WebMvcTest(VisaViewController.class) class VisaViewControllerTest { @@ -49,6 +50,8 @@ class VisaViewControllerTest { private UserService userService; @MockitoBean private CommentService commentService; + @MockitoBean + private FileService fileService; @AfterEach void tearDown() { @@ -123,18 +126,26 @@ void submitApplication_WithValidData_ShouldRedirectToDashboard() throws Exceptio Long userId = 1L; UserDTO mockUser = createMockUser(userId, UserAuthorization.USER); + MockMultipartFile mockFile = new MockMultipartFile( + "passportFile", // Namnet MÃ…STE matcha @RequestParam i controllern + "test.pdf", + "application/pdf", + "test content".getBytes() + ); + // Act & Assert - mockMvc.perform(post("/visas/apply") - .param("currentUserId", userId.toString()) - .param("visaType", "TOURIST") - .param("nationality", "Swedish") - .param("passportNumber", "ABC12345") - .param("travelDate", "2026-12-01") - .with(csrf())) + mockMvc.perform(multipart("/visas/apply") + .file(mockFile) + .param("currentUserId", userId.toString()) + .param("visaType", "TOURIST") + .param("nationality", "Swedish") + .param("passportNumber", "ABC12345") + .param("travelDate", "2026-12-01") + .with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/visas/dashboard")); - verify(visaService).applyForVisa(any(CreateVisaDTO.class), eq(userId)); + verify(visaService).applyForVisa(any(CreateVisaDTO.class), eq(userId),any()); } @Test @@ -210,12 +221,14 @@ void processUpdate_Success_ShouldRedirectToDetails() throws Exception { Long visaId = 100L; Long userId = 1L; String expectedUrl = "/visas/" + visaId; + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); when(userService.findById(userId)).thenReturn(Optional.of(createMockUser(userId, UserAuthorization.USER))); when(visaService.findVisaDtoById(visaId)).thenReturn(createMockVisa(visaId, userId)); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -229,7 +242,7 @@ void processUpdate_Success_ShouldRedirectToDetails() throws Exception { result.andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl(expectedUrl)); - verify(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + verify(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); } @@ -267,12 +280,14 @@ void processUpdate_Unauthorized_ShouldReturnForbiddenStatus() throws Exception { Long visaId = 100L; Long userId = 1L; UserDTO mockUser = createMockUser(userId, UserAuthorization.USER); + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); doThrow(new UnauthorizedException("You are not authorized to update this application.")) - .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -295,16 +310,17 @@ void processUpdate_InvalidDate_ShouldReturnEditFormWithErrorMessage() throws Exc // Arrange Long visaId = 100L; Long userId = 1L; + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); when(userService.findById(userId)).thenReturn(Optional.of(createMockUser(userId, UserAuthorization.USER))); - when(visaService.findVisaDtoById(visaId)).thenReturn(createMockVisa(visaId, userId)); doThrow(new IllegalArgumentException("Travel date cannot be in the past.")) - .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -317,7 +333,8 @@ void processUpdate_InvalidDate_ShouldReturnEditFormWithErrorMessage() throws Exc // Assert result.andExpect(status().isOk()) .andExpect(view().name("visa/edit-form")) - .andExpect(model().hasErrors()); + .andExpect(model().hasErrors()) + .andExpect(model().attributeExists("statusInformation")); } @@ -507,7 +524,9 @@ private VisaDTO createMockVisa(Long visaId, Long applicantId) { null, null, LocalDateTime.now(), LocalDateTime.now(), - "Some status info" + "Some status info", + List.of(), + List.of() ); } diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 27457a7..5df0c66 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,8 +1,29 @@ +# Test Profile Configuration +spring.application.name=visa-case-management-system + +# H2 Database for Testing spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= + +# JPA Configuration for H2 spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create-drop -spring.h2.console.enabled=true +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false + +# H2 Console (optional, for debugging) +spring.h2.console.enabled=false + +# Disable unnecessary services +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + spring.flyway.enabled=false + +minio.endpoint=http://localhost:9000 +minio.accessKey=test-access-key +minio.secretKey=test-secret-key +minio.region=us-east-1 +minio.bucketName=test-bucket