Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,59 @@ List<FileDetailsDTO> uploadFiles(
* @param userId the ID of user performing the detachment
*/
void detachAllFilesByEntity(RelatedEntityType entityType, Long entityId, Long userId);

/**
* Marks attached files as SOFT_DELETED, detaching them from the entity. No-op
* if fileIds is null or empty.
*
* @param entityType the type of the related entity
* @param entityId the ID of the entity
* @param fileIds the IDs of the files to soft-delete
* @param userId the ID of the user performing the operation
*/
void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId);

/**
* Marks a batch of files as SOFT_DELETED that must belong to the specified
* entity. Validates that the files are attached to the given entity type and ID
* before detaching.
*
* <p>
* <strong>Does NOT perform per-file ownership checks.</strong> The calling
* module must verify that the current user is authorized to modify the entity
* (e.g. task ownership check) before invoking this method.
* </p>
*
* @param entityType the type of the related entity
* @param entityId the ID of the entity
* @param fileIds the IDs of the files to soft-delete
*/
void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds);

/**
* Updates the role of an attached file. Only the file owner or admin can
* update. File must be in ATTACHED state.
*
* @param fileId the ID of the file to update
* @param newRole the new role to assign
*/
void updateFileRole(Long fileId, FileRole newRole);

/**
* Updates the role of a file that must belong to the specified entity.
* Validates that the file is ATTACHED to the given entity type and ID before
* updating.
*
* <p>
* <strong>Does NOT perform per-file ownership checks.</strong> The calling
* module must verify that the current user is authorized to modify the entity
* (e.g. task ownership check) before invoking this method.
* </p>
*
* @param fileId the ID of the file to update
* @param newRole the new role to assign
* @param entityType the expected related entity type
* @param entityId the expected related entity ID
*/
void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType, Long entityId);
Comment thread
solenuk marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.itasocialacademy.oitassist.filemanager.controller;

import com.azure.core.annotation.QueryParam;
import com.itasocialacademy.oitassist.core.web.ErrorResponse;
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
Expand Down Expand Up @@ -241,7 +240,7 @@ public ResponseEntity<List<FileResponseDto>> getFiles(
@PreAuthorize("isAuthenticated()")
public ResponseEntity<FileResponseDto> updateRole(
@PathVariable Long id,
@Valid @QueryParam("newRole") UpdateFileRoleRequestDto requestDto) {
return ResponseEntity.ok(fileService.updateRole(id, requestDto));
@Valid @org.springframework.web.bind.annotation.RequestBody UpdateFileRoleRequestDto requestDto) {
return ResponseEntity.ok(fileService.updateRoleGeneral(id, requestDto));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
import com.itasocialacademy.oitassist.filemanager.service.interfaces.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -43,4 +44,25 @@ public List<FileDetailsDTO> uploadFiles(List<MultipartFile> files, RelatedEntity
public void detachAllFilesByEntity(RelatedEntityType entityType, Long entityId, Long userId) {
fileService.detachAllFilesByEntityId(entityType, entityId, userId);
}

@Override
public void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId) {
fileService.detachFiles(entityType, entityId, fileIds, userId);
}

@Override
public void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds) {
fileService.detachFilesForMultiOwnerEntity(entityType, entityId, fileIds);
}

@Override
public void updateFileRole(Long fileId, FileRole newRole) {
fileService.updateRoleGeneral(fileId, new UpdateFileRoleRequestDto(newRole));
}

@Override
public void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType,
Long entityId) {
fileService.updateRoleForMultiOwnerEntity(fileId, newRole, entityType, entityId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@ public void detachFiles(RelatedEntityType entityType, Long entityId, List<Long>
detachFilesHelper(entityType, entityId, userId, files);
}

@Override
@Transactional
public void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds) {
if (fileIds == null || fileIds.isEmpty()) {
return;
}
List<FileAsset> files = repository.findAllById(fileIds);

for (FileAsset file : files) {
validateEntityBoundary(file, entityType, entityId);
markAsSoftDeleted(file);
Comment thread
solenuk marked this conversation as resolved.
}
repository.saveAll(files);
}

/**
* {@inheritDoc}
*/
Expand Down Expand Up @@ -331,51 +346,81 @@ public Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType enti

@Override
@Transactional
public FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto request) {
public FileResponseDto updateRoleGeneral(Long fileId, UpdateFileRoleRequestDto request) {
Long currentUserId = securityFacade.getCurrentUserId()
.orElseThrow(() -> new AuthorizationException(NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
.orElseThrow(() -> new AuthorizationException(
NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));

FileAsset file = repository.findById(fileId)
.orElseThrow(() -> new FileAssetNotFoundException(FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
.orElseThrow(() -> new FileAssetNotFoundException(
FILE_NOT_FOUND_IN_THE_DATABASE + fileId));

checkOwnerOrAdmin(file.getUserId(), currentUserId);

FileAsset saved = validateAndUpdateRole(file, request.getNewRole(),
file.getRelatedEntityType(), file.getRelatedEntityId());

StorageProvider provider = providerResolver.resolve(saved.getStorageProvider());
FileResponseDto dto = fileMapper.toDto(saved);
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
return dto;
}

@Override
@Transactional
public void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType,
Long entityId) {
FileAsset file = repository.findById(fileId)
.orElseThrow(() -> new FileAssetNotFoundException(
FILE_NOT_FOUND_IN_THE_DATABASE + fileId));

validateAndUpdateRole(file, newRole, entityType, entityId);
}

/**
* Validates and updates the role of a given file asset. Ensures the file is in
* an ATTACHED state, belongs to the specified entity, and that the new role is
* allowed for its extension based on the entity's policy.
*
* @param file the file asset to update
* @param newRole the new role to assign
* @param entityType the expected related entity type
* @param entityId the expected related entity ID
* @return the saved file asset (or the original if the role is already set)
* @throws ValidationException if the file is not ATTACHED, does not belong to
* the entity, or the extension is disallowed
*/
private FileAsset validateAndUpdateRole(FileAsset file, FileRole newRole, RelatedEntityType entityType,
Long entityId) {
if (!file.getStatus().equals(FileStatus.ATTACHED)) {
throw new ValidationException(
"File must be in ATTACHED state to update its role",
ErrorCode.FILE_VALIDATION_FAILED);
}

StorageProvider provider = providerResolver.resolve(file.getStorageProvider());
validateEntityBoundary(file, entityType, entityId);

if (file.getFileRole().equals(request.getNewRole())) {
FileAsset saved = repository.save(file);
FileResponseDto dto = fileMapper.toDto(saved);
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
return dto;
if (file.getFileRole().equals(newRole)) {
return file;
}

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

if (isExtensionNotAllowed(extension, newPolicy.getAllowedExtensions())) {
throw new ValidationException(
"File '%s' has extension '%s' which is not allowed for role %s. Allowed: %s.".formatted(
file.getOriginalFilename(), extension, request.getNewRole(),
formatAllowed(newPolicy.getAllowedExtensions())),
"File '%s' has extension '%s' which is not allowed for role %s. Allowed: %s."
.formatted(file.getOriginalFilename(), extension, newRole,
formatAllowed(newPolicy.getAllowedExtensions())),
ErrorCode.FILE_VALIDATION_FAILED);
}

file.setFileRole(request.getNewRole());
file.setFileRole(newRole);
FileAsset saved = repository.save(file);
log.debug("Updated file role for id={} to {} (entity {}:{})",
file.getId(), newRole, entityType, entityId);

log.debug("Updated file role for id={} to {}", saved.getId(), saved.getFileRole());

FileResponseDto dto = fileMapper.toDto(saved);
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));

return dto;
return saved;
}

/**
Expand All @@ -391,19 +436,42 @@ private void detachFilesHelper(RelatedEntityType entityType, Long entityId, Long
boolean isAdmin = securityFacade.hasRole(ROLE_ADMIN);

for (FileAsset file : files) {
if (!entityType.equals(file.getRelatedEntityType())
|| !entityId.equals(file.getRelatedEntityId())) {
throw new ValidationException(
"File id=" + file.getId() + " does not belong to " + entityType + " id=" + entityId,
ErrorCode.FILE_VALIDATION_FAILED);
}
validateEntityBoundary(file, entityType, entityId);
checkOwnerOrAdmin(file.getUserId(), userId, isAdmin);
file.setStatus(FileStatus.SOFT_DELETED);
file.setDeletedAt(OffsetDateTime.now());
markAsSoftDeleted(file);
}
repository.saveAll(files);
}

/**
* Validates that the given file asset belongs to the specified entity.
*
* @param file the file asset to validate
* @param entityType the expected related entity type
* @param entityId the expected related entity ID
* @throws ValidationException if the file does not belong to the entity
*/
private void validateEntityBoundary(FileAsset file, RelatedEntityType entityType, Long entityId) {
if (!entityType.equals(file.getRelatedEntityType()) || !entityId.equals(file.getRelatedEntityId())) {
throw new ValidationException(
"File id=" + file.getId() + " does not belong to " + entityType + " id=" + entityId,
ErrorCode.FILE_VALIDATION_FAILED);
}
}

/**
* Updates the file status to SOFT_DELETED and sets the deletion timestamp.
* Note: This does not persist the changes to the database; the caller must save
* the entity.
*
* @param file the file asset to mark as soft deleted
*/
private void markAsSoftDeleted(FileAsset file) {
file.setStatus(FileStatus.SOFT_DELETED);
file.setDeletedAt(OffsetDateTime.now());
}

/**
* Checks if given files are valid according to the validation strategy.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public interface FileService {
*/
void detachFiles(RelatedEntityType entityType, Long entityId, List<Long> fileIds, Long userId);

/**
* Marks attached files as SOFT_DELETED for a specific entity. Validates the
* entity boundary but skips per-file ownership checks. The caller is
* responsible for ensuring the user has permission to modify the entity.
*
* @param entityType the type of the related entity
* @param entityId the ID of the entity
* @param fileIds the IDs of the files to soft-delete
*/
void detachFilesForMultiOwnerEntity(RelatedEntityType entityType, Long entityId, List<Long> fileIds);

/**
* Marks a batch of files as SOFT_DELETED. Called when files are removed from
* content. Validates ownership for each file using the provided userId.
Expand Down Expand Up @@ -127,5 +138,16 @@ Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType entityType,
* @param requestDto the DTO containing the new role
* @return the updated file response DTO
*/
FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto requestDto);
FileResponseDto updateRoleGeneral(Long fileId, UpdateFileRoleRequestDto requestDto);

/**
* Updates the role of a file that must be ATTACHED to the specified entity.
* Validates entity boundary. Does NOT check per-file ownership.
*
* @param fileId the ID of the file to update
* @param newRole the new role to assign
* @param entityType the expected related entity type
* @param entityId the expected related entity ID
*/
void updateRoleForMultiOwnerEntity(Long fileId, FileRole newRole, RelatedEntityType entityType, Long entityId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.itasocialacademy.oitassist.task.api;

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

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

TaskBodyDetail createTask(String title, String description, List<Long> fileIds);
TaskBodyDetail createTask(String title, String description,
List<MultipartFile> problemFiles, List<MultipartFile> referenceFiles,
List<MultipartFile> solutionFiles);
}
Loading
Loading