User.java

package com.wilzwert.myjobs.core.domain.model.user;


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.job.Job;
import com.wilzwert.myjobs.core.domain.model.job.JobId;
import com.wilzwert.myjobs.core.domain.model.job.command.UpdateJobFieldCommand;
import com.wilzwert.myjobs.core.domain.model.job.exception.JobAlreadyExistsException;
import com.wilzwert.myjobs.core.domain.model.job.exception.JobNotFoundException;
import com.wilzwert.myjobs.core.domain.model.user.exception.ResetPasswordExpiredException;
import com.wilzwert.myjobs.core.domain.model.user.exception.UserNotFoundException;
import com.wilzwert.myjobs.core.domain.shared.exception.ValidationException;
import com.wilzwert.myjobs.core.domain.shared.validation.*;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * @author Wilhelm Zwertvaegher
 * TODO : use defensive copying on collections' getters to ensure immutability
 */
public class User extends DomainEntity<UserId> {

    public static final Integer DEFAULT_JOB_FOLLOW_UP_REMINDER_DAYS = 14;
    public static final Lang DEFAULT_LANG = Lang.EN;
    public static final String DEFAULT_ROLE = "USER";

    private final UserId id;
    private final String email;
    private final EmailStatus emailStatus;
    private final String emailValidationCode;
    private final String password;
    private final String username;
    private final String firstName;
    private final String lastName;
    /**
     * Number of days after which a reminder should be triggered for jobs considered active
     * that haven't received any user interaction (e.g. no updates, status changes, or comments).
     * This helps ensure that jobs requiring attention are not forgotten.
     * Example: if set to 7, a reminder will be issued 7 days after the last action on the job.
     * Must be between 3 and 30, defaults to 14
     */
    private final Integer jobFollowUpReminderDays;
    private final Lang lang;
    private final String role;
    private final String resetPasswordToken;
    private final Instant resetPasswordExpiresAt;
    private final Instant createdAt;
    private final Instant updatedAt;
    private final Instant jobFollowUpReminderSentAt;
    private final List<Job> jobs;

    public static Builder builder() {
        return new Builder();
    }

    /**
     * Warning : use only when sure user is a complete aggregate !
     * @param user the user we want to get a Builder from
     * @return the Builder
     */
    public static Builder from(User user) {
        return new Builder(user, true);
    }

    /**
     * Warning : this is used to get a Builder allowing an incomplete aggregate
     * Use with great caution, as some methods on the aggregate won't work if it is not complete !
     * @param user the user we want to get a Builder from
     * @return the Builder
     */
    public static Builder fromMinimal(User user) {
        return new Builder(user, false);
    }

    // Only for persistence mapping and tests. Do not use for new User creation!
    public static class Builder {
        private UserId id;
        private String email;
        private EmailStatus emailStatus;
        private String emailValidationCode;
        private String password;
        private String username;
        private String firstName;
        private String lastName;
        private Integer jobFollowUpReminderDays;
        private Lang lang;
        private String role;
        private String resetPasswordToken;
        private Instant resetPasswordExpiresAt;
        private Instant createdAt;
        private Instant updatedAt;
        private Instant jobFollowUpReminderSentAt;

        private List<Job> jobs = null;

        public Builder() {}

        private Builder(User user, boolean full) {
            id = user.getId();
            email = user.getEmail();
            emailStatus = user.getEmailStatus();
            emailValidationCode = user.getEmailValidationCode();
            password = user.getPassword();
            username = user.getUsername();
            firstName = user.getFirstName();
            lastName = user.getLastName();
            jobFollowUpReminderDays = user.getJobFollowUpReminderDays();
            lang = user.getLang();
            role = user.getRole();
            resetPasswordToken = user.getResetPasswordToken();
            resetPasswordExpiresAt = user.getResetPasswordExpiresAt();
            createdAt = user.getCreatedAt();
            updatedAt = user.getUpdatedAt();
            jobFollowUpReminderSentAt = user.getJobFollowUpReminderSentAt();
            jobs = full || user.jobs != null ? user.getJobs() : null;
        }

        public Builder id(UserId userId) {
            this.id = userId;
            return this;
        }

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public Builder emailStatus(EmailStatus emailStatus) {
            this.emailStatus = emailStatus;
            return this;
        }

        public Builder emailValidationCode(String emailValidationCode) {
            this.emailValidationCode = emailValidationCode;
            return this;
        }

        public Builder password(String password) {
            this.password = password;
            return this;
        }

        public Builder username(String username) {
            this.username = username;
            return this;
        }
        public Builder firstName(String firstName) {
            this.firstName = firstName;
            return this;
        }
        public Builder lastName(String lastName) {
            this.lastName = lastName;
            return this;
        }

        public Builder jobFollowUpReminderDays(Integer jobFollowUpReminderDays) {
            this.jobFollowUpReminderDays = jobFollowUpReminderDays;
            return this;
        }

        public Builder lang(Lang lang) {
            this.lang = lang;
            return this;
        }

        public Builder role(String role) {
            this.role = role;
            return this;
        }
        public Builder resetPasswordToken(String resetPasswordToken) {
            this.resetPasswordToken = resetPasswordToken;
            return this;
        }
        public Builder resetPasswordExpiresAt(Instant resetPasswordExpiresAt) {
            this.resetPasswordExpiresAt = resetPasswordExpiresAt;
            return this;
        }
        public Builder createdAt(Instant createdAt) {
            this.createdAt = createdAt;
            return this;
        }
        public Builder updatedAt(Instant updatedAt) {
            this.updatedAt = updatedAt;
            return this;
        }

        public Builder jobFollowUpReminderSentAt(Instant jobFollowUpReminderSentAt) {
            this.jobFollowUpReminderSentAt = jobFollowUpReminderSentAt;
            return this;
        }

        public Builder jobs(List<Job> jobs) {
            this.jobs = jobs;
            return this;
        }

        public User build() {
            // build User
            return new User(this);
        }
    }

    private static ValidationError validatePassword(String plainPassword) {
        String regex = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^\\w\\s]).+$";
        if(plainPassword == null || plainPassword.isEmpty() || !plainPassword.matches(regex)) {
            return new ValidationError("password", ErrorCode.USER_WEAK_PASSWORD);
        }
        return null;
    }

    private ValidationErrors validate() {
        return new Validator()
                .requireValidEmail("email", email)
                .requireMinLength("username", username, 2)
                .requireMaxLength("username", username, 30)
                .requireNotEmpty("firstName", firstName)
                .requireNotEmpty("lastName", lastName)
                .requireMinIfNotNull("jobFollowUpReminderDays", jobFollowUpReminderDays, 3)
                .requireMaxIfNotNull("jobFollowUpReminderDays", jobFollowUpReminderDays, 30)
                .requireNotEmpty("role", role)
                .requireNotEmpty("password", password)
                .getErrors();
    }

    private User(User.Builder builder) {
        this.id = builder.id != null ? builder.id : UserId.generate();
        this.email = builder.email;
        this.emailValidationCode = builder.emailValidationCode != null ? builder.emailValidationCode : UUID.randomUUID().toString();
        this.emailStatus = builder.emailStatus != null ? builder.emailStatus : EmailStatus.PENDING;
        this.password = builder.password;
        this.username = builder.username;
        this.firstName = builder.firstName;
        this.lastName = builder.lastName;
        this.jobFollowUpReminderDays = builder.jobFollowUpReminderDays != null ? builder.jobFollowUpReminderDays : DEFAULT_JOB_FOLLOW_UP_REMINDER_DAYS;
        this.lang = builder.lang != null ? builder.lang : DEFAULT_LANG;
        this.role = builder.role != null ? builder.role : DEFAULT_ROLE;
        this.resetPasswordToken = builder.resetPasswordToken;
        this.resetPasswordExpiresAt = builder.resetPasswordExpiresAt;
        this.createdAt = builder.createdAt != null ? builder.createdAt : Instant.now();
        this.updatedAt = builder.updatedAt != null ? builder.updatedAt : Instant.now();
        this.jobFollowUpReminderSentAt = builder.jobFollowUpReminderSentAt;
        // jobs may be null in some cases, but it will throw an exception for operations that need them to be loaded
        this.jobs = builder.jobs != null ? List.copyOf(builder.jobs) : null;

        // validate the User state
        ValidationErrors validationErrors = validate();
        if(validationErrors.hasErrors()) {
            throw new ValidationException(validationErrors);
        }
    }

    public Boolean isJobLate(Instant updatedAt) {
        if(jobFollowUpReminderDays == null) {
            return false;
        }
        Instant maxInstant = Instant.now().minusSeconds((long) jobFollowUpReminderDays * 86_400);
        return updatedAt.isBefore(maxInstant);
    }

    private void requireFull() {
        requireLoadedProperty(jobs);
    }

    private User copy(List<Job> jobs) {

        return new User(
                from(this)
                    .jobs(jobs != null ? jobs : getJobs())
        );
    }

    public static User create(User.Builder builder, String plainPassword) {
        ValidationErrors errors = new ValidationErrors();
        User createdUser = null;
        try {
            createdUser = new User(builder);
        }
        catch(ValidationException e) {
            errors.merge(e.getErrors());
        }

        // password strength validation is very specific and belongs to the user
        ValidationError error = validatePassword(plainPassword);
        if(error != null) {
            errors.add(error);
        }

        if(errors.hasErrors()) {
            throw new ValidationException(errors);
        }

        return createdUser;
    }

    public User update(String email, String username, String firstName, String lastName, Integer jobFollowUpReminderDays) {

        // if email changed we have to change its status
        EmailStatus newEmailStatus = getEmailStatus();
        if(!email.equals(getEmail())) {
            newEmailStatus = EmailStatus.PENDING;
        }

        return new User(
                fromMinimal(this)
                        .email(email)
                        .emailStatus(newEmailStatus)
                        .username(username)
                        .firstName(firstName)
                        .lastName(lastName)
                        .jobFollowUpReminderDays(jobFollowUpReminderDays)
                        .updatedAt(Instant.now())
        );
    }

    public User addJob(Job job) {
        requireFull();

        // check if job to be added already exists by its url
        jobs.stream().filter(j -> j.getUrl().equals(job.getUrl())).findAny().ifPresent(found -> {throw new JobAlreadyExistsException();});

        List<Job> newJobs = new ArrayList<>(jobs);

        // automatically create first activity
        newJobs.add(job.addActivity(Activity.builder().type(ActivityType.CREATION).build()));

        return copy(newJobs);
    }

    public Optional<Job> getJobById(JobId jobId) {
        return getJobs().stream().filter(j -> j.getId().equals(jobId)).findFirst();
    }

    public Optional<Job> getJobByUrl(String url) {
        return getJobs().stream().filter(j -> j.getUrl().equals(url)).findFirst();
    }

    public User updateJobField(Job job, UpdateJobFieldCommand.Field field, String value) {
        requireFull();

        if(!jobs.contains(job)) {
            throw new JobNotFoundException();
        }

        if(field.equals(UpdateJobFieldCommand.Field.URL) && !value.equals(job.getUrl())) {
            jobs.stream().filter(j -> j.getUrl().equals(value)).findAny().ifPresent(found -> {throw new JobAlreadyExistsException();});
        }

        int index = jobs.indexOf(job);
        Job.Builder builder = Job.from(job).updatedAt(Instant.now());

        switch(field) {
            case TITLE -> builder.title(value);
            case URL -> builder.url(value);
            case DESCRIPTION -> builder.description(value);
            case PROFILE -> builder.profile(value);
            case SALARY -> builder.salary(value);
            case COMMENT -> builder.comment(value);
            case COMPANY -> builder.company(value);
        }

        List<Job> newJobs = new ArrayList<>(jobs);
        newJobs.set(index, builder.build());
        return copy(newJobs);
    }

    public User updateJob(Job job, String url, String title, String company, String description, String profile, String comment, String salary) {
        requireFull();

        if(!jobs.contains(job)) {
            throw new JobNotFoundException();
        }
        if(!url.equals(job.getUrl())) {
            jobs.stream().filter(j -> j.getUrl().equals(url)).findAny().ifPresent(found -> {throw new JobAlreadyExistsException();});
        }

        int index = jobs.indexOf(job);

        List<Job> newJobs = new ArrayList<>(jobs);
        newJobs.set(index,
                Job.from(job)
                    .url(url)
                    .title(title)
                    .company(company)
                    .description(description)
                    .profile(profile)
                    .comment(comment)
                    .salary(salary)
                    .updatedAt(Instant.now())
                    .build()
        );

        return copy(newJobs);
    }

    public User removeJob(Job job) {
        requireFull();

        if(!jobs.contains(job)) {
            throw new JobNotFoundException();
        }
        List<Job> newJobs = new ArrayList<>(jobs);
        newJobs.remove(job);
        return copy(newJobs);
    }

    /**
     * Used to complete the aggregate with its required properties
     * As of now, the only property needed to be complete, and which may not be loaded, is jobs
     * This may be used when an incomplete User is loaded, and you want to make it complete and coherent
     * and do stuff on it
     * @param jobsList the user's jobs list
     * @return a complete User aggregate
     */
    public User completeWith(List<Job> jobsList) {
        requireLoadedProperty(jobsList);

        return new User(
            fromMinimal(this)
                .jobs(jobsList)
        );
    }

    public User updatePassword(String plainPassword, String newPassword) {
        ValidationErrors errors = new ValidationErrors();
        User updatedUser = null;
        try {
            updatedUser = new User(
                from(this)
                    .password(newPassword)
            );
        }
        catch(ValidationException e) {
            errors.merge(e.getErrors());
        }

        // password strength validation is very specific and belongs to the user
        ValidationError error = validatePassword(plainPassword);
        if(error != null) {
            errors.add(error);
        }

        if(errors.hasErrors()) {
            throw new ValidationException(errors);
        }

        return updatedUser;
    }

    public User resetPassword() {
        // a reset password request just overrides all previous ones
        return new User(
                from(this)
                    // FIXME : maybe we should use a value object with a generator
                    .resetPasswordToken(UUID.randomUUID().toString())
                    // FIXME : duration should not be hard coded this way
                    // it should be handled by domain anyway
                    .resetPasswordExpiresAt(Instant.now().plus(30, ChronoUnit.MINUTES))
        );
    }

    public User createNewPassword(String plainPassword, String newPassword) {
        if(resetPasswordExpiresAt.isBefore(Instant.now())) {
            throw new ResetPasswordExpiredException();
        }

        return updatePassword(plainPassword, newPassword);
    }

    public User validateEmail(String emailValidationCode) {
        if (getEmailValidationCode() == null || !getEmailValidationCode().equals(emailValidationCode)) {
            throw new UserNotFoundException();
        }

        if(getEmailStatus().equals(EmailStatus.VALIDATED)) {
            return this;
        }

        return new User(
            fromMinimal(this)
                .emailStatus(getEmailStatus().equals(EmailStatus.PENDING) ? EmailStatus.VALIDATED : getEmailStatus())
        );
    }

    public User updateLang(Lang lang) {
        return new User(
            fromMinimal(this)
                .lang(lang)
        );
    }

    public User saveJobFollowUpReminderSentAt() {
        return new User(
            fromMinimal(this)
                .jobFollowUpReminderSentAt(Instant.now())
        );
    }

    public UserId getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public EmailStatus getEmailStatus() {
        return emailStatus;
    }

    public String getEmailValidationCode() {
        return emailValidationCode;
    }

    public String getPassword() {
        return password;
    }

    public String getUsername() {
        return username;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public Integer getJobFollowUpReminderDays() { return jobFollowUpReminderDays; }

    public Lang getLang() { return lang; }

    public String getRole() {
        return role;
    }

    public String  getResetPasswordToken() {return resetPasswordToken;}

    public Instant getResetPasswordExpiresAt() {return resetPasswordExpiresAt;}

    public Instant getCreatedAt() {
        return createdAt;
    }

    public Instant getUpdatedAt() {
        return updatedAt;
    }

    public Instant getJobFollowUpReminderSentAt() {return jobFollowUpReminderSentAt;}

    public List<Job> getJobs() {
        requireLoadedProperty(jobs);
        return jobs;
    }

    @Override
    public String toString() {
        return getId().toString()+ " [email="+getEmail() + "]";
    }

}