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/Dockerfile b/Dockerfile new file mode 100644 index 000000000..0a2effd82 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 +FROM eclipse-temurin:17-jdk-jammy AS builder +WORKDIR /app + +# Copy build files first to leverage Docker layer cache +COPY .mvn/ .mvn/ +COPY mvnw pom.xml ./ +RUN chmod +x mvnw +# Speed up builds by caching the Maven repo (BuildKit needed) +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -DskipTests dependency:go-offline + +# Now add sources and build +COPY src/ src/ +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -DskipTests clean package + +FROM eclipse-temurin:17-jre-jammy +WORKDIR /app + +# Run as non-root +RUN useradd -ms /bin/bash appuser +USER appuser + +# Copy the fat jar +COPY --from=builder /app/target/*.jar /app/app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/README.md b/README.md index 719b268f5..4a1cb9836 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,27 @@ - https://habr.com/ru/articles/259055/ Список выполненных задач: -... \ No newline at end of file + +# JiraRush Project: My Contributions & Enhancements + +This document outlines my key contributions and improvements to the JiraRush project, covering aspects of security, testing, refactoring, new features, and deployment. + +## My Contributions to the Project: + +Here's a breakdown of the specific tasks and features I implemented: + +1. 📘 **Project Structure Onboarding:** I successfully familiarized myself with the existing project structure and architecture, enabling effective subsequent modifications and new feature development. +2. 🚫 **Removal of Deprecated Social Integrations:** I removed outdated social media integrations (VK, Yandex) from the project, simplifying the codebase and mitigating potential security risks. +3. 🔑 **Externalization of Sensitive Information:** I moved sensitive data, such as database login credentials, OAuth registration/authorization identifiers, and email settings, into separate property files. These values are now securely loaded from machine environment variables upon server startup, significantly enhancing security and configuration flexibility. +4. 🧪 **Refactoring Tests for In-Memory H2 Database:** I adapted the test suite to utilize an in-memory H2 database instead of PostgreSQL. This involved defining two distinct Spring beans, with the active Spring profile dictating which database to use. Minor adjustments were also made to test data scripts to ensure compatibility with H2's features. +5. ✅ **Comprehensive `ProfileRestController` Test Coverage:** I developed a robust set of unit and integration tests for all public methods of the `ProfileRestController`. These tests meticulously validate both successful and various unsuccessful execution paths to guarantee the API's reliability and resilience. +6. 🔄 **Refactoring `FileUtil#upload` for Modern File System API:** I refactored the `com.javarush.jira.bugtracking.attachment.FileUtil#upload` method to leverage modern Java file system APIs, enhancing its efficiency, robustness, and maintainability for file operations. +7. 🏷️ **Implementation of Task Tagging System:** I introduced a new feature allowing users to add tags to tasks. This includes developing the dedicated REST API endpoint and implementing the corresponding service-layer logic, utilizing the existing `task_tag` database table. +8. ⏱️ **Adding Task Time Tracking Functionality:** I implemented two service-level methods to calculate the time a task spends in specific operational states: + * **Time in Work:** Calculated as the duration from `in_progress` to `ready_for_review` status. + * **Time in Testing:** Calculated as the duration from `ready_for_review` to `done` status. + To support this, I appended three critical `ACTIVITY` entries (with `in_progress`, `ready_for_review`, and `done` statuses) to the `changelog.sql` database initialization script. +9. 🐳 **Dockerfile for Application Server:** I created a `Dockerfile` to containerize the main application server, ensuring easy and consistent deployment across different environments. +10. 🚀 **Docker Compose for Full Stack Deployment:** I developed a `docker-compose.yml` file to orchestrate the entire application stack. This setup includes the application server, a PostgreSQL database, and an Nginx reverse proxy, leveraging the `config/nginx.conf` file (which can be modified as needed) for efficient routing and load balancing. + +--- \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..c54cb4ef2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,47 @@ +services: + db: + image: postgres + container_name: postgres + env_file: + - ./.env + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - app_network + + app: + build: + context: . + dockerfile: Dockerfile + container_name: app + env_file: + - ./.env + ports: + - "8080:8080" + depends_on: + - db + networks: + - app_network + + nginx: + image: nginx:latest + container_name: nginx + volumes: + - ./config/nginx.conf:/etc/nginx/nginx.conf + - ./resources/static:/opt/jirarush/resources/static + ports: + - "80:80" + depends_on: + - app + networks: + - app_network + +networks: + app_network: + driver: bridge + +volumes: + pgdata: + driver: local \ No newline at end of file diff --git a/config/nginx.conf b/config/nginx.conf index 82b9e234d..3ef4ca4f3 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,40 +1,52 @@ -# 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 { - listen 80; - - # https://www.digitalocean.com/community/tutorials/how-to-optimize-nginx-configuration - gzip on; - gzip_types text/css application/javascript application/json; - gzip_min_length 2048; - - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - root /opt/jirarush/resources; - - if ($request_uri ~ ';') {return 404;} - - # proxy_cookie_flags ~ secure samesite=none; - - # static - location /static/ { - expires 30d; - access_log off; - } - location /robots.txt { - access_log off; - } - - location ~ (/$|/view/|/ui/|/oauth2/) { - expires 0m; - proxy_pass http://localhost:8080; - proxy_connect_timeout 30s; - } - location ~ (/api/|/doc|/swagger-ui/|/v3/api-docs/) { - proxy_pass http://localhost:8080; - proxy_connect_timeout 150s; - } - location / { - try_files /view/404.html = 404; - } +# Основний блок налаштувань +user nginx; +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Ваш серверний блок + server { + listen 80; + + # https://www.digitalocean.com/community/tutorials/how-to-optimize-nginx-configuration + gzip on; + gzip_types text/css application/javascript application/json; + gzip_min_length 2048; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + root /opt/jirarush/resources; + + if ($request_uri ~ ';') { return 404; } + + # Статичні файли + location /static/ { + expires 30d; + access_log off; + } + + location /robots.txt { + access_log off; + } + + location ~ (/$|/view/|/ui/|/oauth2/) { + expires 0m; + proxy_pass http://localhost:8080; + proxy_connect_timeout 30s; + } + + location ~ (/api/|/doc|/swagger-ui/|/v3/api-docs/) { + proxy_pass http://localhost:8080; + proxy_connect_timeout 150s; + } + + location / { + try_files /view/404.html '=' 404; + } + } } \ No newline at end of file diff --git a/pom.xml b/pom.xml index f6c152c68..17044851e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.0.2 + 3.1.12 @@ -41,6 +41,19 @@ spring-boot-starter-validation + + io.github.cdimascio + java-dotenv + 5.2.2 + + + + org.springframework.boot + spring-boot-docker-compose + runtime + true + + com.fasterxml.jackson.datatype @@ -82,6 +95,12 @@ spring-boot-starter-thymeleaf + + com.h2database + h2 + test + + org.postgresql postgresql @@ -96,7 +115,7 @@ org.projectlombok lombok - true + 1.18.30 org.mapstruct @@ -146,6 +165,29 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.30 + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.springframework.boot + spring-boot-configuration-processor + 3.5.5 + + + + org.springframework.boot spring-boot-maven-plugin @@ -196,4 +238,4 @@ - + \ No newline at end of file diff --git a/resources/static/fontawesome/css/all.css b/resources/static/fontawesome/css/all.css index af5980828..6a16cc2f0 100644 --- a/resources/static/fontawesome/css/all.css +++ b/resources/static/fontawesome/css/all.css @@ -8603,10 +8603,6 @@ readers do not read off random characters that represent icons */ content: "\f3e8"; } -.fa-vk:before { - content: "\f189"; -} - .fa-untappd:before { content: "\f405"; } @@ -9955,10 +9951,6 @@ readers do not read off random characters that represent icons */ content: "\f3bc"; } -.fa-yandex:before { - content: "\f413"; -} - .fa-readme:before { content: "\f4d5"; } @@ -10183,10 +10175,6 @@ 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/FileUtil.java b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java index 6cffbe175..434ad5501 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,13 @@ 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 directory = Path.of(directoryPath); + Files.createDirectory(directory); + Path targetFile = directory.resolve(fileName); + multipartFile.transferTo(targetFile); + } catch (IOException e) { + throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename()); } } diff --git a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java index 3ce8a9386..b86749dc9 100644 --- a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java +++ b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java @@ -1,10 +1,12 @@ package com.javarush.jira.bugtracking.task; import com.javarush.jira.common.BaseRepository; +import jakarta.validation.constraints.Size; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Transactional(readOnly = true) public interface ActivityRepository extends BaseRepository { @@ -13,4 +15,6 @@ public interface ActivityRepository extends BaseRepository { @Query("SELECT a FROM Activity a JOIN FETCH a.author WHERE a.taskId =:taskId AND a.comment IS NOT NULL ORDER BY a.updated DESC") List findAllComments(long taskId); + + Optional findActivityByTaskIdAndStatusCode(long taskId, @Size(min = 2, max = 32) String statusCode); } diff --git a/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java b/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java index 7938541bb..308a16eab 100644 --- a/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java +++ b/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import static com.javarush.jira.bugtracking.task.TaskUtil.getLatestValue; @@ -19,11 +21,9 @@ public class ActivityService { private final Handlers.ActivityHandler handler; - private static void checkBelong(HasAuthorId activity) { - if (activity.getAuthorId() != AuthUser.authId()) { - throw new DataConflictException("Activity " + activity.getId() + " doesn't belong to " + AuthUser.get()); - } - } + public static final String IN_PROGRESS = "in_progress"; + public static final String READY_FOR_REVIEW = "ready_for_review"; + public static final String DONE = "done"; @Transactional public Activity create(ActivityTo activityTo) { @@ -73,4 +73,39 @@ private void updateTaskIfRequired(long taskId, String activityStatus, String act } } } + + private static void checkBelong(HasAuthorId activity) { + if (activity.getAuthorId() != AuthUser.authId()) { + throw new DataConflictException("Activity " + activity.getId() + " doesn't belong to " + AuthUser.get()); + } + } + + public Duration progressTaskDuration(Task task) { + LocalDateTime readyForReview = getActivityUpdatedTime(task.id(), READY_FOR_REVIEW); + LocalDateTime inProgress = getActivityUpdatedTime(task.id(), IN_PROGRESS); + + if (readyForReview == null || inProgress == null) { + return Duration.ZERO; + } + + return Duration.between(inProgress, readyForReview); + } + + public Duration testingTaskDuration(Task task) { + LocalDateTime done = getActivityUpdatedTime(task.id(), DONE); + LocalDateTime readyForReview = getActivityUpdatedTime(task.id(), READY_FOR_REVIEW); + + if (readyForReview == null || done == null) { + return Duration.ZERO; + } + + return Duration.between(readyForReview, done); + } + + private LocalDateTime getActivityUpdatedTime(long taskId, String statusCode) { + return handler.getRepository() + .findActivityByTaskIdAndStatusCode(taskId, statusCode) + .map(Activity::getUpdated) + .orElse(null); + } } 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..4fa2523af 100644 --- a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java +++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java @@ -156,4 +156,10 @@ public TaskTreeNode(TaskTo taskTo) { this(taskTo, new LinkedList<>()); } } + + @PostMapping("/{id}/tag") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void addTag(@PathVariable long id, @RequestBody String tag) { + taskService.addTag(id, tag); + } } diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java index e6f385548..e58a8031d 100644 --- a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java +++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java @@ -10,6 +10,7 @@ import com.javarush.jira.bugtracking.task.to.TaskToExt; import com.javarush.jira.bugtracking.task.to.TaskToFull; import com.javarush.jira.common.error.DataConflictException; +import com.javarush.jira.common.error.IllegalRequestDataException; import com.javarush.jira.common.error.NotFoundException; import com.javarush.jira.common.util.Util; import com.javarush.jira.login.AuthUser; @@ -140,4 +141,19 @@ private void checkAssignmentActionPossible(long id, String userType, boolean ass throw new DataConflictException(String.format(assign ? CANNOT_ASSIGN : CANNOT_UN_ASSIGN, userType, task.getStatusCode())); } } + + @Transactional + public void addTag(long taskId, String tag) { + if (tag == null || tag.isEmpty()) { + throw new IllegalRequestDataException("Tag must not be null or empty"); + } + if (tag.length() > 32) { + throw new IllegalRequestDataException("Tag must not exceed 32 characters"); + } + + Task task = handler.getRepository().getExisted(taskId); + task.getTags().add(tag); + + handler.getRepository().saveAndFlush(task); + } } 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/profile/internal/model/Contact.java b/src/main/java/com/javarush/jira/profile/internal/model/Contact.java index e3f29674b..3052233cc 100644 --- a/src/main/java/com/javarush/jira/profile/internal/model/Contact.java +++ b/src/main/java/com/javarush/jira/profile/internal/model/Contact.java @@ -45,7 +45,7 @@ public class Contact implements HasId { @NotBlank @Size(min = 2, max = 256) - @Column(name = "value", nullable = false) + @Column(name = "val", nullable = false) @NoHtml private String value; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7fcba1570..891c2e063 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,34 +1,33 @@ -# 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 + test-mail: ${SPRING_MAIL_USERNAME} templates-update-cache: 5s mail-sending-props: core-pool-size: 8 max-pool-size: 100 spring: + config: + import: "optional:.env" init: mode: never 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 + default-batch-fetch-size: 20 jdbc.batch_size: 20 datasource: - url: jdbc:postgresql://localhost:5432/jira - username: jira - password: JiraRush + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver liquibase: changeLog: "classpath:db/changelog.sql" @@ -51,48 +50,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 @@ -111,8 +86,8 @@ spring: enable: true auth: true host: smtp.gmail.com - username: jira4jr@gmail.com - password: zdfzsrqvgimldzyj + username: ${SPRING_MAIL_USERNAME} + password: ${SPRING_MAIL_PASSWORD} port: 587 thymeleaf.check-template-location: false @@ -127,11 +102,10 @@ 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 +springdoc.swagger-ui.path: /doc \ No newline at end of file diff --git a/src/main/resources/data4dev/data.sql b/src/main/resources/data4dev/data.sql index a7d43cbad..f11a33804 100644 --- a/src/main/resources/data4dev/data.sql +++ b/src/main/resources/data4dev/data.sql @@ -49,14 +49,12 @@ insert into PROFILE (ID, LAST_FAILED_LOGIN, LAST_LOGIN, MAIL_NOTIFICATIONS) values (1, null, null, 49), (2, null, null, 14); -insert into CONTACT (ID, CODE, VALUE) +insert into CONTACT (ID, CODE, VAL) 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; alter diff --git a/src/main/resources/db/changelog.sql b/src/main/resources/db/changelog.sql index 68591336d..0f75bebd0 100644 --- a/src/main/resources/db/changelog.sql +++ b/src/main/resources/db/changelog.sql @@ -107,7 +107,7 @@ create table CONTACT ( ID bigint not null, CODE varchar(32) not null, - VALUE varchar(256) not null, + VAL varchar(256) not null, primary key (ID, CODE), constraint FK_CONTACT_PROFILE foreign key (ID) references PROFILE (ID) on delete cascade ); @@ -218,7 +218,6 @@ values ('task', 'Task', 2), ('mobile', 'Mobile', 0), ('phone', 'Phone', 0), ('website', 'Website', 0), - ('vk', 'VK', 0), ('linkedin', 'LinkedIn', 0), ('github', 'GitHub', 0), -- PRIORITY @@ -329,3 +328,10 @@ values ('todo', 'ToDo', 3, 'in_progress,canceled|'), drop index UK_USER_BELONG; create unique index UK_USER_BELONG on USER_BELONG (OBJECT_ID, OBJECT_TYPE, USER_ID, USER_TYPE_CODE) where ENDPOINT is null; + +--changeset romanyehorov:add_user_activity + +insert into ACTIVITY (AUTHOR_ID, TASK_ID, UPDATED, STATUS_CODE) +values (7, 1, '2023-05-16 09:05:10.000000', 'in_progress'), + (7, 1, '2023-05-16 12:25:10.000000', 'ready_for_review'), + (7, 1, '2023-05-16 14:05:10.000000', 'done'); \ No newline at end of file diff --git a/src/test/java/com/javarush/jira/AbstractControllerTest.java b/src/test/java/com/javarush/jira/AbstractControllerTest.java index 5981bae53..2652be592 100644 --- a/src/test/java/com/javarush/jira/AbstractControllerTest.java +++ b/src/test/java/com/javarush/jira/AbstractControllerTest.java @@ -9,7 +9,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; //https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications -@Sql(scripts = {"classpath:db/changelog.sql", "classpath:data.sql"}, config = @SqlConfig(encoding = "UTF-8")) +@Sql(scripts = {"classpath:db/changelog-test.sql", "classpath:data.sql"}, config = @SqlConfig(encoding = "UTF-8")) @AutoConfigureMockMvc //https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment public abstract class AbstractControllerTest extends BaseTests { 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..a8491b559 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,115 @@ 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 com.javarush.jira.profile.internal.model.Profile; +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.*; +import static com.javarush.jira.profile.internal.web.ProfileTestData.PROFILE_MATCHER; +import static com.javarush.jira.profile.internal.web.ProfileTestData.TO_MATCHER; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class ProfileRestControllerTest extends AbstractControllerTest { -} \ No newline at end of file + @Autowired + private ProfileRepository profileRepository; + + private static final String REST_URL = ProfileRestController.REST_URL; + + @Test + @WithUserDetails(value = USER_MAIL) + void get_asUser_success() throws Exception { + ProfileTo expectedProfile = ProfileTestData.USER_PROFILE_TO; + expectedProfile.setId(USER_ID); + + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(TO_MATCHER.contentJson(expectedProfile)); + } + + @Test + @WithUserDetails(value = GUEST_MAIL) + void get_asGuest_emptyProfile() throws Exception { + ProfileTo expectedProfile = ProfileTestData.GUEST_PROFILE_EMPTY_TO; + expectedProfile.setId(GUEST_ID); + + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(TO_MATCHER.contentJson(expectedProfile)); + } + + @Test + void get_unauthorized() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update_success() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(ProfileTestData.getUpdatedTo()))) + .andDo(print()) + .andExpect(status().isNoContent()); + + Profile dbProfileAfter = profileRepository.getExisted(USER_ID); + Profile updated = ProfileTestData.getUpdated(USER_ID); + + PROFILE_MATCHER.assertMatch(dbProfileAfter, updated); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update_invalidNotification() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(ProfileTestData.getWithUnknownNotificationTo()))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update_invalidUser() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(ProfileTestData.getInvalidTo()))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update_unknownContact() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(ProfileTestData.getWithUnknownContactTo()))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update_htmlUnsafeContact() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(ProfileTestData.getWithContactHtmlUnsafeTo()))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } +} 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..83f14955d 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 @@ -13,6 +13,9 @@ public class ProfileTestData { public static MatcherFactory.Matcher PROFILE_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(Profile.class, "user"); + public static MatcherFactory.Matcher TO_MATCHER = + MatcherFactory.usingIgnoringFieldsComparator(ProfileTo.class, "user"); + public static ProfileTo USER_PROFILE_TO = new ProfileTo(null, Set.of("assigned", "overdue", "deadline"), Set.of(new ContactTo("skype", "userSkype"), @@ -44,7 +47,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 +59,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/application-test.yaml b/src/test/resources/application-test.yaml index 51137fd06..9ea4d79b1 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,8 +1,30 @@ -spring.cache.type: none spring: - init: - mode: always datasource: - url: jdbc:postgresql://localhost:5433/jira-test - username: jira - password: JiraRush \ No newline at end of file + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: none + liquibase: + change-log: "classpath:db/changelog-test.sql" + cache: + type: none + mail: + username: test@example.com + password: testpassword + security: + oauth2: + client: + registration: + github: + client-id: test-github-client-id + client-secret: test-github-client-secret + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + gitlab: + client-id: test-gitlab-client-id + client-secret: test-gitlab-client-secret \ No newline at end of file diff --git a/src/test/resources/data.sql b/src/test/resources/data.sql index 5087dbddc..d44e99876 100644 --- a/src/test/resources/data.sql +++ b/src/test/resources/data.sql @@ -1,32 +1,19 @@ --------- users ---------------------- -delete -from USER_ROLE; -delete -from CONTACT; -delete -from PROFILE; - -delete -from ACTIVITY; -alter -sequence ACTIVITY_ID_SEQ restart with 1; -delete -from TASK; -alter -sequence TASK_ID_SEQ restart with 1; -delete -from SPRINT; -alter -sequence SPRINT_ID_SEQ restart with 1; -delete -from PROJECT; -alter -sequence PROJECT_ID_SEQ restart with 1; - -delete -from USERS; -alter -sequence USERS_ID_SEQ restart with 1; +delete from USER_ROLE; +delete from PROFILE; +delete from USERS; +delete from ACTIVITY; +delete from TASK; +delete from SPRINT; +delete from PROJECT; +delete from USER_BELONG; +alter table PROJECT ALTER COLUMN ID restart with 1; +alter table USERS ALTER COLUMN ID restart with 1; +alter table ACTIVITY ALTER COLUMN ID restart with 1; +alter table TASK ALTER COLUMN ID restart with 1; +alter table SPRINT ALTER COLUMN ID restart with 1; +alter table SPRINT ALTER COLUMN ID restart with 1; +alter table USER_BELONG ALTER COLUMN ID restart with 1; insert into USERS (EMAIL, PASSWORD, FIRST_NAME, LAST_NAME, DISPLAY_NAME) values ('user@gmail.com', '{noop}password', 'userFirstName', 'userLastName', 'userDisplayName'), @@ -48,13 +35,12 @@ insert into PROFILE (ID, LAST_FAILED_LOGIN, LAST_LOGIN, MAIL_NOTIFICATIONS) values (1, null, null, 49), (2, null, null, 14); -insert into CONTACT (ID, CODE, VALUE) +insert into CONTACT (ID, CODE, VAL) 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) @@ -88,11 +74,10 @@ values (1, 1, '2023-05-15 09:05:10', null, 'Data', null, 3, 'epic', 'in_progress (1, 2, '2023-05-15 12:05:10', null, 'Trees', 'Trees desc', 4, 'epic', 'in_progress', 'normal'); insert into USER_BELONG (OBJECT_ID, OBJECT_TYPE, USER_ID, USER_TYPE_CODE, STARTPOINT, ENDPOINT) -values (1, 2, 2, 'task_developer', '2023-06-14 08:35:10', '2023-06-14 08:55:00'), +values (1, 2, 2, 'task_tester', '2023-06-14 08:35:10', '2023-06-14 08:55:00'), (1, 2, 2, 'task_reviewer', '2023-06-14 09:35:10', null), - (1, 2, 1, 'task_developer', '2023-06-12 11:40:00', '2023-06-12 12:35:00'), - (1, 2, 1, 'task_developer', '2023-06-13 12:35:00', null), + (1, 2, 1, 'task_developer', '2023-06-12 11:40:00', null), (1, 2, 1, 'task_tester', '2023-06-14 15:20:00', null), (2, 2, 2, 'task_developer', '2023-06-08 07:10:00', null), (2, 2, 1, 'task_developer', '2023-06-09 14:48:00', null), - (2, 2, 1, 'task_tester', '2023-06-10 16:37:00', null); + (2, 2, 1, 'task_tester', '2023-06-10 16:37:00', null); \ No newline at end of file diff --git a/src/test/resources/db/changelog-test.sql b/src/test/resources/db/changelog-test.sql new file mode 100644 index 000000000..66a82a2ae --- /dev/null +++ b/src/test/resources/db/changelog-test.sql @@ -0,0 +1,251 @@ +--liquibase formatted sql + +--changeset apuchinec:drop_all + +DROP TABLE IF EXISTS USER_ROLE; +DROP TABLE IF EXISTS CONTACT; +DROP TABLE IF EXISTS MAIL_CASE; +DROP + SEQUENCE IF EXISTS MAIL_CASE_ID_SEQ; +DROP TABLE IF EXISTS PROFILE; +DROP TABLE IF EXISTS TASK_TAG; +DROP TABLE IF EXISTS USER_BELONG; +DROP + SEQUENCE IF EXISTS USER_BELONG_ID_SEQ; +DROP TABLE IF EXISTS ACTIVITY; +DROP + SEQUENCE IF EXISTS ACTIVITY_ID_SEQ; +DROP TABLE IF EXISTS TASK; +DROP + SEQUENCE IF EXISTS TASK_ID_SEQ; +DROP TABLE IF EXISTS SPRINT; +DROP + SEQUENCE IF EXISTS SPRINT_ID_SEQ; +DROP TABLE IF EXISTS PROJECT; +DROP + SEQUENCE IF EXISTS PROJECT_ID_SEQ; +DROP TABLE IF EXISTS REFERENCE; +DROP + SEQUENCE IF EXISTS REFERENCE_ID_SEQ; +DROP TABLE IF EXISTS ATTACHMENT; +DROP + SEQUENCE IF EXISTS ATTACHMENT_ID_SEQ; +DROP TABLE IF EXISTS USERS; +DROP + SEQUENCE IF EXISTS USERS_ID_SEQ; + +--changeset apuchinec:create_tables + +create table PROJECT +( + ID bigint generated by default as identity primary key, + CODE varchar(32) not null + constraint UK_PROJECT_CODE unique, + TITLE varchar(1024) not null, + DESCRIPTION varchar(4096) not null, + TYPE_CODE varchar(32) not null, + STARTPOINT timestamp, + ENDPOINT timestamp, + PARENT_ID bigint, + constraint FK_PROJECT_PARENT foreign key (PARENT_ID) references PROJECT (ID) on delete cascade +); + +create table MAIL_CASE +( + ID bigint generated by default as identity primary key, + EMAIL varchar(255) not null, + NAME varchar(255) not null, + DATE_TIME timestamp not null, + RESULT varchar(255) not null, + TEMPLATE varchar(255) not null +); + +create table SPRINT +( + ID bigint generated by default as identity primary key, + STATUS_CODE varchar(32) not null, + STARTPOINT timestamp, + ENDPOINT timestamp, + CODE varchar(32) not null, + PROJECT_ID bigint not null, + constraint FK_SPRINT_PROJECT foreign key (PROJECT_ID) references PROJECT (ID) on delete cascade, + constraint UK_SPRINT_PROJECT_CODE unique(PROJECT_ID, CODE) +); + +create table REFERENCE +( + ID bigint generated by default as identity primary key, + CODE varchar(32) not null, + REF_TYPE smallint not null, + ENDPOINT timestamp, + STARTPOINT timestamp, + TITLE varchar(1024) not null, + AUX varchar, + constraint UK_REFERENCE_REF_TYPE_CODE unique (REF_TYPE, CODE) +); + +create table USERS +( + ID bigint generated by default as identity primary key, + DISPLAY_NAME varchar(32) not null + constraint UK_USERS_DISPLAY_NAME unique, + EMAIL varchar(128) not null + constraint UK_USERS_EMAIL unique, + FIRST_NAME varchar(32) not null, + LAST_NAME varchar(32), + PASSWORD varchar(128) not null, + ENDPOINT timestamp, + STARTPOINT timestamp +); + +create table PROFILE +( + ID bigint primary key, + LAST_LOGIN timestamp, + LAST_FAILED_LOGIN timestamp, + MAIL_NOTIFICATIONS bigint, + constraint FK_PROFILE_USERS foreign key (ID) references USERS (ID) on delete cascade +); + + +create table TASK +( + ID bigint generated by default as identity primary key, + TITLE varchar(1024) not null, + TYPE_CODE varchar(32) not null, + STATUS_CODE varchar(32) not null, + PROJECT_ID bigint not null, + SPRINT_ID bigint, + PARENT_ID bigint, + STARTPOINT timestamp, + ENDPOINT timestamp, + constraint FK_TASK_SPRINT foreign key (SPRINT_ID) references SPRINT (ID) on delete set null, + constraint FK_TASK_PROJECT foreign key (PROJECT_ID) references PROJECT (ID) on delete cascade, + constraint FK_TASK_PARENT_TASK foreign key (PARENT_ID) references TASK (ID) on delete cascade +); + +create table ACTIVITY +( + ID bigint generated by default as identity primary key, + AUTHOR_ID bigint not null, + TASK_ID bigint not null, + UPDATED timestamp, + COMMENT varchar(4096), +-- history of task field change + TITLE varchar(1024), + DESCRIPTION varchar(4096), + ESTIMATE integer, + TYPE_CODE varchar(32), + STATUS_CODE varchar(32), + PRIORITY_CODE varchar(32), + constraint FK_ACTIVITY_USERS foreign key (AUTHOR_ID) references USERS (ID) on delete cascade , + constraint FK_ACTIVITY_TASK foreign key (TASK_ID) references TASK (ID) on delete cascade +); + +create table TASK_TAG +( + TASK_ID bigint not null, + TAG varchar(32) not null, + constraint UK_TASK_TAG unique (TASK_ID, TAG), + constraint FK_TASK_TAG foreign key (TASK_ID) references TASK (ID) on delete cascade +); + +create table USER_BELONG +( + ID bigint generated by default as identity primary key, + OBJECT_ID bigint not null, + OBJECT_TYPE smallint not null, + USER_ID bigint not null, + USER_TYPE_CODE varchar(32) not null, + STARTPOINT timestamp, + ENDPOINT timestamp, + constraint FK_USER_BELONG foreign key (USER_ID) references USERS (ID) on delete cascade, + constraint UK_USER_BELONG unique(OBJECT_ID, OBJECT_TYPE, USER_ID, USER_TYPE_CODE) +); +create index IX_USER_BELONG_USER_ID on USER_BELONG (USER_ID); + +create table ATTACHMENT +( + ID bigint generated by default as identity primary key, + NAME varchar(128) not null, + FILE_LINK varchar(2048) not null, + OBJECT_ID bigint not null, + OBJECT_TYPE smallint not null, + USER_ID bigint not null, + DATE_TIME timestamp, + constraint FK_ATTACHMENT foreign key (USER_ID) references USERS (ID) on DELETE cascade +); + +create table USER_ROLE +( + USER_ID bigint not null, + ROLE smallint not null, + constraint UK_USER_ROLE unique (USER_ID, ROLE), + constraint FK_USER_ROLE foreign key (USER_ID) references USERS (ID) on delete cascade +); + +--changeset apuchinec:populate_data +--============ References ================= +insert into REFERENCE (CODE, TITLE, REF_TYPE) +-- TASK +values ('task', 'Task', 2), + ('story', 'Story', 2), + ('bug', 'Bug', 2), + ('epic', 'Epic', 2), +-- SPRINT_STATUS + ('planning', 'Planning', 4), + ('active', 'Active', 4), + ('finished', 'Finished', 4), +-- USER_TYPE + ('project_author', 'Author', 5), + ('project_manager', 'Manager', 5), + ('sprint_author', 'Author', 5), + ('sprint_manager', 'Manager', 5), + ('task_author', 'Author', 5), + ('task_developer', 'Developer', 5), + ('task_reviewer', 'Reviewer', 5), + ('task_tester', 'Tester', 5), +-- PROJECT + ('scrum', 'Scrum', 1), + ('task_tracker', 'Task tracker', 1), +-- CONTACT + ('skype', 'Skype', 0), + ('tg', 'Telegram', 0), + ('mobile', 'Mobile', 0), + ('phone', 'Phone', 0), + ('website', 'Website', 0), + ('vk', 'VK', 0), + ('linkedin', 'LinkedIn', 0), + ('github', 'GitHub', 0), +-- PRIORITY + ('critical', 'Critical', 7), + ('high', 'High', 7), + ('normal', 'Normal', 7), + ('low', 'Low', 7), + ('neutral', 'Neutral', 7); + +insert into REFERENCE (CODE, TITLE, REF_TYPE, AUX) +-- MAIL_NOTIFICATION +values ('assigned', 'Assigned', 6, '1'), + ('three_days_before_deadline', 'Three days before deadline', 6, '2'), + ('two_days_before_deadline', 'Two days before deadline', 6, '4'), + ('one_day_before_deadline', 'One day before deadline', 6, '8'), + ('deadline', 'Deadline', 6, '16'), + ('overdue', 'Overdue', 6, '32'), +-- TASK_STATUS + ('todo', 'ToDo', 3, 'in_progress,canceled|'), + ('in_progress', 'In progress', 3, 'ready_for_review,canceled|task_developer'), + ('ready_for_review', 'Ready for review', 3, 'in_progress,review,canceled|'), + ('review', 'Review', 3, 'in_progress,ready_for_test,canceled|task_reviewer'), + ('ready_for_test', 'Ready for test', 3, 'review,test,canceled|'), + ('test', 'Test', 3, 'done,in_progress,canceled|task_tester'), + ('done', 'Done', 3, 'canceled|'), + ('canceled', 'Canceled', 3, null); +create table CONTACT +( + ID bigint not null, + CODE varchar(32) not null, + VAL varchar(256) not null, + primary key (ID, CODE), + constraint FK_CONTACT_PROFILE foreign key (ID) references PROFILE (ID) on delete cascade +); \ No newline at end of file