package net.halfbinary.scavengerhuntapi.service import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.tasks.UnsupportedFormatException import net.halfbinary.scavengerhuntapi.error.exception.BadFileException import net.halfbinary.scavengerhuntapi.error.exception.ConflictException import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.model.FoundStatus import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.ImageVersion import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.TeamId import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toRecord import net.halfbinary.scavengerhuntapi.model.converter.toResponse import net.halfbinary.scavengerhuntapi.model.domain.Photo import net.halfbinary.scavengerhuntapi.model.domain.PhotoFile import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse import net.halfbinary.scavengerhuntapi.repository.PhotoRepository import org.springframework.core.io.InputStreamResource import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.time.OffsetDateTime private const val PHOTO_NOT_FOUND = "Photo not found" @Service class PhotoService( private val photoRepository: PhotoRepository, private val hunterService: HunterService, private val teamService: TeamService, private val huntService: HuntService, private val s3StorageService: S3StorageService, private val fileProbeService: FileProbeService ) { fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile) { val hunter = hunterService.getHunterByEmail(email) val hunt = huntService.getHunt(huntId) if (!hunter.isAdmin && !hunt.isOngoing) throw ForbiddenException() val originalBytes = file.bytes val fileType = fileProbeService.getFileType(originalBytes) if(!fileProbeService.isImageType(fileType)) throw BadFileException("Not an image") val originalAsJpeg = try { toJpeg(file.bytes) } catch (_: UnsupportedFormatException) { throw BadFileException("Image type is not supported") } val now = OffsetDateTime.now() val photo = Photo( itemId = itemId, huntId = huntId, hunterId = hunter.id, foundDateTime = now, status = PhotoStatus.SUBMITTED, statusChangeDateTime = now ) val savedRecord = photoRepository.save(photo.toRecord()) val baseName = savedRecord.id.toString() s3StorageService.upload("${baseName}_original${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType) s3StorageService.upload("${baseName}_large.jpg", originalAsJpeg, MediaType.IMAGE_JPEG_VALUE) s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), MediaType.IMAGE_JPEG_VALUE) s3StorageService.upload("${baseName}_small.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE) } fun getPhotoInfo(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String): PhotoResponse { val requestingHunter = hunterService.getHunterByEmail(email) val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId) ?: throw NotFoundException(PHOTO_NOT_FOUND) if (!requestingHunter.isAdmin) { val hunt = huntService.getHunt(huntId) if (!hunt.isOngoing) throw ForbiddenException() val team = try { teamService.getTeamForHunterInHunt(huntId, email) } catch (_: NotFoundException) { throw ForbiddenException() } if (team.id != teamId) throw ForbiddenException() } val submitter = hunterService.getHunterById(photoRecord.hunterId) return photoRecord.toDomain().toResponse(submitter) } fun getPhotoFile(photoId: PhotoId, email: String, version: ImageVersion = ImageVersion.LARGE): PhotoFile { val requestingHunter = hunterService.getHunterByEmail(email) val photoRecord = photoRepository.findByIdOrNull(photoId) ?: throw NotFoundException(PHOTO_NOT_FOUND) if (!requestingHunter.isAdmin) { val submitter = hunterService.getHunterById(photoRecord.hunterId) try { val requestingTeam = teamService.getTeamForHunterInHunt(photoRecord.huntId, requestingHunter.email) val submitterTeam = teamService.getTeamForHunterInHunt(photoRecord.huntId, submitter.email) if (requestingTeam.id != submitterTeam.id) throw ForbiddenException() } catch (_: NotFoundException) { throw ForbiddenException() } } val key = when (version) { ImageVersion.ORIGINAL -> s3StorageService.findKeyByPrefix("${photoId}_original") ?: throw NotFoundException("Photo file not found") ImageVersion.LARGE -> "${photoId}_large.jpg" ImageVersion.MEDIUM -> "${photoId}_medium.jpg" ImageVersion.SMALL -> "${photoId}_small.jpg" } val (stream, contentType) = s3StorageService.download(key) return PhotoFile(InputStreamResource(stream), MediaType.parseMediaType(contentType)) } fun getItemFoundStatus(huntId: HuntId, teamId: TeamId, itemId: ItemId, email: String): FoundStatus { val requestingHunter = hunterService.getHunterByEmail(email) if (!requestingHunter.isAdmin) { val hunt = huntService.getHunt(huntId) if (!hunt.isOngoing) throw ForbiddenException() val team = try { teamService.getTeamForHunterInHunt(huntId, email) } catch (_: NotFoundException) { throw ForbiddenException() } if (team.id != teamId) throw ForbiddenException() } val teamHunterIds = teamService.getHunterIdsForTeam(teamId) val photos = photoRepository.findByHuntIdAndItemId(huntId, itemId) .filter { it.hunterId in teamHunterIds && it.status != PhotoStatus.REMOVED } return when { photos.any { it.status == PhotoStatus.APPROVED } -> FoundStatus.APPROVED photos.any { it.status == PhotoStatus.REJECTED } -> FoundStatus.REJECTED photos.any { it.status == PhotoStatus.SUBMITTED } -> FoundStatus.SUBMITTED else -> FoundStatus.NOT_FOUND } } fun removePhoto(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String) { val requestingHunter = hunterService.getHunterByEmail(email) if (!requestingHunter.isAdmin) { val hunt = huntService.getHunt(huntId) if (!hunt.isOngoing) throw ForbiddenException() } val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId) ?: throw NotFoundException(PHOTO_NOT_FOUND) if (!requestingHunter.isAdmin) { val team = try { teamService.getTeamForHunterInHunt(huntId, email) } catch (_: NotFoundException) { throw ForbiddenException() } if (team.id != teamId) throw ForbiddenException() } if (photoRecord.status == PhotoStatus.APPROVED) throw ConflictException("Cannot remove an approved photo") photoRepository.save(photoRecord.copy(status = PhotoStatus.REMOVED, statusChangeDateTime = OffsetDateTime.now())) } fun getItemPhotos(huntId: HuntId, teamId: TeamId, itemId: ItemId, email: String): List { val requestingHunter = hunterService.getHunterByEmail(email) if (!requestingHunter.isAdmin) { val hunt = huntService.getHunt(huntId) if (!hunt.isOngoing) throw ForbiddenException() val team = try { teamService.getTeamForHunterInHunt(huntId, email) } catch (_: NotFoundException) { throw ForbiddenException() } if (team.id != teamId) throw ForbiddenException() } val teamHunterIds = teamService.getHunterIdsForTeam(teamId) return photoRepository.findByHuntIdAndItemId(huntId, itemId) .filter { it.hunterId in teamHunterIds && it.status != PhotoStatus.REMOVED } .map { it.toDomain().toResponse(hunterService.getHunterById(it.hunterId)) } } fun updatePhotoStatus(photoId: PhotoId, status: PhotoStatus) { val record = photoRepository.findByIdOrNull(photoId) ?: throw NotFoundException(PHOTO_NOT_FOUND) photoRepository.save(record.copy(status = status, statusChangeDateTime = OffsetDateTime.now())) } private fun toJpeg(bytes: ByteArray): ByteArray { val output = ByteArrayOutputStream() Thumbnails.of(ByteArrayInputStream(bytes)) .scale(1.0) .outputFormat("jpg") .toOutputStream(output) return output.toByteArray() } private fun resize(bytes: ByteArray, width: Int): ByteArray { val output = ByteArrayOutputStream() Thumbnails.of(ByteArrayInputStream(bytes)) .width(width) .outputFormat("jpg") .toOutputStream(output) return output.toByteArray() } }