diff --git a/.gitignore b/.gitignore index cd38e2e7b..7f9709787 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ target logs attachments *.patch +.env diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..d52c3716b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ + +services: + postgres-db: + image: postgres + container_name: postgres-db + ports: + - "5432:5432" + environment: + POSTGRES_USER: jira + POSTGRES_PASSWORD: JiraRush + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - ./pgdata:/var/lib/postgresql/data + restart: unless-stopped \ No newline at end of file diff --git a/config/nginx.conf b/config/nginx.conf index 82b9e234d..024e03cf5 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,4 +1,4 @@ -# https://losst.ru/ustanovka-nginx-ubuntu-16-04 + # https://pai-bx.com/wiki/nginx/2332-useful-redirects-in-nginx/#1 # sudo iptables -A INPUT ! -s 127.0.0.1 -p tcp -m tcp --dport 8080 -j DROP server { diff --git a/pom.xml b/pom.xml index f6c152c68..753fe8cdb 100644 --- a/pom.xml +++ b/pom.xml @@ -97,12 +97,15 @@ org.projectlombok lombok true + 1.18.30 + org.mapstruct mapstruct ${mapstruct.version} + org.springframework.boot spring-boot-configuration-processor @@ -142,10 +145,37 @@ junit-platform-launcher test + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.30 + + + org.mapstruct + mapstruct-processor + 1.6.0.Beta1 + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/resources/static/fontawesome/css/all.css b/resources/static/fontawesome/css/all.css index af5980828..6a347647c 100644 --- a/resources/static/fontawesome/css/all.css +++ b/resources/static/fontawesome/css/all.css @@ -8603,9 +8603,7 @@ readers do not read off random characters that represent icons */ content: "\f3e8"; } -.fa-vk:before { - content: "\f189"; -} + .fa-untappd:before { content: "\f405"; @@ -9955,9 +9953,7 @@ readers do not read off random characters that represent icons */ content: "\f3bc"; } -.fa-yandex:before { - content: "\f413"; -} + .fa-readme:before { content: "\f4d5"; @@ -10183,9 +10179,7 @@ readers do not read off random characters that represent icons */ content: "\f7c6"; } -.fa-yandex-international:before { - content: "\f414"; -} + .fa-cc-amex:before { content: "\f1f3"; diff --git a/resources/view/login.html b/resources/view/login.html index 8765ca8ff..d49ce5691 100644 --- a/resources/view/login.html +++ b/resources/view/login.html @@ -48,14 +48,6 @@

Sign in

type="button"> - - - - - - diff --git a/resources/view/unauth/register.html b/resources/view/unauth/register.html index 2ba955045..52a892bd3 100644 --- a/resources/view/unauth/register.html +++ b/resources/view/unauth/register.html @@ -77,14 +77,6 @@

Registration

type="button"> - - - - - - diff --git a/src/main/java/com/javarush/jira/bugtracking/attachment/AttachmentController.java b/src/main/java/com/javarush/jira/bugtracking/attachment/AttachmentController.java index 2ddd79591..1cc1f7b5f 100644 --- a/src/main/java/com/javarush/jira/bugtracking/attachment/AttachmentController.java +++ b/src/main/java/com/javarush/jira/bugtracking/attachment/AttachmentController.java @@ -2,6 +2,7 @@ import com.javarush.jira.bugtracking.ObjectType; import com.javarush.jira.login.AuthUser; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -27,6 +28,10 @@ public class AttachmentController { @Transactional @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Завантажити файл вкладення", + description = "Додає нове вкладення до об'єкта певного типу (наприклад, задачі чи проєкту) та зберігає його у файловій системі" + ) public ResponseEntity upload(@RequestPart MultipartFile file, @RequestParam ObjectType type, @RequestParam Long objectId, @AuthenticationPrincipal AuthUser authUser) { log.debug("upload file {} to folder {}", file.getOriginalFilename(), type.toString().toLowerCase()); @@ -41,6 +46,10 @@ public ResponseEntity upload(@RequestPart MultipartFile file, @Reque @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Видалити файл вкладення", + description = "Видаляє вкладення з бази даних та фізично з файлової системи за ID" + ) public void delete(@PathVariable Long id) { log.debug("delete file id = {}", id); Attachment attachment = repository.getExisted(id); @@ -49,6 +58,10 @@ public void delete(@PathVariable Long id) { } @GetMapping(value = "/download/{id}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Operation( + summary = "Завантажити файл вкладення", + description = "Повертає файл вкладення як ресурс для завантаження за його ID" + ) public ResponseEntity download(@PathVariable long id) { log.debug("download file id = {}", id); Attachment attachment = repository.getExisted(id); @@ -59,12 +72,20 @@ public ResponseEntity download(@PathVariable long id) { } @GetMapping("/for-object") + @Operation( + summary = "Отримати всі вкладення для об'єкта", + description = "Повертає список усіх вкладень, прикріплених до вказаного об'єкта певного типу (за objectId та типом)" + ) public List getAllForObject(@RequestParam long objectId, @RequestParam ObjectType type) { log.info("get all attachment by objectId = {}", objectId); return repository.getAllForObject(objectId, type); } @GetMapping + @Operation( + summary = "Отримати всі вкладення", + description = "Повертає повний список всіх вкладень, збережених у системі" + ) public List getAll() { log.info("get all attachment"); return repository.findAll(); diff --git a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java index 6cffbe175..dd9f42664 100644 --- a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java +++ b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java @@ -25,14 +25,15 @@ public static void upload(MultipartFile multipartFile, String directoryPath, Str throw new IllegalRequestDataException("Select a file to upload."); } - File dir = new File(directoryPath); - if (dir.exists() || dir.mkdirs()) { - File file = new File(directoryPath + fileName); - try (OutputStream outStream = new FileOutputStream(file)) { - outStream.write(multipartFile.getBytes()); - } catch (IOException ex) { - throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename()); - } + try { + Path dirPath = Paths.get(directoryPath); + Files.createDirectories(dirPath); + + Path filePath = dirPath.resolve(fileName); + Files.write(filePath, multipartFile.getBytes()); + + } catch (IOException ex) { + throw new IllegalRequestDataException("Failed to upload file " + multipartFile.getOriginalFilename()); } } diff --git a/src/main/java/com/javarush/jira/bugtracking/project/ProjectController.java b/src/main/java/com/javarush/jira/bugtracking/project/ProjectController.java index 5ebd02ee3..4dc4cf1d6 100644 --- a/src/main/java/com/javarush/jira/bugtracking/project/ProjectController.java +++ b/src/main/java/com/javarush/jira/bugtracking/project/ProjectController.java @@ -2,6 +2,7 @@ import com.javarush.jira.bugtracking.Handlers; import com.javarush.jira.bugtracking.project.to.ProjectTo; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -22,16 +23,29 @@ public class ProjectController { private final Handlers.ProjectHandler handler; @GetMapping("/projects") + @Operation( + summary = "Отримати список усіх проєктів", + description = "Повертає список усіх проєктів у порядку від найновіших до найстаріших" + ) + public List getAll() { return handler.getAllTos(ProjectRepository.NEWEST_FIRST); } @GetMapping("/projects/{id}") + @Operation( + summary = "Отримати проєкт за ID", + description = "Повертає повну інформацію про проєкт із зазначеним ID" + ) public ProjectTo getById(@PathVariable Long id) { return handler.getTo(id); } @PostMapping(path = "/mngr/projects", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Створити новий проєкт", + description = "Створює новий проєкт на основі переданих даних" + ) public ResponseEntity create(@Valid @RequestBody ProjectTo projectTo) { Project created = handler.createWithBelong(projectTo, PROJECT, "project_author"); return createdResponse(REST_URL + "/projects", created); @@ -39,12 +53,20 @@ public ResponseEntity create(@Valid @RequestBody ProjectTo projectTo) { @PutMapping("/mngr/projects/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Оновити проєкт", + description = "Оновлює існуючий проєкт за його ID" + ) public void update(@Valid @RequestBody ProjectTo projectTo, @PathVariable Long id) { handler.updateFromTo(projectTo, id); } @PatchMapping("/mngr/projects/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Увімкнути/вимкнути проєкт", + description = "Змінює статус активності проєкту: enabled = true/false" + ) public void enable(@PathVariable long id, @RequestParam boolean enabled) { handler.enable(id, enabled); } diff --git a/src/main/java/com/javarush/jira/bugtracking/report/ReportRestController.java b/src/main/java/com/javarush/jira/bugtracking/report/ReportRestController.java index a2b221d95..40cfa9c62 100644 --- a/src/main/java/com/javarush/jira/bugtracking/report/ReportRestController.java +++ b/src/main/java/com/javarush/jira/bugtracking/report/ReportRestController.java @@ -1,5 +1,6 @@ package com.javarush.jira.bugtracking.report; +import io.swagger.v3.oas.annotations.Operation; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; @@ -20,6 +21,10 @@ public class ReportRestController { private final ReportRepository reportRepository; @GetMapping + @Operation( + summary = "Отримати звіт по задачах спринту", + description = "Повертає список зведеної інформації про задачі у межах вказаного спринту, включаючи кількість, статуси та інші ключові метрики" + ) public List getTaskSummaries(@PathVariable long sprintId) { log.info("get task summaries for sprint with id={}", sprintId); return reportRepository.getTaskSummaries(sprintId); diff --git a/src/main/java/com/javarush/jira/bugtracking/sprint/SprintController.java b/src/main/java/com/javarush/jira/bugtracking/sprint/SprintController.java index 67611735f..c55ca2f8b 100644 --- a/src/main/java/com/javarush/jira/bugtracking/sprint/SprintController.java +++ b/src/main/java/com/javarush/jira/bugtracking/sprint/SprintController.java @@ -3,6 +3,7 @@ import com.javarush.jira.bugtracking.Handlers; import com.javarush.jira.bugtracking.project.ProjectRepository; import com.javarush.jira.bugtracking.sprint.to.SprintTo; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Nullable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -32,11 +33,19 @@ public class SprintController { private final Handlers.SprintHandler handler; @GetMapping("/sprints/{id}") + @Operation( + summary = "Отримати спринт за ID", + description = "Повертає повну інформацію про спринт із зазначеним ID" + ) public SprintTo get(@PathVariable long id) { return handler.getTo(id); } @GetMapping("/sprints/by-project") + @Operation( + summary = "Отримати спринти за проєктом", + description = "Повертає список усіх спринтів для проєкту. Якщо enabled=true, то тільки активні" + ) public List getAllByProject(@RequestParam long projectId, @RequestParam @Nullable Boolean enabled) { log.info("get all for project {} and enabled {}", projectId, enabled); checkProjectExists(projectId); @@ -52,6 +61,10 @@ private void checkProjectExists(long id) { } @GetMapping("/sprints/by-project-and-status") + @Operation( + summary = "Отримати спринти за статусом", + description = "Повертає спринти проєкту з певним статусом (наприклад: active, done)" + ) public List getAllByProjectAndStatus(@RequestParam long projectId, @NotBlank @RequestParam String statusCode) { log.info("get all {} sprints for project with id={}", statusCode, projectId); checkProjectExists(projectId); @@ -60,6 +73,10 @@ public List getAllByProjectAndStatus(@RequestParam long projectId, @NotB @PostMapping(path = "/mngr/sprints", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Створити спринт", + description = "Створює новий спринт у вказаному проєкті" + ) public ResponseEntity createWithLocation(@Valid @RequestBody SprintTo sprintTo) { Sprint created = handler.createWithBelong(sprintTo, SPRINT, "sprint_author"); return createdResponse(REST_URL + "/sprints", created); @@ -67,6 +84,10 @@ public ResponseEntity createWithLocation(@Valid @RequestBody SprintTo sp @PutMapping(path = "/mngr/sprints/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Оновити спринт", + description = "Оновлює існуючий спринт за його ID" + ) public void update(@Validated @RequestBody SprintTo sprintTo, @PathVariable long id) { handler.updateFromTo(sprintTo, id); } @@ -74,6 +95,10 @@ public void update(@Validated @RequestBody SprintTo sprintTo, @PathVariable long @Transactional @PatchMapping("/mngr/sprints/{id}/change-status") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Змінити статус спринта", + description = "Змінює статус спринта (наприклад, з active на done)" + ) public void changeStatusCode(@PathVariable long id, @RequestParam String statusCode) { log.info("change statusCode of sprint {}", id); Sprint sprint = handler.getRepository().getExisted(id); @@ -82,6 +107,10 @@ public void changeStatusCode(@PathVariable long id, @RequestParam String statusC @PatchMapping("/mngr/sprints/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Активувати/деактивувати спринт", + description = "Вмикає або вимикає спринт (enabled = true/false)" + ) public void enable(@PathVariable long id, @RequestParam boolean enabled) { handler.enable(id, enabled); } diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java index b53f7ff37..ce248628f 100644 --- a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java +++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java @@ -10,6 +10,7 @@ import com.javarush.jira.bugtracking.tree.ITreeNode; import com.javarush.jira.common.util.Util; import com.javarush.jira.login.AuthUser; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Nullable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -42,12 +43,20 @@ public class TaskController { @GetMapping("/{id}") + @Operation( + summary = "Отримати задачу за ID", + description = "Повертає повну інформацію про задачу з вказаним ID" + ) public TaskToFull get(@PathVariable long id) { log.info("get task by id={}", id); return taskService.get(id); } @GetMapping("/by-sprint") + @Operation( + summary = "Отримати задачі по спринту", + description = "Повертає всі задачі, що належать до вказаного спринту" + ) public List getAllBySprint(@RequestParam long sprintId) { log.info("get all for sprint {}", sprintId); return sortTasksAsTree(handler.getMapper().toToList(handler.getRepository().findAllBySprintId(sprintId))); @@ -70,6 +79,10 @@ private List sortTasksAsTree(List tasks) { } @GetMapping("/by-project") + @Operation( + summary = "Отримати задачі по проєкту", + description = "Повертає всі задачі, що належать до вказаного проєкту" + ) public List getAllByProject(@RequestParam long projectId) { log.info("get all for project {}", projectId); return handler.getMapper().toToList(handler.getRepository().findAllByProjectId(projectId)); @@ -77,24 +90,40 @@ public List getAllByProject(@RequestParam long projectId) { @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Створити нову задачу", + description = "Створює нову задачу на основі переданих даних" + ) public ResponseEntity createWithLocation(@Valid @RequestBody TaskToExt taskTo) { return createdResponse(REST_URL, taskService.create(taskTo)); } @PutMapping(path = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Оновити задачу", + description = "Оновлює існуючу задачу за її ID" + ) public void update(@Valid @RequestBody TaskToExt taskTo, @PathVariable long id) { taskService.update(taskTo, id); } @PatchMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Активувати/деактивувати задачу", + description = "Встановлює статус 'enabled' для задачі" + ) public void enable(@PathVariable long id, @RequestParam boolean enabled) { handler.enable(id, enabled); } @PatchMapping("/{id}/change-status") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Змінити статус задачі", + description = "Змінює статус задачі за допомогою коду статусу" + ) public void changeTaskStatus(@PathVariable long id, @NotBlank @RequestParam String statusCode) { log.info("change task(id={}) status to {}", id, statusCode); taskService.changeStatus(id, statusCode); @@ -102,12 +131,20 @@ public void changeTaskStatus(@PathVariable long id, @NotBlank @RequestParam Stri @PatchMapping("/{id}/change-sprint") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Змінити спринт задачі", + description = "Прив'язує або відв'язує задачу від конкретного спринту" + ) public void changeTaskSprint(@PathVariable long id, @Nullable @RequestParam Long sprintId) { log.info("change task(id={}) sprint to {}", id, sprintId); taskService.changeSprint(id, sprintId); } @GetMapping("/assignments/by-sprint") + @Operation( + summary = "Отримати призначення задач по спринту", + description = "Повертає список користувацьких призначень для задач у заданому спринті" + ) public List getTaskAssignmentsBySprint(@RequestParam long sprintId) { log.info("get task assignments for user {} for sprint {}", AuthUser.authId(), sprintId); return userBelongRepository.findActiveTaskAssignmentsForUserBySprint(AuthUser.authId(), sprintId); @@ -115,6 +152,10 @@ public List getTaskAssignmentsBySprint(@RequestParam long sprintId) @PatchMapping("/{id}/assign") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Призначити користувача до задачі", + description = "Призначає поточного користувача до задачі з вказаним типом ролі" + ) public void assign(@PathVariable long id, @NotBlank @RequestParam String userType) { log.info("assign user {} as {} to task {}", AuthUser.authId(), userType, id); taskService.assign(id, userType, AuthUser.authId()); @@ -122,12 +163,20 @@ public void assign(@PathVariable long id, @NotBlank @RequestParam String userTyp @PatchMapping("/{id}/unassign") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Видалити призначення користувача", + description = "Видаляє призначення користувача з задачі" + ) public void unAssign(@PathVariable long id, @NotBlank @RequestParam String userType) { log.info("unassign user {} as {} from task {}", AuthUser.authId(), userType, id); taskService.unAssign(id, userType, AuthUser.authId()); } @GetMapping("/{id}/comments") + @Operation( + summary = "Отримати коментарі до задачі", + description = "Повертає список коментарів, пов'язаних із задачою" + ) public List getComments(@PathVariable long id) { log.info("get comments for task with id={}", id); return activityHandler.getMapper().toToList(activityHandler.getRepository().findAllComments(id)); @@ -135,18 +184,30 @@ public List getComments(@PathVariable long id) { @PostMapping(value = "/activities", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Створити активність", + description = "Додає нову активність або коментар до задачі" + ) public Activity create(@Valid @RequestBody ActivityTo activityTo) { return activityService.create(activityTo); } @PutMapping(path = "/activities/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Оновити активність", + description = "Оновлює існуючу активність (наприклад, коментар)" + ) public void update(@Valid @RequestBody ActivityTo activityTo, @PathVariable long id) { activityService.update(activityTo, id); } @DeleteMapping("/activities/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Видалити активність", + description = "Видаляє активність або коментар за ID" + ) public void delete(@PathVariable long id) { activityService.delete(id); } diff --git a/src/main/java/com/javarush/jira/bugtracking/tree/TreeRestController.java b/src/main/java/com/javarush/jira/bugtracking/tree/TreeRestController.java index a3a7da4ec..ea4ba98cf 100644 --- a/src/main/java/com/javarush/jira/bugtracking/tree/TreeRestController.java +++ b/src/main/java/com/javarush/jira/bugtracking/tree/TreeRestController.java @@ -9,6 +9,7 @@ import com.javarush.jira.bugtracking.task.TaskRepository; import com.javarush.jira.bugtracking.task.mapper.TaskMapper; import com.javarush.jira.common.util.Util; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; @@ -44,12 +45,20 @@ private static List toTree(List list, Function mappe } @GetMapping("/projects") + @Operation( + summary = "Отримати дерево проєктів", + description = "Повертає всі проєкти у вигляді ієрархічного дерева NodeTo" + ) public List getProjects() { log.info("get projects tree"); return toTree(projectMapper.toToList(projectRepository.getAll()), mapper::fromProject); } @GetMapping("/projects/{projectId}/sprints") + @Operation( + summary = "Отримати спринти проєкту та беклог", + description = "Повертає список спринтів проєкту у вигляді дерева, включаючи окремий вузол 'Backlog'" + ) public List getSprintsAndBacklog(@PathVariable long projectId) { log.info("get project {} sprints", projectId); List sprintTos = new ArrayList<>(sprintMapper.toToList(sprintRepository.getAllByProject(projectId))); @@ -58,12 +67,20 @@ public List getSprintsAndBacklog(@PathVariable long projectId) { } @GetMapping("/sprints/{sprintId}/tasks") + @Operation( + summary = "Отримати задачі для спринту", + description = "Повертає задачі, прикріплені до певного спринту, у вигляді дерева" + ) public List getSprintTasks(@PathVariable long sprintId) { log.info("get sprint {} tasks", sprintId); return toTree(taskMapper.toToList(taskRepository.findAllBySprintId(sprintId)), mapper::fromTask); } @GetMapping("/projects/{projectId}/backlog/tasks") + @Operation( + summary = "Отримати задачі беклогу проєкту", + description = "Повертає задачі проєкту, які ще не додані до жодного спринту, у вигляді дерева" + ) public List getBacklogTasks(@PathVariable long projectId) { log.info("get project {} backlog tasks", projectId); return toTree(taskMapper.toToList(taskRepository.findAllByProjectIdAndSprintIsNull(projectId)), mapper::fromTask); diff --git a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java b/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java deleted file mode 100644 index e8e05be05..000000000 --- a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.javarush.jira.login.internal.sociallogin.handler; - -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Component("vk") -public class VkOAuth2UserDataHandler implements OAuth2UserDataHandler { - @Override - public String getFirstName(OAuth2UserData oAuth2UserData) { - return getAttribute(oAuth2UserData, "first_name"); - } - - @Override - public String getLastName(OAuth2UserData oAuth2UserData) { - return getAttribute(oAuth2UserData, "last_name"); - } - - @Override - public String getEmail(OAuth2UserData oAuth2UserData) { - return oAuth2UserData.getData("email"); - } - - private String getAttribute(OAuth2UserData oAuth2UserData, String name) { - List> attributesResponse = oAuth2UserData.getData("response"); - if (attributesResponse != null) { - Map attributes = attributesResponse.get(0); - if (attributes != null) { - return (String) attributes.get(name); - } - } - return null; - } -} diff --git a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/YandexOAuth2UserDataHandler.java b/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/YandexOAuth2UserDataHandler.java deleted file mode 100644 index e8ea1ac1d..000000000 --- a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/YandexOAuth2UserDataHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.javarush.jira.login.internal.sociallogin.handler; - -import org.springframework.stereotype.Component; - -@Component("yandex") -public class YandexOAuth2UserDataHandler implements OAuth2UserDataHandler { - @Override - public String getFirstName(OAuth2UserData oAuth2UserData) { - return oAuth2UserData.getData("first_name"); - } - - @Override - public String getLastName(OAuth2UserData oAuth2UserData) { - return oAuth2UserData.getData("last_name"); - } - - @Override - public String getEmail(OAuth2UserData oAuth2UserData) { - return oAuth2UserData.getData("default_email"); - } -} diff --git a/src/main/java/com/javarush/jira/login/internal/web/AdminUserController.java b/src/main/java/com/javarush/jira/login/internal/web/AdminUserController.java index c29c17f7b..31b4580e1 100644 --- a/src/main/java/com/javarush/jira/login/internal/web/AdminUserController.java +++ b/src/main/java/com/javarush/jira/login/internal/web/AdminUserController.java @@ -5,6 +5,7 @@ import com.javarush.jira.login.User; import com.javarush.jira.login.UserTo; import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Size; import org.springframework.cache.annotation.CacheEvict; import org.springframework.data.domain.Sort; @@ -25,23 +26,39 @@ public class AdminUserController extends AbstractUserController { static final String REST_URL = "/api/admin/users"; @GetMapping("/{id}") + @Operation( + summary = "Отримати користувача за ID", + description = "Повертає об'єкт користувача за вказаним ID" + ) public User get(@PathVariable long id) { return handler.get(id); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Видалити користувача", + description = "Видаляє користувача з бази даних за ID" + ) // getByEmail with return old user until expired public void delete(@PathVariable long id) { handler.delete(id); } @GetMapping + @Operation( + summary = "Отримати всіх користувачів", + description = "Повертає список усіх користувачів, відсортований за email" + ) public List getAll() { return handler.getAll(Sort.by(Sort.Direction.ASC, "email")); } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Створити нового користувача", + description = "Створює нового користувача на основі переданих даних" + ) public ResponseEntity createWithLocation(@Validated(View.OnCreate.class) @RequestBody User user) { User created = handler.create(user); return createdResponse(REST_URL, created); @@ -50,6 +67,10 @@ public ResponseEntity createWithLocation(@Validated(View.OnCreate.class) @ @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) @CacheEvict(value = "users", key = "#user.email") + @Operation( + summary = "Оновити дані користувача", + description = "Оновлює дані користувача за його ID" + ) // In case of update email, getByEmail with old email return old user until expired @JsonView(View.OnUpdate.class) public void update(@Validated(View.OnUpdate.class) @RequestBody User user, @PathVariable long id) { @@ -57,12 +78,20 @@ public void update(@Validated(View.OnUpdate.class) @RequestBody User user, @Path } @GetMapping("/by-email") + @Operation( + summary = "Отримати користувача за email", + description = "Повертає користувача, знайденого за адресою електронної пошти" + ) public User getByEmail(@RequestParam String email) { return handler.getRepository().getExistedByEmail(email); } @PatchMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Активувати/деактивувати користувача", + description = "Змінює статус активності користувача" + ) // getByEmail with return old user until expired public void enable(@PathVariable long id, @RequestParam boolean enabled) { handler.enable(id, enabled); @@ -70,6 +99,10 @@ public void enable(@PathVariable long id, @RequestParam boolean enabled) { @PostMapping("/{id}/change_password") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Змінити пароль користувача", + description = "Оновлює пароль користувача, якщо вказано правильний поточний" + ) public void changePassword(@RequestParam String oldPassword, @Size(min = 5, max = 128) @RequestParam String newPassword, @PathVariable long id) { changePassword0(oldPassword, newPassword, id); } diff --git a/src/main/java/com/javarush/jira/login/internal/web/UserController.java b/src/main/java/com/javarush/jira/login/internal/web/UserController.java index 29cb0e1ce..8e191bb8a 100644 --- a/src/main/java/com/javarush/jira/login/internal/web/UserController.java +++ b/src/main/java/com/javarush/jira/login/internal/web/UserController.java @@ -5,6 +5,7 @@ import com.javarush.jira.login.AuthUser; import com.javarush.jira.login.User; import com.javarush.jira.login.UserTo; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Size; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; @@ -26,6 +27,10 @@ public class UserController extends AbstractUserController { @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Створити нового користувача", + description = "Створює нового користувача на основі даних з тіла запиту" + ) public ResponseEntity createWithLocation(@Validated(View.OnCreate.class) @RequestBody UserTo userTo) { User created = handler.createFromTo(userTo); return createdResponse(REST_URL, created); @@ -34,11 +39,19 @@ public ResponseEntity createWithLocation(@Validated(View.OnCreate.class) @ @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) @JsonView(View.OnUpdate.class) + @Operation( + summary = "Оновити користувача", + description = "Оновлює дані поточного авторизованого користувача" + ) public void update(@Validated(View.OnUpdate.class) @RequestBody UserTo userTo, @AuthenticationPrincipal AuthUser authUser) { authUser.setUser(handler.updateFromTo(userTo, authUser.id())); } @GetMapping + @Operation( + summary = "Отримати користувача", + description = "Повертає дані поточного авторизованого користувача" + ) public User get(@AuthenticationPrincipal AuthUser authUser) { return handler.get(authUser.id()); } @@ -46,6 +59,10 @@ public User get(@AuthenticationPrincipal AuthUser authUser) { @DeleteMapping @ResponseStatus(HttpStatus.NO_CONTENT) @CacheEvict(key = "#authUser.user.email") + @Operation( + summary = "Видалити користувача", + description = "Видаляє поточного авторизованого користувача" + ) public void delete(@AuthenticationPrincipal AuthUser authUser) { handler.delete(authUser.id()); } @@ -53,6 +70,10 @@ public void delete(@AuthenticationPrincipal AuthUser authUser) { @PostMapping("/change_password") @ResponseStatus(HttpStatus.NO_CONTENT) @CacheEvict(key = "#authUser.user.email") + @Operation( + summary = "Змінити пароль", + description = "Змінює пароль для поточного авторизованого користувача" + ) public void changePassword(@RequestParam String oldPassword, @Size(min = 5, max = 128) @RequestParam String newPassword, @AuthenticationPrincipal AuthUser authUser) { changePassword0(oldPassword, newPassword, authUser.id()); } diff --git a/src/main/java/com/javarush/jira/profile/internal/web/ProfileRestController.java b/src/main/java/com/javarush/jira/profile/internal/web/ProfileRestController.java index ba0fcfcb5..c1b6a18a0 100644 --- a/src/main/java/com/javarush/jira/profile/internal/web/ProfileRestController.java +++ b/src/main/java/com/javarush/jira/profile/internal/web/ProfileRestController.java @@ -2,6 +2,7 @@ import com.javarush.jira.login.AuthUser; import com.javarush.jira.profile.ProfileTo; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -14,12 +15,20 @@ public class ProfileRestController extends AbstractProfileController { public static final String REST_URL = "/api/profile"; @GetMapping + @Operation( + summary = "Отримати профіль користувача", + description = "Повертає дані профілю поточного автентифікованого користувача" + ) public ProfileTo get(@AuthenticationPrincipal AuthUser authUser) { return super.get(authUser.id()); } @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Оновити профіль користувача", + description = "Оновлює профіль поточного автентифікованого користувача" + ) public void update(@Valid @RequestBody ProfileTo profileTo, @AuthenticationPrincipal AuthUser authUser) { super.update(profileTo, authUser.id()); } diff --git a/src/main/java/com/javarush/jira/ref/internal/web/ReferenceController.java b/src/main/java/com/javarush/jira/ref/internal/web/ReferenceController.java index 487f8a24b..97ba82276 100644 --- a/src/main/java/com/javarush/jira/ref/internal/web/ReferenceController.java +++ b/src/main/java/com/javarush/jira/ref/internal/web/ReferenceController.java @@ -6,6 +6,7 @@ import com.javarush.jira.ref.internal.Reference; import com.javarush.jira.ref.internal.ReferenceMapper; import com.javarush.jira.ref.internal.ReferenceRepository; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,17 +33,29 @@ public class ReferenceController { private ReferenceRepository repository; @GetMapping("/{type}") + @Operation( + summary = "Отримати всі значення довідника за типом", + description = "Повертає мапу всіх значень довідника певного типу у форматі RefTo" + ) public Map getRefsByType(@PathVariable RefType type) { return ReferenceService.getRefs(type); } @GetMapping("/{type}/{code}") + @Operation( + summary = "Отримати значення довідника за типом і кодом", + description = "Повертає об'єкт RefTo, який відповідає вказаному типу довідника і коду" + ) public RefTo getRefByTypeByCode(@PathVariable RefType type, @PathVariable String code) { return ReferenceService.getRefTo(type, code); } @DeleteMapping("/{type}/{code}") @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Видалити значення довідника", + description = "Видаляє елемент довідника за типом і кодом та оновлює кеш" + ) public void delete(@PathVariable RefType type, @PathVariable String code) { log.debug("delete with type {}, code {}", type, code); RefTo ref = ReferenceService.getRefTo(type, code); @@ -51,6 +64,10 @@ public void delete(@PathVariable RefType type, @PathVariable String code) { } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Створити нове значення довідника", + description = "Створює нове значення довідника на основі переданих даних RefTo" + ) public ResponseEntity create(@Valid @RequestBody RefTo refTo) { log.debug("create {}", refTo); checkNew(refTo); @@ -64,6 +81,10 @@ public ResponseEntity create(@Valid @RequestBody RefTo refTo) { @PutMapping("/{type}/{code}") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional + @Operation( + summary = "Оновити назву значення довідника", + description = "Оновлює поле title для обраного елемента довідника за типом і кодом" + ) public void updateTitle(@PathVariable RefType type, @PathVariable String code, @RequestParam String title) { log.debug("update Ref with type={}, code={} with title={}", title, code, title); Reference ref = getExisted(type, code); @@ -75,6 +96,10 @@ public void updateTitle(@PathVariable RefType type, @PathVariable String code, @ @PatchMapping("/{type}/{code}") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional + @Operation( + summary = "Активувати/деактивувати значення довідника", + description = "Змінює статус enabled (активний/неактивний) для елемента довідника" + ) public void enable(@PathVariable RefType type, @PathVariable String code, @RequestParam boolean enabled) { log.debug("enable Ref with type={}, code={} with enabled={}", type, code, enabled); Reference ref = getExisted(type, code); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7fcba1570..c779529bc 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,11 +1,10 @@ -# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html app: - host-url: http://localhost:8080 - test-mail: jira4jr@gmail.com + host-url: ${APP_HOST_URL:http://localhost:8080} + test-mail: ${APP_TEST_MAIL:jira4jr@gmail.com} templates-update-cache: 5s mail-sending-props: - core-pool-size: 8 - max-pool-size: 100 + core-pool-size: ${MAIL_CORE_POOL_SIZE:8} + max-pool-size: ${MAIL_MAX_POOL_SIZE:100} spring: init: @@ -13,27 +12,21 @@ spring: jpa: show-sql: true open-in-view: false - - # validate db by model hibernate: ddl-auto: validate - properties: - # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations hibernate: format_sql: true default_batch_fetch_size: 20 - # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc jdbc.batch_size: 20 datasource: - url: jdbc:postgresql://localhost:5432/jira - username: jira - password: JiraRush + url: ${DB_URL:jdbc:postgresql://localhost:5432/jira} + username: ${DB_USERNAME:jira} + password: ${DB_PASSWORD:JiraRush} liquibase: changeLog: "classpath:db/changelog.sql" - # Jackson Fields Serialization jackson: visibility: field: any @@ -41,7 +34,6 @@ spring: setter: none is-getter: none - # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#common-application-properties-cache cache: cache-names: users caffeine.spec: maximumSize=10000,expireAfterAccess=5m @@ -51,48 +43,24 @@ spring: client: registration: github: - client-id: 3d0d8738e65881fff266 - client-secret: 0f97031ce6178b7dfb67a6af587f37e222a16120 + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} scope: - email google: - client-id: 329113642700-f8if6pu68j2repq3ef6umd5jgiliup60.apps.googleusercontent.com - client-secret: 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 + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} scope: - email - profile - vk: - client-id: 51562377 - client-secret: jNM1YHQy1362Mqs49wUN - client-name: Vkontakte - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - scope: email - yandex: - client-id: 2f3395214ba84075956b76a34b231985 - client-secret: ed236c501e444a609b0f419e5e88f1e1 - client-name: Yandex - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - authorization-grant-type: authorization_code gitlab: - client-id: b8520a3266089063c0d8261cce36971defa513f5ffd9f9b7a3d16728fc83a494 - client-secret: e72c65320cf9d6495984a37b0f9cc03ec46be0bb6f071feaebbfe75168117004 + client-id: ${GITLAB_CLIENT_ID} + client-secret: ${GITLAB_CLIENT_SECRET} client-name: GitLab redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code scope: read_user provider: - vk: - authorization-uri: https://oauth.vk.com/authorize - token-uri: https://oauth.vk.com/access_token - user-info-uri: https://api.vk.com/method/users.get?v=8.1 - user-name-attribute: response - yandex: - authorization-uri: https://oauth.yandex.ru/authorize - token-uri: https://oauth.yandex.ru/token - user-info-uri: https://login.yandex.ru/info - user-name-attribute: login gitlab: authorization-uri: https://gitlab.com/oauth/authorize token-uri: https://gitlab.com/oauth/token @@ -110,10 +78,11 @@ spring: starttls: enable: true auth: true - host: smtp.gmail.com - username: jira4jr@gmail.com - password: zdfzsrqvgimldzyj - port: 587 + host: ${MAIL_HOST:smtp.gmail.com} + username: ${MAIL_USERNAME:jira4jr@gmail.com} + password: ${MAIL_PASSWORD} + port: ${MAIL_PORT:587} + thymeleaf.check-template-location: false mvc.throw-exception-if-no-handler-found: true @@ -127,11 +96,11 @@ logging: org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG server: - # https://springdoc.org/index.html#how-can-i-deploy-springdoc-openapi-ui-behind-a-reverse-proxy forward-headers-strategy: framework servlet: encoding: - charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly - enabled: true # Enable http encoding support + charset: UTF-8 + enabled: true force: true + springdoc.swagger-ui.path: /doc diff --git a/src/main/resources/data4dev/data.sql b/src/main/resources/data4dev/data.sql index a7d43cbad..28b494155 100644 --- a/src/main/resources/data4dev/data.sql +++ b/src/main/resources/data4dev/data.sql @@ -54,8 +54,7 @@ values (1, 'skype', 'userSkype'), (1, 'mobile', '+01234567890'), (1, 'website', 'user.com'), (2, 'github', 'adminGitHub'), - (2, 'tg', 'adminTg'), - (2, 'vk', 'adminVk'); + (2, 'tg', 'adminTg'); delete from ATTACHMENT; diff --git a/src/test/java/com/javarush/jira/profile/internal/web/ProfileRestControllerTest.java b/src/test/java/com/javarush/jira/profile/internal/web/ProfileRestControllerTest.java index a6fd5e3bf..36e40095d 100644 --- a/src/test/java/com/javarush/jira/profile/internal/web/ProfileRestControllerTest.java +++ b/src/test/java/com/javarush/jira/profile/internal/web/ProfileRestControllerTest.java @@ -1,8 +1,75 @@ package com.javarush.jira.profile.internal.web; import com.javarush.jira.AbstractControllerTest; +import com.javarush.jira.profile.ProfileTo; +import com.javarush.jira.profile.internal.ProfileRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import static com.javarush.jira.common.util.JsonUtil.writeValue; +import static com.javarush.jira.login.internal.web.UserTestData.USER_MAIL; +import static com.javarush.jira.profile.internal.web.ProfileRestController.REST_URL; +import static com.javarush.jira.profile.internal.web.ProfileTestData.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; class ProfileRestControllerTest extends AbstractControllerTest { -} \ No newline at end of file + @Autowired + private ProfileRepository repository; + + @Test + @WithUserDetails(USER_MAIL) + void getProfile() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.mailNotifications.length()").value(USER_PROFILE_TO.getMailNotifications().size())) + .andExpect(jsonPath("$.contacts.length()").value(USER_PROFILE_TO.getContacts().size())); + } + + @Test + void getProfileUnauthorized() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(USER_MAIL) + void updateProfile() throws Exception { + ProfileTo updatedTo = getUpdatedTo(); + + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + @WithUserDetails(USER_MAIL) + void updateInvalidProfile() throws Exception { + ProfileTo invalid = getInvalidTo(); + + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(invalid))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void updateProfileUnauthorized() throws Exception { + ProfileTo profile = getNewTo(); + + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(profile))) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/javarush/jira/profile/internal/web/ProfileTestData.java b/src/test/java/com/javarush/jira/profile/internal/web/ProfileTestData.java index fb4407268..cc0513971 100644 --- a/src/test/java/com/javarush/jira/profile/internal/web/ProfileTestData.java +++ b/src/test/java/com/javarush/jira/profile/internal/web/ProfileTestData.java @@ -44,7 +44,6 @@ public static ProfileTo getUpdatedTo() { new ContactTo("website", "new.com"), new ContactTo("github", "newGitHub"), new ContactTo("tg", "newTg"), - new ContactTo("vk", "newVk"), new ContactTo("linkedin", "newLinkedin"))); } @@ -57,7 +56,6 @@ public static Profile getUpdated(long id) { new Contact(id, "website", "new.com"), new Contact(id, "github", "newGitHub"), new Contact(id, "tg", "newTg"), - new Contact(id, "vk", "newVk"), new Contact(id, "linkedin", "newLinkedin"))); return profile; } diff --git a/src/test/resources/data.sql b/src/test/resources/data.sql index 5087dbddc..bc26161e4 100644 --- a/src/test/resources/data.sql +++ b/src/test/resources/data.sql @@ -53,8 +53,7 @@ values (1, 'skype', 'userSkype'), (1, 'mobile', '+01234567890'), (1, 'website', 'user.com'), (2, 'github', 'adminGitHub'), - (2, 'tg', 'adminTg'), - (2, 'vk', 'adminVk'); + (2, 'tg', 'adminTg'); insert into PROJECT (code, title, description, type_code, parent_id)