JobUseCaseImpl.java

package com.wilzwert.myjobs.core.application.usecase;


import com.wilzwert.myjobs.core.domain.model.*;
import com.wilzwert.myjobs.core.domain.model.activity.Activity;
import com.wilzwert.myjobs.core.domain.model.activity.ActivityType;
import com.wilzwert.myjobs.core.domain.model.activity.command.CreateActivityCommand;
import com.wilzwert.myjobs.core.domain.model.activity.command.UpdateActivityCommand;
import com.wilzwert.myjobs.core.domain.model.activity.event.integration.ActivityAutomaticallyCreatedEvent;
import com.wilzwert.myjobs.core.domain.model.activity.event.integration.ActivityCreatedEvent;
import com.wilzwert.myjobs.core.domain.model.attachment.Attachment;
import com.wilzwert.myjobs.core.domain.model.attachment.AttachmentId;
import com.wilzwert.myjobs.core.domain.model.attachment.command.CreateAttachmentCommand;
import com.wilzwert.myjobs.core.domain.model.attachment.command.DeleteAttachmentCommand;
import com.wilzwert.myjobs.core.domain.model.attachment.command.DownloadAttachmentCommand;
import com.wilzwert.myjobs.core.domain.model.attachment.event.integration.AttachmentCreatedEvent;
import com.wilzwert.myjobs.core.domain.model.attachment.event.integration.AttachmentDeletedEvent;
import com.wilzwert.myjobs.core.domain.model.attachment.exception.AttachmentNotFoundException;
import com.wilzwert.myjobs.core.domain.model.attachment.ports.driving.DownloadAttachmentUseCase;
import com.wilzwert.myjobs.core.domain.model.attachment.ports.driving.GetAttachmentFileInfoUseCase;
import com.wilzwert.myjobs.core.domain.model.job.*;
import com.wilzwert.myjobs.core.domain.model.job.command.*;
import com.wilzwert.myjobs.core.domain.model.job.event.integration.*;
import com.wilzwert.myjobs.core.domain.model.job.exception.JobNotFoundException;
import com.wilzwert.myjobs.core.domain.model.job.ports.driven.JobDataManager;
import com.wilzwert.myjobs.core.domain.model.job.ports.driving.*;
import com.wilzwert.myjobs.core.domain.model.user.ports.driven.UserDataManager;
import com.wilzwert.myjobs.core.domain.shared.event.integration.IntegrationEventId;
import com.wilzwert.myjobs.core.domain.shared.exception.DomainException;
import com.wilzwert.myjobs.core.domain.shared.pagination.DomainPage;
import com.wilzwert.myjobs.core.domain.model.user.User;
import com.wilzwert.myjobs.core.domain.model.user.UserId;
import com.wilzwert.myjobs.core.domain.model.user.exception.UserNotFoundException;
import com.wilzwert.myjobs.core.domain.model.user.ports.driving.GetUserJobUseCase;
import com.wilzwert.myjobs.core.domain.model.user.ports.driving.GetUserJobsUseCase;
import com.wilzwert.myjobs.core.domain.model.job.service.JobEnricher;
import com.wilzwert.myjobs.core.domain.shared.ports.driven.FileStorage;
import com.wilzwert.myjobs.core.domain.shared.ports.driven.HtmlSanitizer;
import com.wilzwert.myjobs.core.domain.shared.ports.driven.event.IntegrationEventPublisher;
import com.wilzwert.myjobs.core.domain.shared.ports.driven.transaction.TransactionProvider;
import com.wilzwert.myjobs.core.domain.shared.specification.DomainSpecification;
import com.wilzwert.myjobs.core.domain.shared.validation.ErrorCode;

import java.lang.reflect.Method;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;

/**
 * @author Wilhelm Zwertvaegher
 */

public class JobUseCaseImpl implements CreateJobUseCase, GetUserJobUseCase, UpdateJobUseCase, UpdateJobStatusUseCase, UpdateJobRatingUseCase, DeleteJobUseCase, GetUserJobsUseCase, AddActivityToJobUseCase, UpdateActivityUseCase, AddAttachmentToJobUseCase, DownloadAttachmentUseCase, DeleteAttachmentUseCase, GetAttachmentFileInfoUseCase {

    private final TransactionProvider transactionProvider;

    private final IntegrationEventPublisher integrationEventPublisher;

    private final JobDataManager jobDataManager;

    private final UserDataManager userDataManager;

    private final FileStorage fileStorage;

    private final HtmlSanitizer htmlSanitizer;

    private final JobEnricher jobEnricher = new JobEnricher();

    public JobUseCaseImpl(
            TransactionProvider transactionProvider,
            IntegrationEventPublisher integrationEventPublisher,
            JobDataManager jobDataManager,
            UserDataManager userDataManager,
            FileStorage fileStorage,
            HtmlSanitizer htmlSanitizer) {
        this.transactionProvider = transactionProvider;
        this.integrationEventPublisher = integrationEventPublisher;
        this.jobDataManager = jobDataManager;
        this.userDataManager = userDataManager;
        this.fileStorage = fileStorage;
        this.htmlSanitizer = htmlSanitizer;
    }

    @Override
    public Job createJob(CreateJobCommand command) {
        Optional<User> user = userDataManager.findById(command.userId());
        if(user.isEmpty()) {
            throw new UserNotFoundException();
        }

        CreateJobCommand actualCommand = sanitizeCommandFields(command, List.of("title", "company", "description", "profile", "comment", "salary"));

        return transactionProvider.executeInTransaction(() -> {
            Job jobToCreate = Job.create(
                    Job.builder()
                            .url(actualCommand.url())
                            .title(actualCommand.title())
                            .company(actualCommand.company())
                            .description(actualCommand.description())
                            .profile(actualCommand.profile())
                            .comment(actualCommand.comment())
                            .salary(actualCommand.salary())
                            .userId(user.get().getId())
            );
            User updatedUser = user.get().addJob(jobToCreate);

            Job job = updatedUser.getJobByUrl(jobToCreate.getUrl()).orElseThrow(() -> new DomainException(ErrorCode.UNEXPECTED_ERROR));
            userDataManager.saveUserAndJob(updatedUser, job);
            integrationEventPublisher.publish(new JobCreatedEvent(IntegrationEventId.generate(), job.getId()));
            return job;
        });
    }

    @Override
    public void deleteJob(DeleteJobCommand command) {
        Optional<User> foundUser = userDataManager.findById(command.userId());
        if(foundUser.isEmpty()) {
            throw new UserNotFoundException();
        }

        User user = foundUser.get();

        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), user.getId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }
        Job job = foundJob.get();

        transactionProvider.executeInTransaction(() -> {
            // delete attachments' files
            job.getAttachments().forEach(attachment -> {
                try {
                    fileStorage.delete(attachment.getFileId());
                }
                catch (Exception e) {
                    // TODO log incoherence
                }
            });
            User updatedUser = user.removeJob(job);
            userDataManager.deleteJobAndSaveUser(updatedUser, job);
            integrationEventPublisher.publish(new JobDeletedEvent(IntegrationEventId.generate(), job.getId()));
            return null;
        });


    }

    @Override
    public DomainPage<EnrichedJob> getUserJobs(UserId userId, int page, int size, JobStatus status, JobStatusMeta statusMeta, String sort) {
        Optional<User> foundUser = userDataManager.findById(userId);
        if(foundUser.isEmpty()) {
            throw new UserNotFoundException();
        }

        User user = foundUser.get();

        List<DomainSpecification> specs = new ArrayList<>(List.of(DomainSpecification.eq("userId", user.getId(), UserId.class)));

        DomainPage<Job> jobs;

        String statusField = "status";

        if(statusMeta != null) {
            // threshold instant : jobs not updated since that instant are considered late
            switch (statusMeta) {
                case ACTIVE: specs.add(DomainSpecification.in(statusField, JobStatus.activeStatuses())); break;
                case INACTIVE: specs.add(DomainSpecification.in(statusField, JobStatus.inactiveStatuses())); break;
                case LATE:
                    Instant nowMinusReminderDays = Instant.now().minus(user.getJobFollowUpReminderDays(), ChronoUnit.DAYS);
                    specs.add(DomainSpecification.in(statusField, JobStatus.activeStatuses()));
                    specs.add(DomainSpecification.lt("statusUpdatedAt", nowMinusReminderDays));
                    break;
            }
        }

        if( status != null) {
            specs.add(DomainSpecification.eq(statusField, status, JobStatus.class));
        }

        var finalSpecs = DomainSpecification.and(specs);
        if(sort != null && !sort.isEmpty()) {
            DomainSpecification.applySort(finalSpecs, DomainSpecification.sort(sort));
        }

        jobs = jobDataManager.findPaginated(finalSpecs, page, size);
        return jobEnricher.enrich(jobs, user);
    }

    @Override
    public Job updateJobField(UpdateJobFieldCommand command) {
        User user = userDataManager.findById(command.userId()).orElseThrow(UserNotFoundException::new);
        Job job = jobDataManager.findByIdAndUserId(command.jobId(), user.getId()).orElseThrow(JobNotFoundException::new);
        UpdateJobFieldCommand actualCommand = sanitizeCommandFields(command, List.of("value"));

        return transactionProvider.executeInTransaction(() -> {
            User updatedUser = user.updateJobField(job, actualCommand.field(), actualCommand.value());
            // soft reload the updatedJob in the loaded collection
            Job updatedJob = updatedUser.getJobById(job.getId()).orElseThrow(() -> new DomainException(ErrorCode.UNEXPECTED_ERROR));

            // FIXME
            // this is an ugly workaround to force the infra (persistence in particular) to save all data
            // as I understand DDD, only the root aggregate should be explicitly persisted
            // but I just don't how to do it cleanly for now
            userDataManager.saveUserAndJob(updatedUser, updatedJob);

            integrationEventPublisher.publish(new JobFieldUpdatedEvent(IntegrationEventId.generate(), job.getId(), actualCommand.field()));
            return updatedJob;
        });
    }

    @Override
    public Job updateJob(UpdateJobFullCommand command) {
        User user = userDataManager.findById(command.userId()).orElseThrow(UserNotFoundException::new);
        Job job = jobDataManager.findByIdAndUserId(command.jobId(), user.getId()).orElseThrow(JobNotFoundException::new);

        UpdateJobFullCommand actualCommand = sanitizeCommandFields(command, List.of("title", "company", "description", "profile", "comment", "salary"));

        return transactionProvider.executeInTransaction(() -> {

            User updatedUser = user.updateJob(job, actualCommand.url(), actualCommand.title(), actualCommand.company(), actualCommand.description(), actualCommand.profile(), actualCommand.comment(), actualCommand.salary());
            // soft reload the updatedJob in the loaded collection
            Job updatedJob = updatedUser.getJobById(job.getId()).orElseThrow(() -> new DomainException(ErrorCode.UNEXPECTED_ERROR));

            // FIXME
            // this is an ugly workaround to force the infra (persistence in particular) to save all data
            // as I understand DDD, only the root aggregate should be explicitly persisted
            // but I just don't how to do it cleanly for now
            userDataManager.saveUserAndJob(updatedUser, updatedJob);
            integrationEventPublisher.publish(new JobUpdatedEvent(IntegrationEventId.generate(), job.getId()));
            return updatedJob;
        });
    }

    @Override
    public Activity addActivityToJob(CreateActivityCommand command) {
        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), command.userId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }

        Job job = foundJob.get();

        CreateActivityCommand actualCommand = sanitizeCommandFields(command, List.of("comment"));

        return transactionProvider.executeInTransaction(() -> {
            Activity activity = Activity.builder()
                    .type(actualCommand.activityType())
                    .comment(actualCommand.comment())
                    .build();
            Job updatedJob = job.addActivity(activity);

            // FIXME
            // this is an ugly workaround to force the infra (persistence in particular) to save all data
            // as I understand DDD, only the aggregate should be explicitly persisted
            // but I just don't how to do it cleanly for now
            this.jobDataManager.saveJobAndActivity(updatedJob, activity);

            this.integrationEventPublisher.publish(new ActivityCreatedEvent(IntegrationEventId.generate(), job.getId(), activity.getId(), activity.getType()));
            return activity;
        });
    }

    @Override
    public Activity updateActivity(UpdateActivityCommand command) {
        Job job = jobDataManager.findByIdAndUserId(command.jobId(), command.userId()).orElseThrow(JobNotFoundException::new);

        Activity activity = Activity.builder()
                .id(command.activityId())
                .type(command.activityType())
                .comment(command.comment())
                .build();

        job = job.updateActivity(activity);

        // FIXME
        // this is an ugly workaround to force the infra (persistence in particular) to save all data
        // as I understand DDD, only the aggregate should be explicitly persisted
        // but I just don't how to do it cleanly for now
        this.jobDataManager.saveJobAndActivity(job, activity);
        return activity;
    }

    @Override
    public Job getUserJob(UserId userId, JobId jobId) {
        return jobDataManager.findByIdAndUserId(jobId, userId).orElseThrow(JobNotFoundException::new);
    }

    /**
     * FIXME : this should be improved to avoid reflection and ugly casts, and externalized
     * @param command the command to sanitize
     * @param fieldsToSanitize the command fields to sanitize
     * @return a new comment of the same class
     * @param <T> the command class
     */
    private <T> T sanitizeCommandFields(T command, List<String> fieldsToSanitize) {
        Class<?> clazz = command.getClass();

        Object builder;
        try {
            // get a builder
            Class<?> builderClass = Class.forName(clazz.getName()+"$Builder");
            builder = builderClass.getConstructor(clazz).newInstance(command);

            for (String field : fieldsToSanitize) {
                Method getterMethod = clazz.getMethod(field);
                String fieldValue = (String) getterMethod.invoke(command);

                if (fieldValue != null) {
                    String sanitizedValue = htmlSanitizer.sanitize(fieldValue);
                    Method setterMethod = builder.getClass().getMethod(field, String.class);
                    setterMethod.invoke(builder, sanitizedValue);
                }
            }
            return (T) builder.getClass().getMethod("build").invoke(builder);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return command;
    }

    @Override
    public Attachment addAttachmentToJob(CreateAttachmentCommand command) {
        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), command.userId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }

        Job job = foundJob.get();
        AttachmentId attachmentId = AttachmentId.generate();

        return transactionProvider.executeInTransaction(() -> {
            DownloadableFile file = fileStorage.store(command.file(), command.userId().value().toString() + "/" + attachmentId.value().toString(), command.filename());
            Attachment attachment = Attachment.builder()
                    .id(attachmentId)
                    .name(command.name())
                    .fileId(file.fileId())
                    .filename(command.filename())
                    .contentType(file.contentType()).build();
            Job updatedJob = job.addAttachment(attachment);

            Activity activity = Activity.builder().type(ActivityType.ATTACHMENT_CREATION).comment(attachment.getName()).build();
            updatedJob = updatedJob.addActivity(activity);

            // FIXME
            // this is an ugly workaround to force the infra (persistence in particular) to save all data
            // as I understand DDD, only the aggregate should be explicitly persisted
            // but I just don't how to do it cleanly for now
            jobDataManager.saveJobAndAttachment(updatedJob, attachment, activity);
            integrationEventPublisher.publish(new AttachmentCreatedEvent(IntegrationEventId.generate(), job.getId(), attachment.getId()));
            integrationEventPublisher.publish(new ActivityAutomaticallyCreatedEvent(IntegrationEventId.generate(), job.getId(), activity.getId(), activity.getType()));
            return attachment;
        });
    }


    @Override
    public DownloadableFile downloadAttachment(DownloadAttachmentCommand command) {
        Job job = jobDataManager.findByIdAndUserId(command.jobId(), command.userId()).orElseThrow(JobNotFoundException::new);

        Attachment attachment = job.getAttachments().stream().filter(a -> a.getId().value().toString().equals(command.id())).findAny().orElse(null);
        if(attachment == null) {
            throw new AttachmentNotFoundException();
        }

        return fileStorage.retrieve(attachment.getFileId(), attachment.getFilename());
    }

    @Override
    public AttachmentFileInfo getAttachmentFileInfo(DownloadAttachmentCommand command) {
        Job job = jobDataManager.findByIdAndUserId(command.jobId(), command.userId()).orElseThrow(JobNotFoundException::new);

        Attachment attachment = job.getAttachments().stream().filter(a -> a.getId().value().toString().equals(command.id())).findAny().orElse(null);
        if(attachment == null) {
            throw new AttachmentNotFoundException();
        }

        return new AttachmentFileInfo(attachment.getFileId(), fileStorage.generateProtectedUrl(job.getId(), attachment.getId(), attachment.getFileId()));
    }

    @Override
    public void deleteAttachment(DeleteAttachmentCommand command) {
        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), command.userId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }

        Attachment attachment = foundJob.get().getAttachments().stream().filter(a -> a.getId().equals(command.id())).findAny().orElse(null);
        if(attachment == null) {
            throw new AttachmentNotFoundException();
        }
        transactionProvider.executeInTransaction(() -> {
            // FIXME : maybe the activity should be created by the Job aggregate
            // however for now we do it here,
            // to be able to explicitly ask the JobDataManager to delete the attachment and store both the job and the new activity
            Job job = foundJob.get().removeAttachment(attachment);
            Activity activity = Activity.builder().type(ActivityType.ATTACHMENT_DELETION).comment(attachment.getName()).build();
            job = job.addActivity(activity);
            // FIXME
            // this is an ugly workaround to force the infra (persistence in particular) to save all data
            // as I understand DDD, only the root aggregate should be explicitly persisted
            // but I just don't how to do it cleanly for now
            jobDataManager.deleteAttachmentAndSaveJob(job, attachment, activity);
            try {
                fileStorage.delete(attachment.getFileId());
            } catch (Exception e) {
                // TODO do something about it
            }
            finally {
                integrationEventPublisher.publish(new AttachmentDeletedEvent(IntegrationEventId.generate(), job.getId(), attachment.getId()));
                integrationEventPublisher.publish(new ActivityAutomaticallyCreatedEvent(IntegrationEventId.generate(), job.getId(), activity.getId(), activity.getType()));
            }
            return null;
        });
    }

    @Override
    public Job updateJobStatus(UpdateJobStatusCommand command) {
        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), command.userId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }

        return transactionProvider.executeInTransaction(() -> {
            Job job = foundJob.get().updateStatus(command.status());
            jobDataManager.saveJobAndActivity(job, job.getActivities().getFirst());
            integrationEventPublisher.publish(new JobStatusUpdatedEvent(IntegrationEventId.generate(), job.getId(), job.getStatus()));
            return job;
        });
    }

    @Override
    public Job updateJobRating(UpdateJobRatingCommand command) {
        Optional<Job> foundJob = jobDataManager.findByIdAndUserId(command.jobId(), command.userId());
        if(foundJob.isEmpty()) {
            throw new JobNotFoundException();
        }

        return transactionProvider.executeInTransaction(() -> {
            Job job = foundJob.get().updateRating(command.rating());
            jobDataManager.saveJobAndActivity(job, job.getActivities().getFirst());
            integrationEventPublisher.publish(new JobRatingUpdatedEvent(IntegrationEventId.generate(), job.getId(), job.getRating()));
            return job;
        });
    }
}