Skip to content

Commit e6c3211

Browse files
authored
[Feature] Make task creation and updates an atomic operation (#598)
* feat: update create and update DTOs to exclude file ids * feat(filemanager): add new facade methods for file detachment and file role update * feat: update task controller and service to handle MultipartFiles * feat: update module dependencies * feat: update task facade method * feat: update UpdateTaskRequestDTO * feat(taskassignment): update assignment module to support MultipartFiles for task creation * test: update unit tests * fix: coderabbit issues * fix: coderabbit issues * fix: coderabbit issue
1 parent 62d1595 commit e6c3211

23 files changed

Lines changed: 660 additions & 315 deletions

File tree

src/main/java/com/itasocialacademy/oitassist/filemanager/api/FileManagerFacade.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,59 @@ List<FileDetailsDTO> uploadFiles(
5858
* @param userId the ID of user performing the detachment
5959
*/
6060
void detachAllFilesByEntity(RelatedEntityType entityType, Long entityId, Long userId);
61+
62+
/**
63+
* Marks attached files as SOFT_DELETED, detaching them from the entity. No-op
64+
* if fileIds is null or empty.
65+
*
66+
* @param entityType the type of the related entity
67+
* @param entityId the ID of the entity
68+
* @param fileIds the IDs of the files to soft-delete
69+
* @param userId the ID of the user performing the operation
70+
*/
71+
void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId);
72+
73+
/**
74+
* Marks a batch of files as SOFT_DELETED that must belong to the specified
75+
* entity. Validates that the files are attached to the given entity type and ID
76+
* before detaching.
77+
*
78+
* <p>
79+
* <strong>Does NOT perform per-file ownership checks.</strong> The calling
80+
* module must verify that the current user is authorized to modify the entity
81+
* (e.g. task ownership check) before invoking this method.
82+
* </p>
83+
*
84+
* @param entityType the type of the related entity
85+
* @param entityId the ID of the entity
86+
* @param fileIds the IDs of the files to soft-delete
87+
*/
88+
void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds);
89+
90+
/**
91+
* Updates the role of an attached file. Only the file owner or admin can
92+
* update. File must be in ATTACHED state.
93+
*
94+
* @param fileId the ID of the file to update
95+
* @param newRole the new role to assign
96+
*/
97+
void updateFileRole(Long fileId, FileRole newRole);
98+
99+
/**
100+
* Updates the role of a file that must belong to the specified entity.
101+
* Validates that the file is ATTACHED to the given entity type and ID before
102+
* updating.
103+
*
104+
* <p>
105+
* <strong>Does NOT perform per-file ownership checks.</strong> The calling
106+
* module must verify that the current user is authorized to modify the entity
107+
* (e.g. task ownership check) before invoking this method.
108+
* </p>
109+
*
110+
* @param fileId the ID of the file to update
111+
* @param newRole the new role to assign
112+
* @param entityType the expected related entity type
113+
* @param entityId the expected related entity ID
114+
*/
115+
void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType, Long entityId);
61116
}

src/main/java/com/itasocialacademy/oitassist/filemanager/controller/FileController.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.itasocialacademy.oitassist.filemanager.controller;
22

3-
import com.azure.core.annotation.QueryParam;
43
import com.itasocialacademy.oitassist.core.web.ErrorResponse;
54
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
65
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
@@ -241,7 +240,7 @@ public ResponseEntity<List<FileResponseDto>> getFiles(
241240
@PreAuthorize("isAuthenticated()")
242241
public ResponseEntity<FileResponseDto> updateRole(
243242
@PathVariable Long id,
244-
@Valid @QueryParam("newRole") UpdateFileRoleRequestDto requestDto) {
245-
return ResponseEntity.ok(fileService.updateRole(id, requestDto));
243+
@Valid @org.springframework.web.bind.annotation.RequestBody UpdateFileRoleRequestDto requestDto) {
244+
return ResponseEntity.ok(fileService.updateRoleGeneral(id, requestDto));
246245
}
247246
}

src/main/java/com/itasocialacademy/oitassist/filemanager/service/FileManagerFacadeImpl.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
66
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
77
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
8+
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
89
import com.itasocialacademy.oitassist.filemanager.service.interfaces.FileService;
910
import lombok.RequiredArgsConstructor;
1011
import org.springframework.stereotype.Component;
@@ -43,4 +44,25 @@ public List<FileDetailsDTO> uploadFiles(List<MultipartFile> files, RelatedEntity
4344
public void detachAllFilesByEntity(RelatedEntityType entityType, Long entityId, Long userId) {
4445
fileService.detachAllFilesByEntityId(entityType, entityId, userId);
4546
}
47+
48+
@Override
49+
public void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId) {
50+
fileService.detachFiles(entityType, entityId, fileIds, userId);
51+
}
52+
53+
@Override
54+
public void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds) {
55+
fileService.detachFilesForMultiOwnerEntity(entityType, entityId, fileIds);
56+
}
57+
58+
@Override
59+
public void updateFileRole(Long fileId, FileRole newRole) {
60+
fileService.updateRoleGeneral(fileId, new UpdateFileRoleRequestDto(newRole));
61+
}
62+
63+
@Override
64+
public void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType,
65+
Long entityId) {
66+
fileService.updateRoleForMultiOwnerEntity(fileId, newRole, entityType, entityId);
67+
}
4668
}

src/main/java/com/itasocialacademy/oitassist/filemanager/service/FileServiceImpl.java

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,21 @@ public void detachFiles(RelatedEntityType entityType, Long entityId, List<Long>
227227
detachFilesHelper(entityType, entityId, userId, files);
228228
}
229229

230+
@Override
231+
@Transactional
232+
public void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds) {
233+
if (fileIds == null || fileIds.isEmpty()) {
234+
return;
235+
}
236+
List<FileAsset> files = repository.findAllById(fileIds);
237+
238+
for (FileAsset file : files) {
239+
validateEntityBoundary(file, entityType, entityId);
240+
markAsSoftDeleted(file);
241+
}
242+
repository.saveAll(files);
243+
}
244+
230245
/**
231246
* {@inheritDoc}
232247
*/
@@ -331,51 +346,81 @@ public Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType enti
331346

332347
@Override
333348
@Transactional
334-
public FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto request) {
349+
public FileResponseDto updateRoleGeneral(Long fileId, UpdateFileRoleRequestDto request) {
335350
Long currentUserId = securityFacade.getCurrentUserId()
336-
.orElseThrow(() -> new AuthorizationException(NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
351+
.orElseThrow(() -> new AuthorizationException(
352+
NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
337353

338354
FileAsset file = repository.findById(fileId)
339-
.orElseThrow(() -> new FileAssetNotFoundException(FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
355+
.orElseThrow(() -> new FileAssetNotFoundException(
356+
FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
340357

341358
checkOwnerOrAdmin(file.getUserId(), currentUserId);
342359

360+
FileAsset saved = validateAndUpdateRole(file, request.getNewRole(),
361+
file.getRelatedEntityType(), file.getRelatedEntityId());
362+
363+
StorageProvider provider = providerResolver.resolve(saved.getStorageProvider());
364+
FileResponseDto dto = fileMapper.toDto(saved);
365+
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
366+
return dto;
367+
}
368+
369+
@Override
370+
@Transactional
371+
public void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType,
372+
Long entityId) {
373+
FileAsset file = repository.findById(fileId)
374+
.orElseThrow(() -> new FileAssetNotFoundException(
375+
FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
376+
377+
validateAndUpdateRole(file, newRole, entityType, entityId);
378+
}
379+
380+
/**
381+
* Validates and updates the role of a given file asset. Ensures the file is in
382+
* an ATTACHED state, belongs to the specified entity, and that the new role is
383+
* allowed for its extension based on the entity's policy.
384+
*
385+
* @param file the file asset to update
386+
* @param newRole the new role to assign
387+
* @param entityType the expected related entity type
388+
* @param entityId the expected related entity ID
389+
* @return the saved file asset (or the original if the role is already set)
390+
* @throws ValidationException if the file is not ATTACHED, does not belong to
391+
* the entity, or the extension is disallowed
392+
*/
393+
private FileAsset validateAndUpdateRole(FileAsset file, FileRole newRole, RelatedEntityType entityType,
394+
Long entityId) {
343395
if (!file.getStatus().equals(FileStatus.ATTACHED)) {
344396
throw new ValidationException(
345397
"File must be in ATTACHED state to update its role",
346398
ErrorCode.FILE_VALIDATION_FAILED);
347399
}
348400

349-
StorageProvider provider = providerResolver.resolve(file.getStorageProvider());
401+
validateEntityBoundary(file, entityType, entityId);
350402

351-
if (file.getFileRole().equals(request.getNewRole())) {
352-
FileAsset saved = repository.save(file);
353-
FileResponseDto dto = fileMapper.toDto(saved);
354-
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
355-
return dto;
403+
if (file.getFileRole().equals(newRole)) {
404+
return file;
356405
}
357406

358-
// Validate if new role is allowed for the file extension
359-
FilePolicy newPolicy = filePolicyResolver.resolve(file.getRelatedEntityType(), request.getNewRole());
407+
FilePolicy newPolicy = filePolicyResolver.resolve(entityType, newRole);
360408
String extension = extractExtension(file.getOriginalFilename());
361409

362410
if (isExtensionNotAllowed(extension, newPolicy.getAllowedExtensions())) {
363411
throw new ValidationException(
364-
"File '%s' has extension '%s' which is not allowed for role %s. Allowed: %s.".formatted(
365-
file.getOriginalFilename(), extension, request.getNewRole(),
366-
formatAllowed(newPolicy.getAllowedExtensions())),
412+
"File '%s' has extension '%s' which is not allowed for role %s. Allowed: %s."
413+
.formatted(file.getOriginalFilename(), extension, newRole,
414+
formatAllowed(newPolicy.getAllowedExtensions())),
367415
ErrorCode.FILE_VALIDATION_FAILED);
368416
}
369417

370-
file.setFileRole(request.getNewRole());
418+
file.setFileRole(newRole);
371419
FileAsset saved = repository.save(file);
420+
log.debug("Updated file role for id={} to {} (entity {}:{})",
421+
file.getId(), newRole, entityType, entityId);
372422

373-
log.debug("Updated file role for id={} to {}", saved.getId(), saved.getFileRole());
374-
375-
FileResponseDto dto = fileMapper.toDto(saved);
376-
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
377-
378-
return dto;
423+
return saved;
379424
}
380425

381426
/**
@@ -391,17 +436,41 @@ private void detachFilesHelper(RelatedEntityType entityType, Long entityId, Long
391436
boolean isAdmin = securityFacade.hasRole(ROLE_ADMIN);
392437

393438
for (FileAsset file : files) {
394-
if (!entityType.equals(file.getRelatedEntityType())
395-
|| !entityId.equals(file.getRelatedEntityId())) {
396-
throw new ValidationException(
397-
"File id=" + file.getId() + " does not belong to " + entityType + " id=" + entityId,
398-
ErrorCode.FILE_VALIDATION_FAILED);
399-
}
439+
validateEntityBoundary(file, entityType, entityId);
400440
checkOwnerOrAdmin(file.getUserId(), userId, isAdmin);
441+
markAsSoftDeleted(file);
442+
}
443+
repository.saveAll(files);
444+
}
445+
446+
/**
447+
* Validates that the given file asset belongs to the specified entity.
448+
*
449+
* @param file the file asset to validate
450+
* @param entityType the expected related entity type
451+
* @param entityId the expected related entity ID
452+
* @throws ValidationException if the file does not belong to the entity
453+
*/
454+
private void validateEntityBoundary(FileAsset file, RelatedEntityType entityType, Long entityId) {
455+
if (!entityType.equals(file.getRelatedEntityType()) || !entityId.equals(file.getRelatedEntityId())) {
456+
throw new ValidationException(
457+
"File id=" + file.getId() + " does not belong to " + entityType + " id=" + entityId,
458+
ErrorCode.FILE_VALIDATION_FAILED);
459+
}
460+
}
461+
462+
/**
463+
* Updates the file status to SOFT_DELETED and sets the deletion timestamp.
464+
* Note: This does not persist the changes to the database; the caller must save
465+
* the entity.
466+
*
467+
* @param file the file asset to mark as soft deleted
468+
*/
469+
private void markAsSoftDeleted(FileAsset file) {
470+
if (file.getStatus().equals(FileStatus.ATTACHED)) {
401471
file.setStatus(FileStatus.SOFT_DELETED);
402472
file.setDeletedAt(OffsetDateTime.now());
403473
}
404-
repository.saveAll(files);
405474
}
406475

407476
/**

src/main/java/com/itasocialacademy/oitassist/filemanager/service/interfaces/FileService.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ public interface FileService {
7575
*/
7676
void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId);
7777

78+
/**
79+
* Marks attached files as SOFT_DELETED for a specific entity. Validates the
80+
* entity boundary but skips per-file ownership checks. The caller is
81+
* responsible for ensuring the user has permission to modify the entity.
82+
*
83+
* @param entityType the type of the related entity
84+
* @param entityId the ID of the entity
85+
* @param fileIds the IDs of the files to soft-delete
86+
*/
87+
void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds);
88+
7889
/**
7990
* Marks a batch of files as SOFT_DELETED. Called when files are removed from
8091
* content. Validates ownership for each file using the provided userId.
@@ -127,5 +138,16 @@ Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType entityType,
127138
* @param requestDto the DTO containing the new role
128139
* @return the updated file response DTO
129140
*/
130-
FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto requestDto);
141+
FileResponseDto updateRoleGeneral(Long fileId, UpdateFileRoleRequestDto requestDto);
142+
143+
/**
144+
* Updates the role of a file that must be ATTACHED to the specified entity.
145+
* Validates entity boundary. Does NOT check per-file ownership.
146+
*
147+
* @param fileId the ID of the file to update
148+
* @param newRole the new role to assign
149+
* @param entityType the expected related entity type
150+
* @param entityId the expected related entity ID
151+
*/
152+
void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType, Long entityId);
131153
}

src/main/java/com/itasocialacademy/oitassist/task/api/TaskBodyFacade.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.itasocialacademy.oitassist.task.api;
22

33
import com.itasocialacademy.oitassist.task.api.dto.TaskBodyDetail;
4+
import org.springframework.web.multipart.MultipartFile;
45
import java.util.List;
56
import java.util.Map;
67
import java.util.Optional;
@@ -14,5 +15,7 @@ public interface TaskBodyFacade {
1415

1516
Map<Long, String> getTaskTitlesByIds(List<Long> taskBodyIds);
1617

17-
TaskBodyDetail createTask(String title, String description, List<Long> fileIds);
18+
TaskBodyDetail createTask(String title, String description,
19+
List<MultipartFile> problemFiles, List<MultipartFile> referenceFiles,
20+
List<MultipartFile> solutionFiles);
1821
}

0 commit comments

Comments
 (0)