ProjectController.java

/*
 * BSD 2-Clause License
 * 
 * Copyright (c) 2022, [Aleksandra Serba, Marcin Czerniak, Bartosz Wawrzyniak, Adrian Antkowiak]
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * 1. Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 * 
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package dev.vernite.vernite.project;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;

import dev.vernite.vernite.auditlog.AuditLog;
import dev.vernite.vernite.auditlog.AuditLogRepository;
import dev.vernite.vernite.cdn.File;
import dev.vernite.vernite.cdn.FileManager;
import dev.vernite.vernite.event.Event;
import dev.vernite.vernite.event.EventFilter;
import dev.vernite.vernite.event.EventService;
import dev.vernite.vernite.integration.calendar.CalendarIntegration;
import dev.vernite.vernite.integration.calendar.CalendarIntegrationRepository;
import dev.vernite.vernite.integration.git.Branch;
import dev.vernite.vernite.integration.git.GitTaskService;
import dev.vernite.vernite.integration.git.Issue;
import dev.vernite.vernite.integration.git.PullRequest;
import dev.vernite.vernite.projectworkspace.ProjectMember;
import dev.vernite.vernite.projectworkspace.ProjectWorkspace;
import dev.vernite.vernite.projectworkspace.ProjectWorkspaceRepository;
import dev.vernite.vernite.task.time.TimeTrack;
import dev.vernite.vernite.task.time.TimeTrackRepository;
import dev.vernite.vernite.user.User;
import dev.vernite.vernite.user.UserRepository;
import dev.vernite.vernite.utils.ErrorType;
import dev.vernite.vernite.utils.ImageConverter;
import dev.vernite.vernite.utils.ObjectNotFoundException;
import dev.vernite.vernite.utils.SecureStringUtils;
import dev.vernite.vernite.workspace.Workspace;
import dev.vernite.vernite.workspace.WorkspaceId;
import dev.vernite.vernite.workspace.WorkspaceRepository;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Flux;

/**
 * Rest controller for performing CRUD operations on Projects entities.
 */
@RestController
@AllArgsConstructor
@RequestMapping("/project")
public class ProjectController {

    private ProjectRepository projectRepository;

    private WorkspaceRepository workspaceRepository;

    private ProjectWorkspaceRepository projectWorkspaceRepository;

    private UserRepository userRepository;

    private TimeTrackRepository timeTrackRepository;

    private EventService eventService;

    private FileManager fileManager;

    private CalendarIntegrationRepository calendarRepository;

    private AuditLogRepository auditLogRepository;

    private GitTaskService service;

    /**
     * Create new project. Creating user will be automatically added to that
     * project.
     * 
     * @param user   logged in user
     * @param create data for new project
     * @return newly created project
     */
    @PostMapping
    public Project create(@NotNull @Parameter(hidden = true) User user, @RequestBody @Valid CreateProject create) {
        long id = create.getWorkspaceId();
        Workspace workspace = workspaceRepository.findByIdOrThrow(new WorkspaceId(id, user.getId()));
        Project project = projectRepository.save(new Project(create));
        projectWorkspaceRepository.save(new ProjectWorkspace(project, workspace, 1L));
        return project;
    }

    /**
     * Retrieve project. If user is not member of project with given ID this method
     * returns not found error.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return project with given ID
     */
    @GetMapping("/{id}")
    public Project get(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        return projectRepository.findByIdAndMemberOrThrow(id, user);
    }

    /**
     * Update project with given ID. Performs partial update using only supplied
     * fields from request body. Authenticated user must be member of project.
     * 
     * @param user   logged in user
     * @param id     ID of project
     * @param update data to update
     * @return project after update
     */
    @PutMapping("/{id}")
    public Project update(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
            @RequestBody @Valid UpdateProject update) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        if (update.getWorkspaceId() != null) {
            changeWorkspace(update.getWorkspaceId(), project, user);
        }
        project.update(update);
        return projectRepository.save(project);
    }

    private void changeWorkspace(long workspaceId, Project project, User user) {
        Workspace workspace = workspaceRepository.findByIdOrThrow(new WorkspaceId(workspaceId, user.getId()));
        ProjectWorkspace pw = project.removeMember(user);
        projectWorkspaceRepository.delete(pw);
        projectWorkspaceRepository.save(new ProjectWorkspace(project, workspace, pw.getPrivileges()));
        if (pw.getWorkspace().getId().getId() == 0 && pw.getWorkspace().getProjectWorkspaces().isEmpty()) {
            workspaceRepository.delete(pw.getWorkspace());
        }
    }

    /**
     * Delete project with given ID. Project will be soft deleted and full delete
     * wil happen after a week. Authenticated user must be member of project.
     * 
     * @param user logged in user
     * @param id   ID of project
     */
    @DeleteMapping("/{id}")
    public void delete(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        project.softDelete();
        projectRepository.save(project);
    }

    /**
     * Retrieve project members. Authenticated user must be member of project.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return list of project members
     */
    @GetMapping("/{id}/member")
    public List<ProjectMember> getProjectMembers(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return projectWorkspaceRepository.findByProjectOrderByWorkspaceUserUsernameAscWorkspaceUserIdAsc(project)
                .stream().map(ProjectWorkspace::getProjectMember).toList();
    }

    /**
     * Retrieve project member. Authenticated user must be member of project.
     * 
     * @param user     logged in user
     * @param id       ID of project
     * @param memberId ID of searched user
     * @return project member
     */
    @GetMapping("/{id}/member/{memberId}")
    public ProjectMember getProjectMember(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
            @PathVariable long memberId) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return projectWorkspaceRepository.findByProjectOrderByWorkspaceUserUsernameAscWorkspaceUserIdAsc(project)
                .stream().map(ProjectWorkspace::getProjectMember).filter(member -> member.user().getId() == memberId)
                .findFirst().orElseThrow(ObjectNotFoundException::new);
    }

    /**
     * Adds members to projects. Adds every given user to every given project. In
     * order to add users to project authenticated user must be member of every
     * project. If authenticated user is not member of project no one will be added
     * to this project. If user with given email / username does not exists no error
     * will be thrown.
     * 
     * @param user   logged in user
     * @param invite list of project and user to add.
     * @return list with project and users which were added to projects
     */
    @PostMapping("/member")
    public ProjectInvite addProjectMembers(@NotNull @Parameter(hidden = true) User user,
            @RequestBody ProjectInvite invite) {
        List<User> users = userRepository.findByEmailInOrUsernameIn(invite.getEmails(), invite.getEmails());
        Iterable<Project> projects = projectRepository.findAllById(invite.getProjects());
        List<Project> result = new ArrayList<>();
        for (Project project : projects) {
            if (project.isMember(user)) {
                result.add(project);
                for (User invitedUser : users) {
                    if (project.isMember(invitedUser)) {
                        continue;
                    }
                    Workspace workspace = workspaceRepository.findById(new WorkspaceId(0, invitedUser.getId()))
                            .orElseGet(() -> workspaceRepository.save(new Workspace(0, "inbox", invitedUser)));
                    projectWorkspaceRepository
                            .save(new ProjectWorkspace(project, workspace, 2L));
                }
            }
        }
        if (result.isEmpty() || users.isEmpty()) {
            return new ProjectInvite(new ArrayList<>(), new ArrayList<>());
        }
        return new ProjectInvite(users.stream().map(User::getUsername).toList(), result);
    }

    /**
     * Remove members from project. Authenticated user must be member of project and
     * have sufficient privileges.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @param ids  IDs of users to remove
     * @return removed users
     */
    @PutMapping("/{id}/member")
    @ApiResponse(description = "List with actual users removed from project.", responseCode = "200")
    @ApiResponse(description = "Not enough privileges.", responseCode = "403", content = @Content(schema = @Schema(implementation = ErrorType.class)))
    public List<User> deleteMember(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
            @RequestBody List<Long> ids) {
        Project project = projectRepository.findByIdOrThrow(id);
        int index = project.member(user);
        if (index == -1) {
            throw new ObjectNotFoundException();
        }
        if (project.getProjectWorkspaces().get(index).getPrivileges() != 1L) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        Iterable<User> users = userRepository.findAllById(ids.stream().filter(i -> i != user.getId()).toList());
        List<ProjectWorkspace> projectWorkspaces = projectWorkspaceRepository.findByWorkspaceUserInAndProject(users,
                project);
        projectWorkspaceRepository.deleteAll(projectWorkspaces);
        return projectWorkspaces.stream().map(ps -> ps.getWorkspace().getUser()).toList();
    }

    /**
     * Leave project. Authenticated user leaves project with given ID.
     * 
     * @param user logged in user
     * @param id   ID of project
     */
    @DeleteMapping("/{id}/member")
    public void leaveProject(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdOrThrow(id);
        int index = project.member(user);
        if (index == -1) {
            throw new ObjectNotFoundException();
        }
        ProjectWorkspace pw = project.getProjectWorkspaces().get(index);
        projectWorkspaceRepository.delete(pw);
    }

    /**
     * Retrieve project time tracks. Authenticated user must be member of project.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return list with all time tracks from given project
     */
    @GetMapping("/{id}/track")
    public List<TimeTrack> getTimeTracks(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return timeTrackRepository.findByTaskStatusProject(project);
    }

    /**
     * Retrieve git issues for project. Retrieve all issues from integrated git
     * providers.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return list with issues
     */
    @GetMapping("/{id}/integration/git/issue")
    public Flux<Issue> getIssues(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return service.getIssues(project);
    }

    /**
     * Retrieve git pull requests for project. Retrieve all pull requests from
     * integrated git providers.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return list with pull requests
     */
    @GetMapping("/{id}/integration/git/pull")
    public Flux<PullRequest> getPullRequests(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return service.getPullRequests(project);
    }

    /**
     * Retrieve git branches for project. Retrieve all branches from integrated git
     * providers.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return list with branches
     */
    @GetMapping("/{id}/integration/git/branch")
    public Flux<Branch> getBranches(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return service.getBranches(project);
    }

    /**
     * Retrieve events for project.
     * 
     * @param user   logged in user
     * @param id     ID of project
     * @param from   timestamp after events happen
     * @param to     timestamp before events happen
     * @param filter filter for events
     * @return list with events after 'from' and before 'to' filtered by 'filter'
     */
    @GetMapping("/{id}/events")
    public Set<Event> getEvents(@NotNull @Parameter(hidden = true) User user, @PathVariable long id, long from,
            long to, @ModelAttribute EventFilter filter) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return eventService.getProjectEvents(project, new Date(from), new Date(to), filter);
    }

    /**
     * Update project logo. Given file will be converted to image/webp format with
     * resolution 400x400. Alpha channel is supported.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @param file new logo image
     * @return new logo file information
     */
    @PostMapping(path = "/{id}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @ApiResponse(description = "Project logo changed.", responseCode = "200")
    @ApiResponse(description = "Cannot convert image.", responseCode = "400", content = @Content(schema = @Schema(implementation = ErrorType.class)))
    public File uploadLogo(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
            @RequestParam("file") MultipartFile file) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        byte[] converted;
        try {
            converted = ImageConverter.convertImage(file.getOriginalFilename(), file.getBytes());
        } catch (IOException e) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
        }
        File f = fileManager.uploadFile("image/webp", converted);
        project.setLogo(f);
        project = projectRepository.save(project);
        return f;
    }

    /**
     * Delete project logo. After that logo will be empty.
     * 
     * @param user logged in user
     * @param id   ID of project
     */
    @DeleteMapping(path = "/{id}/logo")
    public void uploadImage(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        project.setLogo(null);
        project = projectRepository.save(project);
    }

    /**
     * Create calendar synchronization link. Creates link for iCalendar format
     * synchronization of project calendar.
     * 
     * @param user logged in user
     * @param id   ID of project
     * @return link to project calendar in iCalendar format
     */
    @PostMapping("/{id}/events/sync")
    public String createCalendarSync(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        String key = SecureStringUtils.generateRandomSecureString();
        while (calendarRepository.findByKey(key).isPresent()) {
            key = SecureStringUtils.generateRandomSecureString();
        }
        Optional<CalendarIntegration> integration = calendarRepository.findByUserAndProject(user, project);
        if (integration.isPresent()) {
            key = integration.get().getKey();
        } else {
            calendarRepository.save(new CalendarIntegration(user, project, key));
        }
        return "https://vernite.dev/api/webhook/calendar?key=" + key;
    }

    @GetMapping("/{id}/auditlog")
    public List<AuditLog> getAuditLog(@NotNull @Parameter(hidden = true) User user, @PathVariable long id) {
        Project project = projectRepository.findByIdAndMemberOrThrow(id, user);
        return auditLogRepository.findByProject(project);
    }

}