From 3c94a553ea672ab4ad575d21d24dd19f07b8a9ab Mon Sep 17 00:00:00 2001 From: berkanOzel Date: Tue, 4 Jun 2024 16:38:19 +0300 Subject: [PATCH 1/3] TinyTask emailScheduler improvements --- angular.json | 8 +- build.gradle | 3 + cypress.config.ts | 27 +++ cypress.json | 9 - cypress/fixtures/example.json | 5 + cypress/support/commands.ts | 37 ++++ cypress/support/component-index.html | 12 ++ cypress/support/component.ts | 39 ++++ package.json | 5 + .../cypress/integration/{spec.ts => .cy.ts} | 0 .../coyoapp/tinytask/TinyTaskApplication.java | 4 +- .../configuration/FreeMarkerConfig.java | 17 ++ .../tinytask/configuration/WebConfig.java | 4 +- .../tinytask/domain/NotificationSetting.java | 34 ++++ .../com/coyoapp/tinytask/domain/Task.java | 4 + .../dto/NotificationSettingModel.java | 33 +++ .../com/coyoapp/tinytask/dto/TaskRequest.java | 3 + .../coyoapp/tinytask/dto/TaskResponse.java | 4 + .../com/coyoapp/tinytask/enums/Duration.java | 18 ++ .../NotificationSettingRepository.java | 19 ++ .../tinytask/repository/TaskRepository.java | 13 ++ .../tinytask/scheduler/EMailScheduler.java | 50 +++++ .../service/DefaultNotificationService.java | 59 ++++++ .../tinytask/service/DefaultTaskService.java | 22 +- .../tinytask/service/EmailService.java | 56 ++++++ .../tinytask/service/NotificationService.java | 9 + .../service/NotificationSettingService.java | 15 ++ .../coyoapp/tinytask/service/TaskService.java | 6 + .../tinytask/web/NotificationController.java | 30 +++ .../coyoapp/tinytask/web/TaskController.java | 18 +- src/main/resources/application.yml | 12 ++ src/main/resources/db/migration/V2__Setup.sql | 34 ++++ src/main/resources/templates/TaskMail.ftl | 45 +++++ src/main/webapp/app/app.component.html | 7 +- src/main/webapp/app/app.component.scss | 6 + src/main/webapp/app/app.component.spec.ts | 16 ++ src/main/webapp/app/app.component.ts | 4 + src/main/webapp/app/app.module.ts | 9 +- .../{tasks => }/default-task.service.spec.ts | 16 +- src/main/webapp/app/mail/enum/duration.ts | 6 + .../mail-settings.component.html | 42 ++++ .../mail-settings.component.scss | 27 +++ .../mail-settings.component.spec.ts | 146 ++++++++++++++ .../mail-settings/mail-settings.component.ts | 79 ++++++++ src/main/webapp/app/mail/mail.module.ts | 33 +++ .../app/mail/model/notification-setting.ts | 7 + .../service/default-notification-service.ts | 21 ++ .../local-notification.service.spec.ts | 16 ++ .../service/local-notification.service.ts | 9 + .../app/mail/service/notification.service.ts | 7 + .../webapp/app/tasks/default-task.service.ts | 4 + .../app/tasks/local-task.service.spec.ts | 19 ++ .../webapp/app/tasks/local-task.service.ts | 11 +- .../tasks/task-list/task-list.component.html | 27 ++- .../task-list/task-list.component.spec.ts | 189 +++++++++++++++++- .../tasks/task-list/task-list.component.ts | 71 ++++++- src/main/webapp/app/tasks/task.service.ts | 2 + src/main/webapp/app/tasks/task.ts | 7 +- src/main/webapp/app/tasks/tasks.module.ts | 15 +- src/main/webapp/styles.scss | 4 + .../tinytask/TinyTaskApplicationTest.java | 3 + .../scheduler/EMailSchedulerTest.java | 66 ++++++ .../DefaultNotificationServiceTest.java | 91 +++++++++ .../service/DefaultTaskServiceTest.java | 48 ++++- .../tinytask/service/EmailServiceTest.java | 73 +++++++ .../tinytask/web/BaseControllerTest.java | 4 + .../web/NotificationControllerTest.java | 78 ++++++++ .../tinytask/web/TaskControllerTest.java | 45 ++++- yarn.lock | 36 ++++ 69 files changed, 1832 insertions(+), 66 deletions(-) create mode 100644 cypress.config.ts delete mode 100644 cypress.json create mode 100644 cypress/fixtures/example.json create mode 100644 cypress/support/commands.ts create mode 100644 cypress/support/component-index.html create mode 100644 cypress/support/component.ts rename src/main/cypress/integration/{spec.ts => .cy.ts} (100%) create mode 100644 src/main/java/com/coyoapp/tinytask/configuration/FreeMarkerConfig.java create mode 100644 src/main/java/com/coyoapp/tinytask/domain/NotificationSetting.java create mode 100644 src/main/java/com/coyoapp/tinytask/dto/NotificationSettingModel.java create mode 100644 src/main/java/com/coyoapp/tinytask/enums/Duration.java create mode 100644 src/main/java/com/coyoapp/tinytask/repository/NotificationSettingRepository.java create mode 100644 src/main/java/com/coyoapp/tinytask/scheduler/EMailScheduler.java create mode 100644 src/main/java/com/coyoapp/tinytask/service/DefaultNotificationService.java create mode 100644 src/main/java/com/coyoapp/tinytask/service/EmailService.java create mode 100644 src/main/java/com/coyoapp/tinytask/service/NotificationService.java create mode 100644 src/main/java/com/coyoapp/tinytask/service/NotificationSettingService.java create mode 100644 src/main/java/com/coyoapp/tinytask/web/NotificationController.java create mode 100644 src/main/resources/db/migration/V2__Setup.sql create mode 100644 src/main/resources/templates/TaskMail.ftl rename src/main/webapp/app/{tasks => }/default-task.service.spec.ts (79%) create mode 100644 src/main/webapp/app/mail/enum/duration.ts create mode 100644 src/main/webapp/app/mail/mail-settings/mail-settings.component.html create mode 100644 src/main/webapp/app/mail/mail-settings/mail-settings.component.scss create mode 100644 src/main/webapp/app/mail/mail-settings/mail-settings.component.spec.ts create mode 100644 src/main/webapp/app/mail/mail-settings/mail-settings.component.ts create mode 100644 src/main/webapp/app/mail/mail.module.ts create mode 100644 src/main/webapp/app/mail/model/notification-setting.ts create mode 100644 src/main/webapp/app/mail/service/default-notification-service.ts create mode 100644 src/main/webapp/app/mail/service/local-notification.service.spec.ts create mode 100644 src/main/webapp/app/mail/service/local-notification.service.ts create mode 100644 src/main/webapp/app/mail/service/notification.service.ts create mode 100644 src/test/java/com/coyoapp/tinytask/scheduler/EMailSchedulerTest.java create mode 100644 src/test/java/com/coyoapp/tinytask/service/DefaultNotificationServiceTest.java create mode 100644 src/test/java/com/coyoapp/tinytask/service/EmailServiceTest.java create mode 100644 src/test/java/com/coyoapp/tinytask/web/NotificationControllerTest.java diff --git a/angular.json b/angular.json index 69791bc2..8dfa6e7c 100644 --- a/angular.json +++ b/angular.json @@ -31,9 +31,12 @@ "src/main/webapp/assets" ], "styles": [ - "src/main/webapp/styles.scss" + "src/main/webapp/styles.scss", + "node_modules/bootstrap/dist/css/bootstrap.min.css" ], - "scripts": [] + "scripts": [ + "node_modules/bootstrap/dist/js/bootstrap.min.js", + ] }, "configurations": { "production": { @@ -100,6 +103,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "codeCoverage": true, "main": "src/main/webapp/test.ts", "polyfills": "src/main/webapp/polyfills.ts", "tsConfig": "src/main/webapp/tsconfig.spec.json", diff --git a/build.gradle b/build.gradle index 5d0de53b..b6ea01c2 100644 --- a/build.gradle +++ b/build.gradle @@ -24,12 +24,15 @@ test { } dependencies { + implementation 'org.freemarker:freemarker:2.3.31' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.flywaydb:flyway-core' implementation 'org.modelmapper:modelmapper:3.2.0' implementation 'org.postgresql:postgresql' + testImplementation 'org.projectlombok:lombok:1.18.26' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 00000000..0b9b0dc1 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + projectId: "sgxb9c", + videosFolder: "src/main/cypress/videos", + screenshotsFolder: "src/main/cypress/screenshots", + fixturesFolder: "src/main/cypress/fixtures", + + e2e: { + // We've imported your old cypress plugins here. + // You may want to clean this up later by importing these. + setupNodeEvents(on, config) { + return require("./src/main/cypress/plugins/index.ts")(on, config); + }, + specPattern: "src/main/cypress/integration/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "src/main/cypress/support/index.ts", + baseUrl: "http://localhost:4200", + }, + + component: { + devServer: { + framework: "angular", + bundler: "webpack", + }, + specPattern: "**/*.cy.ts", + }, +}); diff --git a/cypress.json b/cypress.json deleted file mode 100644 index cf632698..00000000 --- a/cypress.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "integrationFolder": "src/main/cypress/integration", - "supportFile": "src/main/cypress/support/index.ts", - "videosFolder": "src/main/cypress/videos", - "screenshotsFolder": "src/main/cypress/screenshots", - "pluginsFile": "src/main/cypress/plugins/index.ts", - "fixturesFolder": "src/main/cypress/fixtures", - "baseUrl": "http://localhost:4200" -} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000..02e42543 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 00000000..698b01a4 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,37 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } \ No newline at end of file diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html new file mode 100644 index 00000000..ac6e79fd --- /dev/null +++ b/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + \ No newline at end of file diff --git a/cypress/support/component.ts b/cypress/support/component.ts new file mode 100644 index 00000000..9ec1f745 --- /dev/null +++ b/cypress/support/component.ts @@ -0,0 +1,39 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +import { mount } from 'cypress/angular' + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount + } + } +} + +Cypress.Commands.add('mount', mount) + +// Example use: +// cy.mount(MyComponent) \ No newline at end of file diff --git a/package.json b/package.json index bd4ef69c..5764a4c4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "@angular/platform-browser": "~17.3.9", "@angular/platform-browser-dynamic": "~17.3.9", "@angular/router": "~17.3.9", + "@ng-bootstrap/ng-bootstrap": "^16.0.0", + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.2", + "ng-bootstrap": "^1.1.16", + "ngx-bootstrap": "^12.0.0", "rxjs": "~7.8.1", "tslib": "^2.3.0", "uuid": "^9.0.1", diff --git a/src/main/cypress/integration/spec.ts b/src/main/cypress/integration/.cy.ts similarity index 100% rename from src/main/cypress/integration/spec.ts rename to src/main/cypress/integration/.cy.ts diff --git a/src/main/java/com/coyoapp/tinytask/TinyTaskApplication.java b/src/main/java/com/coyoapp/tinytask/TinyTaskApplication.java index 8dac9c94..0e99ef63 100644 --- a/src/main/java/com/coyoapp/tinytask/TinyTaskApplication.java +++ b/src/main/java/com/coyoapp/tinytask/TinyTaskApplication.java @@ -3,11 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = {"com.coyoapp.tinytask"}) public class TinyTaskApplication { - public static void main(String[] args) { SpringApplication.run(TinyTaskApplication.class, args); } - } diff --git a/src/main/java/com/coyoapp/tinytask/configuration/FreeMarkerConfig.java b/src/main/java/com/coyoapp/tinytask/configuration/FreeMarkerConfig.java new file mode 100644 index 00000000..ba916f08 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/configuration/FreeMarkerConfig.java @@ -0,0 +1,17 @@ +package com.coyoapp.tinytask.configuration; + +import freemarker.template.TemplateExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FreeMarkerConfig { + @Bean + public freemarker.template.Configuration freemarkerConfiguration() { + freemarker.template.Configuration configuration = new freemarker.template.Configuration(freemarker.template.Configuration.VERSION_2_3_31); + configuration.setClassForTemplateLoading(this.getClass(), "/templates"); + configuration.setDefaultEncoding("UTF-8"); + configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + return configuration; + } +} diff --git a/src/main/java/com/coyoapp/tinytask/configuration/WebConfig.java b/src/main/java/com/coyoapp/tinytask/configuration/WebConfig.java index b85c09b1..2b003a4f 100644 --- a/src/main/java/com/coyoapp/tinytask/configuration/WebConfig.java +++ b/src/main/java/com/coyoapp/tinytask/configuration/WebConfig.java @@ -3,19 +3,21 @@ import org.modelmapper.ModelMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableWebMvc +@EnableScheduling public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:4200") - .allowedMethods("POST", "GET", "DELETE") + .allowedMethods("POST", "PUT", "GET", "DELETE") .maxAge(3600); } diff --git a/src/main/java/com/coyoapp/tinytask/domain/NotificationSetting.java b/src/main/java/com/coyoapp/tinytask/domain/NotificationSetting.java new file mode 100644 index 00000000..c3dfa890 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/domain/NotificationSetting.java @@ -0,0 +1,34 @@ +package com.coyoapp.tinytask.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.GenericGenerator; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDate; + +@Table(name = "notification_setting") +@Entity +@Setter +@Getter +@EntityListeners(AuditingEntityListener.class) +public class NotificationSetting { + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "id", nullable = false, updatable = false) + private String id; + + private String duration; + + private String email; + + private boolean isActive; + + private boolean isOnlyDueDate; + + private Integer dayBeforeDueDate; + + private LocalDate requestedNotificationDate; +} diff --git a/src/main/java/com/coyoapp/tinytask/domain/Task.java b/src/main/java/com/coyoapp/tinytask/domain/Task.java index be72edca..ca26e0dd 100644 --- a/src/main/java/com/coyoapp/tinytask/domain/Task.java +++ b/src/main/java/com/coyoapp/tinytask/domain/Task.java @@ -1,6 +1,8 @@ package com.coyoapp.tinytask.domain; import java.time.Instant; +import java.time.LocalDate; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -30,4 +32,6 @@ public class Task { @CreatedDate private Instant created; + + private LocalDate dueDate; } diff --git a/src/main/java/com/coyoapp/tinytask/dto/NotificationSettingModel.java b/src/main/java/com/coyoapp/tinytask/dto/NotificationSettingModel.java new file mode 100644 index 00000000..dc6bcb21 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/dto/NotificationSettingModel.java @@ -0,0 +1,33 @@ +package com.coyoapp.tinytask.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationSettingModel { + @NotEmpty + private String duration; + + @NotEmpty + private String email; + + @NotEmpty + private boolean isActive; + + @NotEmpty + private boolean isOnlyDueDate; + + @NotEmpty + private Integer dayBeforeDueDate; + + @NotEmpty + private LocalDate requestedNotificationDate; +} diff --git a/src/main/java/com/coyoapp/tinytask/dto/TaskRequest.java b/src/main/java/com/coyoapp/tinytask/dto/TaskRequest.java index 0df6242b..e3f39e79 100644 --- a/src/main/java/com/coyoapp/tinytask/dto/TaskRequest.java +++ b/src/main/java/com/coyoapp/tinytask/dto/TaskRequest.java @@ -6,6 +6,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; + @Data @Builder @NoArgsConstructor @@ -15,4 +17,5 @@ public class TaskRequest { @NotEmpty private String name; + private LocalDate dueDate; } diff --git a/src/main/java/com/coyoapp/tinytask/dto/TaskResponse.java b/src/main/java/com/coyoapp/tinytask/dto/TaskResponse.java index 95d7112d..9c737c97 100644 --- a/src/main/java/com/coyoapp/tinytask/dto/TaskResponse.java +++ b/src/main/java/com/coyoapp/tinytask/dto/TaskResponse.java @@ -5,6 +5,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; + @Data @Builder @NoArgsConstructor @@ -15,4 +17,6 @@ public class TaskResponse { private String name; + private LocalDate dueDate; + } diff --git a/src/main/java/com/coyoapp/tinytask/enums/Duration.java b/src/main/java/com/coyoapp/tinytask/enums/Duration.java new file mode 100644 index 00000000..6ecf7ba6 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/enums/Duration.java @@ -0,0 +1,18 @@ +package com.coyoapp.tinytask.enums; + +public enum Duration { + EVERY_24H(1), + EVERY_48H(2), + EVERY_72H(3), + ONCE_A_WEEK(7); + + private final int day; + + Duration(int day) { + this.day = day; + } + + public long getDurationInDay() { + return day; + } +} diff --git a/src/main/java/com/coyoapp/tinytask/repository/NotificationSettingRepository.java b/src/main/java/com/coyoapp/tinytask/repository/NotificationSettingRepository.java new file mode 100644 index 00000000..157eb552 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/repository/NotificationSettingRepository.java @@ -0,0 +1,19 @@ +package com.coyoapp.tinytask.repository; + +import com.coyoapp.tinytask.domain.NotificationSetting; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.Optional; + +public interface NotificationSettingRepository extends JpaRepository { + Optional findTopByOrderByIdAsc(); + + @Modifying + @Transactional + @Query("UPDATE NotificationSetting n SET n.requestedNotificationDate = :requestedDate WHERE n.email = :email") + void updateRequestedNotificationDate(String email, LocalDate requestedDate); +} diff --git a/src/main/java/com/coyoapp/tinytask/repository/TaskRepository.java b/src/main/java/com/coyoapp/tinytask/repository/TaskRepository.java index 27c1a99e..cfc0d0d7 100644 --- a/src/main/java/com/coyoapp/tinytask/repository/TaskRepository.java +++ b/src/main/java/com/coyoapp/tinytask/repository/TaskRepository.java @@ -1,7 +1,20 @@ package com.coyoapp.tinytask.repository; import com.coyoapp.tinytask.domain.Task; +import jakarta.transaction.Transactional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.List; public interface TaskRepository extends JpaRepository { + + @Modifying + @Transactional + @Query("UPDATE Task t SET t.dueDate = :dueDate WHERE t.id = :taskId") + void updateDueDateById(String taskId, LocalDate dueDate); + + List findByDueDateBetween(LocalDate startDate, LocalDate endDate); } diff --git a/src/main/java/com/coyoapp/tinytask/scheduler/EMailScheduler.java b/src/main/java/com/coyoapp/tinytask/scheduler/EMailScheduler.java new file mode 100644 index 00000000..ec2edc78 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/scheduler/EMailScheduler.java @@ -0,0 +1,50 @@ +package com.coyoapp.tinytask.scheduler; + +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.coyoapp.tinytask.dto.TaskResponse; +import com.coyoapp.tinytask.enums.Duration; +import com.coyoapp.tinytask.service.NotificationService; +import com.coyoapp.tinytask.service.NotificationSettingService; +import com.coyoapp.tinytask.service.TaskService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.time.LocalDate; +import java.util.List; + +@Service +@Slf4j +public class EMailScheduler { + private final TaskService taskService; + private final NotificationSettingService notificationSettingService; + private final NotificationService notificationService; + public EMailScheduler(TaskService taskService, NotificationSettingService notificationSettingService, NotificationService notificationService) { + this.taskService = taskService; + this.notificationSettingService = notificationSettingService; + this.notificationService = notificationService; + } + + @Scheduled(fixedDelayString = "#{120000}") + public void sendEmailSchedule() { + try { + log.info("sendEmailSchedule() started"); + NotificationSettingModel notificationSetting = notificationSettingService.getNotificationSetting(); + LocalDate today = LocalDate.now(); + boolean allowEmailSending = notificationSetting.isActive() && !notificationSetting.getRequestedNotificationDate().isAfter(today); + log.info("allowEmailSending: " + allowEmailSending); + if (allowEmailSending) { + List tasks = notificationSetting.isOnlyDueDate() ? taskService.getTasksWithinDays(notificationSetting.getDayBeforeDueDate()) : taskService.getTasks(); + log.info("number of total tasks: " + tasks.size()); + if (!CollectionUtils.isEmpty(tasks)) { + notificationService.sendNotificationAboutTasks(notificationSetting.getEmail(), tasks, "TaskMail.ftl"); + notificationSettingService.updateNotificationRequestedDate(notificationSetting.getEmail(), today.plusDays(Duration.valueOf(notificationSetting.getDuration()).getDurationInDay())); + } + } + log.info("sendEmailSchedule() finished."); + } catch (Exception e) { + log.error("sendEmailSchedule() got an error! {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/coyoapp/tinytask/service/DefaultNotificationService.java b/src/main/java/com/coyoapp/tinytask/service/DefaultNotificationService.java new file mode 100644 index 00000000..18440f7e --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/service/DefaultNotificationService.java @@ -0,0 +1,59 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.domain.NotificationSetting; +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.coyoapp.tinytask.enums.Duration; +import com.coyoapp.tinytask.repository.NotificationSettingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultNotificationService implements NotificationSettingService { + + private final NotificationSettingRepository notificationSettingRepository; + private final ModelMapper mapper; + + @Override + @Transactional + public void updateNotificationSetting(NotificationSettingModel notification) { + log.debug("updateNotificationSetting()"); + NotificationSetting notificationSetting = notificationSettingRepository.findTopByOrderByIdAsc() + .orElseThrow(() -> new RuntimeException("Notification setting is not found!")); + if (!notification.getDuration().equals(notificationSetting.getDuration())) { + notificationSetting.setRequestedNotificationDate(LocalDate.now().plusDays(Duration.valueOf(notification.getDuration()).getDurationInDay())); + } + notificationSetting.setEmail(notification.getEmail()); + notificationSetting.setDuration(notification.getDuration()); + notificationSetting.setActive(notification.isActive()); + notificationSetting.setOnlyDueDate(notification.isOnlyDueDate()); + notificationSetting.setDayBeforeDueDate(notification.getDayBeforeDueDate()); + notificationSettingRepository.save(notificationSetting); + } + + @Override + @Transactional(readOnly = true) + public NotificationSettingModel getNotificationSetting() { + log.debug("getNotificationSetting()"); + return notificationSettingRepository.findTopByOrderByIdAsc() + .map(this::transformToDto) + .orElseThrow(() -> new RuntimeException("Notification setting is not found!")); + } + + @Override + public void updateNotificationRequestedDate(String email, LocalDate requestedNotificationDate) { + log.debug("updateNotificationRequestedDate(email={}, requestedNotificationDate={} )", email, requestedNotificationDate); + notificationSettingRepository.updateRequestedNotificationDate(email, requestedNotificationDate); + } + + private NotificationSettingModel transformToDto(NotificationSetting notificationSetting) { + return mapper.map(notificationSetting, NotificationSettingModel.class); + } +} diff --git a/src/main/java/com/coyoapp/tinytask/service/DefaultTaskService.java b/src/main/java/com/coyoapp/tinytask/service/DefaultTaskService.java index 36c94230..ddc965bc 100644 --- a/src/main/java/com/coyoapp/tinytask/service/DefaultTaskService.java +++ b/src/main/java/com/coyoapp/tinytask/service/DefaultTaskService.java @@ -5,7 +5,10 @@ import com.coyoapp.tinytask.dto.TaskResponse; import com.coyoapp.tinytask.exception.TaskNotFoundException; import com.coyoapp.tinytask.repository.TaskRepository; + +import java.time.LocalDate; import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.modelmapper.ModelMapper; @@ -37,8 +40,11 @@ public List getTasks() { return taskRepository.findAll().stream().map(this::transformToResponse).collect(toList()); } - private TaskResponse transformToResponse(Task task) { - return mapper.map(task, TaskResponse.class); + @Override + public List getTasksWithinDays(int days) { + LocalDate today = LocalDate.now(); + LocalDate futureDate = today.plusDays(days); + return taskRepository.findByDueDateBetween(today,futureDate).stream().map(this::transformToResponse).collect(toList()); } @Override @@ -48,6 +54,18 @@ public void deleteTask(String taskId) { taskRepository.delete(getTaskOrThrowException(taskId)); } + @Override + public void updateTask(String taskId, LocalDate dueDate) { + log.debug("updateTask(taskId={})", taskId); + taskRepository.updateDueDateById(taskId, dueDate); + } + + + protected TaskResponse transformToResponse(Task task) { + return mapper.map(task, TaskResponse.class); + } + + private Task getTaskOrThrowException(String taskId) { return taskRepository.findById(taskId).orElseThrow(TaskNotFoundException::new); } diff --git a/src/main/java/com/coyoapp/tinytask/service/EmailService.java b/src/main/java/com/coyoapp/tinytask/service/EmailService.java new file mode 100644 index 00000000..39eb0b44 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/service/EmailService.java @@ -0,0 +1,56 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.dto.TaskResponse; +import freemarker.template.*; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class EmailService implements NotificationService { + + private final JavaMailSender mailSender; + private final Configuration freemarkerConfiguration; + + public EmailService(JavaMailSender mailSender, Configuration freemarkerConfiguration) { + this.mailSender = mailSender; + this.freemarkerConfiguration = freemarkerConfiguration; + } + + @Override + public void sendNotificationAboutTasks(String email, List tasks, String templateFileName) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + + helper.setTo(email); + helper.setSubject("Unfinished Tasks Reminder"); + Map model = new HashMap<>(); + model.put("taskList", tasks); + + Template template = freemarkerConfiguration.getTemplate(templateFileName); + StringWriter stringWriter = new StringWriter(); + template.process(model, stringWriter); + String htmlContent = stringWriter.getBuffer().toString(); + helper.setText(htmlContent, true); + + mailSender.send(message); + log.info("The email has been sent to " + email); + } catch (TemplateException | MessagingException | IOException e) { + log.error("Error while sending email!"); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/coyoapp/tinytask/service/NotificationService.java b/src/main/java/com/coyoapp/tinytask/service/NotificationService.java new file mode 100644 index 00000000..69b6682f --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/service/NotificationService.java @@ -0,0 +1,9 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.dto.TaskResponse; + +import java.util.List; + +public interface NotificationService { + void sendNotificationAboutTasks(String email, List tasks, String templateFileName); +} diff --git a/src/main/java/com/coyoapp/tinytask/service/NotificationSettingService.java b/src/main/java/com/coyoapp/tinytask/service/NotificationSettingService.java new file mode 100644 index 00000000..e1767677 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/service/NotificationSettingService.java @@ -0,0 +1,15 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +public interface NotificationSettingService { + + void updateNotificationSetting(NotificationSettingModel notificationDto); + + NotificationSettingModel getNotificationSetting(); + + void updateNotificationRequestedDate(String email, LocalDate requestedNotificationDate); +} diff --git a/src/main/java/com/coyoapp/tinytask/service/TaskService.java b/src/main/java/com/coyoapp/tinytask/service/TaskService.java index 767d34ff..fd6db647 100644 --- a/src/main/java/com/coyoapp/tinytask/service/TaskService.java +++ b/src/main/java/com/coyoapp/tinytask/service/TaskService.java @@ -2,6 +2,8 @@ import com.coyoapp.tinytask.dto.TaskRequest; import com.coyoapp.tinytask.dto.TaskResponse; + +import java.time.LocalDate; import java.util.List; public interface TaskService { @@ -10,6 +12,10 @@ public interface TaskService { List getTasks(); + List getTasksWithinDays(int days); + void deleteTask(String taskId); + void updateTask(String taskId, LocalDate dueDate); + } diff --git a/src/main/java/com/coyoapp/tinytask/web/NotificationController.java b/src/main/java/com/coyoapp/tinytask/web/NotificationController.java new file mode 100644 index 00000000..9c0d0cc0 --- /dev/null +++ b/src/main/java/com/coyoapp/tinytask/web/NotificationController.java @@ -0,0 +1,30 @@ +package com.coyoapp.tinytask.web; + +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.coyoapp.tinytask.service.NotificationSettingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/notification") +@RequiredArgsConstructor +class NotificationController { + + private final NotificationSettingService notificationSettingService; + + @ResponseStatus(HttpStatus.OK) + @PutMapping + public void updateNotificationSetting(@RequestBody NotificationSettingModel notificationDto) { + log.debug("updateNotificationSettings(notification={})", notificationDto); + notificationSettingService.updateNotificationSetting(notificationDto); + } + + @GetMapping + public NotificationSettingModel getNotificationSetting() { + log.debug("getNotificationSetting()"); + return notificationSettingService.getNotificationSetting(); + } +} diff --git a/src/main/java/com/coyoapp/tinytask/web/TaskController.java b/src/main/java/com/coyoapp/tinytask/web/TaskController.java index b364a942..d560f12e 100644 --- a/src/main/java/com/coyoapp/tinytask/web/TaskController.java +++ b/src/main/java/com/coyoapp/tinytask/web/TaskController.java @@ -3,20 +3,15 @@ import com.coyoapp.tinytask.dto.TaskRequest; import com.coyoapp.tinytask.dto.TaskResponse; import com.coyoapp.tinytask.service.TaskService; + +import java.time.LocalDate; import java.util.List; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @@ -45,4 +40,11 @@ public void deleteTask(@PathVariable String taskId) { log.debug("deleteTask(taskId={})", taskId); taskService.deleteTask(taskId); } + + @ResponseStatus(HttpStatus.OK) + @PutMapping(path = "/{taskId}") + public void updateDueDate(@PathVariable String taskId, @RequestBody String dueDate) { + log.debug("updateDueDate(taskId={})", taskId); + taskService.updateTask(taskId,LocalDate.parse(dueDate)); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 47e7dee1..2fb20022 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,6 +9,18 @@ spring: hibernate: ddl-auto: validate + mail: + host: smtp.gmail.com + port: 587 + username: thismailistestpurposes@gmail.com + password: gzpefsrentenyksn + properties: + mail: + smtp: + auth: true + starttls: + enable: true + logging: level: com.coyoapp.tinytask: DEBUG diff --git a/src/main/resources/db/migration/V2__Setup.sql b/src/main/resources/db/migration/V2__Setup.sql new file mode 100644 index 00000000..a0b1b9ee --- /dev/null +++ b/src/main/resources/db/migration/V2__Setup.sql @@ -0,0 +1,34 @@ +ALTER TABLE task ADD due_date DATE; + +CREATE TABLE IF NOT EXISTS notification_setting ( + id VARCHAR(36) NOT NULL, + duration VARCHAR(36) NOT NULL, + email VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL, + is_only_due_date BOOLEAN NOT NULL, + day_before_due_date INT NOT NULL, + requested_notification_date DATE NOT NULL, + PRIMARY KEY (id) +); + + +DROP TRIGGER IF EXISTS check_single_row_trigger ON notification_setting; + +CREATE OR REPLACE FUNCTION check_single_row() + RETURNS TRIGGER AS $$ +BEGIN + IF (SELECT COUNT(*) FROM notification_setting) > 1 THEN + RAISE EXCEPTION 'Only one row is allowed in the notification_setting table'; +END IF; +RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER check_single_row_trigger + AFTER INSERT ON notification_setting + FOR EACH ROW EXECUTE FUNCTION check_single_row(); + +INSERT INTO notification_setting (id, duration, email, is_active, is_only_due_date, day_before_due_date, requested_notification_date) +SELECT '123456', 'EVERY_24H', 'test@gmail.com', true, false, 1, '2024-01-01' +WHERE NOT EXISTS (SELECT 1 FROM notification_setting); diff --git a/src/main/resources/templates/TaskMail.ftl b/src/main/resources/templates/TaskMail.ftl new file mode 100644 index 00000000..74d7f338 --- /dev/null +++ b/src/main/resources/templates/TaskMail.ftl @@ -0,0 +1,45 @@ + + + + + + + +

+ There are unfinished tasks. You can find them in the list below. +

+

+ + + + + + + + <#list taskList as _task> + + + + + + + +
Task IdTask NameDue Date
${_task.id}${_task.name}${_task.dueDate!}
+ + diff --git a/src/main/webapp/app/app.component.html b/src/main/webapp/app/app.component.html index 42185222..d5fb4699 100644 --- a/src/main/webapp/app/app.component.html +++ b/src/main/webapp/app/app.component.html @@ -4,12 +4,15 @@ - +
+ + +
- +
diff --git a/src/main/webapp/app/app.component.scss b/src/main/webapp/app/app.component.scss index ff9bbbda..c27015d5 100644 --- a/src/main/webapp/app/app.component.scss +++ b/src/main/webapp/app/app.component.scss @@ -28,4 +28,10 @@ justify-content: space-between; padding: 16px 0; } + + .task-mail { + display: flex; + align-items: center; + gap: 20px; + } } diff --git a/src/main/webapp/app/app.component.spec.ts b/src/main/webapp/app/app.component.spec.ts index c31e8ff8..ff0687af 100644 --- a/src/main/webapp/app/app.component.spec.ts +++ b/src/main/webapp/app/app.component.spec.ts @@ -78,4 +78,20 @@ describe('AppComponent', () => { expect(taskService.getAll).toHaveBeenCalled(); }); })); + + it('should reload the tasks after task update', fakeAsync(() => { + // given + const tasks = ['test1', 'test2'] as any as Task[]; + const tasks$ = of(tasks); + taskService.getAll.and.returnValue(tasks$); + + // when + component.updated(); + tick(); + // then + component.tasks$.subscribe(t => { + expect(t).toEqual(tasks); + expect(taskService.getAll).toHaveBeenCalled(); + }); + })); }); diff --git a/src/main/webapp/app/app.component.ts b/src/main/webapp/app/app.component.ts index dfbdb2d8..2ff8b8e1 100644 --- a/src/main/webapp/app/app.component.ts +++ b/src/main/webapp/app/app.component.ts @@ -29,4 +29,8 @@ export class AppComponent implements OnInit { deleted(): void { this.fetch.next(); } + + updated(): void { + this.fetch.next(); + } } diff --git a/src/main/webapp/app/app.module.ts b/src/main/webapp/app/app.module.ts index c81453f7..f6a862e4 100644 --- a/src/main/webapp/app/app.module.ts +++ b/src/main/webapp/app/app.module.ts @@ -13,6 +13,9 @@ import { BASE_URL } from './app.tokens'; import { DefaultTaskService } from './tasks/default-task.service'; import { LocalTaskService } from './tasks/local-task.service'; import { TasksModule } from './tasks/tasks.module'; +import {MailModule} from "./mail/mail.module"; +import {DefaultNotificationService} from "./mail/service/default-notification-service"; +import {LocalNotificationService} from "./mail/service/local-notification.service"; @NgModule({ declarations: [AppComponent], @@ -24,11 +27,13 @@ import { TasksModule } from './tasks/tasks.module'; MatInputModule, MatIconModule, MatToolbarModule, - TasksModule + TasksModule, + MailModule, ], providers: [ { provide: BASE_URL, useValue: 'http://localhost:8080' }, - { provide: 'TaskService', useClass: (environment.useLocalStorage) ? LocalTaskService : DefaultTaskService } + { provide: 'TaskService', useClass: (environment.useLocalStorage) ? LocalTaskService : DefaultTaskService }, + { provide: 'NotificationService', useClass: (environment.useLocalStorage) ? LocalNotificationService : DefaultNotificationService }, ], bootstrap: [AppComponent] }) diff --git a/src/main/webapp/app/tasks/default-task.service.spec.ts b/src/main/webapp/app/default-task.service.spec.ts similarity index 79% rename from src/main/webapp/app/tasks/default-task.service.spec.ts rename to src/main/webapp/app/default-task.service.spec.ts index f7485b51..56b67ff8 100644 --- a/src/main/webapp/app/tasks/default-task.service.spec.ts +++ b/src/main/webapp/app/default-task.service.spec.ts @@ -1,8 +1,8 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { BASE_URL } from '../app.tokens'; +import { BASE_URL } from './app.tokens'; -import { DefaultTaskService } from './default-task.service'; +import { DefaultTaskService } from './tasks/default-task.service'; describe('DefaultTaskService', () => { let httpTestingController: HttpTestingController; @@ -61,4 +61,16 @@ describe('DefaultTaskService', () => { // finally req.flush({}); }); + + it('should update task', () => { + // when + taskService.updateDueDate('id123', '01-01-2024').subscribe(); + + // then + const req = httpTestingController.expectOne(request => request.url === 'http://backend.tld/tasks/id123'); + expect(req.request.method).toEqual('PUT'); + + // finally + req.flush({}); + }); }); diff --git a/src/main/webapp/app/mail/enum/duration.ts b/src/main/webapp/app/mail/enum/duration.ts new file mode 100644 index 00000000..89f8924e --- /dev/null +++ b/src/main/webapp/app/mail/enum/duration.ts @@ -0,0 +1,6 @@ +export enum Duration { + EVERY_24H = 'Every 24 Hours', + EVERY_48H = 'Every 48 Hours', + EVERY_72H = 'Every 72 Hours', + ONCE_A_WEEK = 'Once A Week' +} diff --git a/src/main/webapp/app/mail/mail-settings/mail-settings.component.html b/src/main/webapp/app/mail/mail-settings/mail-settings.component.html new file mode 100644 index 00000000..7d6d2162 --- /dev/null +++ b/src/main/webapp/app/mail/mail-settings/mail-settings.component.html @@ -0,0 +1,42 @@ +
+ settings + Task Notification Settings +
+ + + + + + + diff --git a/src/main/webapp/app/mail/mail-settings/mail-settings.component.scss b/src/main/webapp/app/mail/mail-settings/mail-settings.component.scss new file mode 100644 index 00000000..e32d9442 --- /dev/null +++ b/src/main/webapp/app/mail/mail-settings/mail-settings.component.scss @@ -0,0 +1,27 @@ +.task-notification-settings { + display: flex; + align-items: center; + margin-bottom: 17px; + cursor: pointer; + font-size: 15px; +} + +.task-notification-settings span { + margin-left: 8px; + font-weight: bold; +} + +.task-notification-settings:hover span { + text-decoration: underline; + margin-left: 8px; + font-weight: bold; +} + +.form-container { + display: flex; + align-items: center; +} + +.duration-field { + margin-right: 10px; +} diff --git a/src/main/webapp/app/mail/mail-settings/mail-settings.component.spec.ts b/src/main/webapp/app/mail/mail-settings/mail-settings.component.spec.ts new file mode 100644 index 00000000..eccaa170 --- /dev/null +++ b/src/main/webapp/app/mail/mail-settings/mail-settings.component.spec.ts @@ -0,0 +1,146 @@ +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {ReactiveFormsModule} from '@angular/forms'; +import {of} from 'rxjs'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {MailSettingsComponent} from './mail-settings.component'; +import {NotificationService} from '../service/notification.service'; +import {NotificationSetting} from '../model/notification-setting'; + +describe('MailSettingsComponent', () => { + let component: MailSettingsComponent; + let fixture: ComponentFixture; + let notificationService: jasmine.SpyObj; + let modalService: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + const notificationServiceSpy = jasmine.createSpyObj('NotificationService', ['getNotificationSetting', 'updateNotificationSetting']); + const modalServiceSpy = jasmine.createSpyObj('NgbModal', ['open', 'dismissAll']); + + TestBed.configureTestingModule({ + declarations: [MailSettingsComponent], + imports: [ReactiveFormsModule], + providers: [ + {provide: 'NotificationService', useValue: notificationServiceSpy}, + {provide: NgbModal, useValue: modalServiceSpy} + ] + }) + .compileComponents(); + + notificationService = TestBed.get('NotificationService') as jasmine.SpyObj; + modalService = TestBed.inject(NgbModal) as jasmine.SpyObj; + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MailSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should populate form with notification settings', () => { + const mockSetting: NotificationSetting = { + email: 'test@gmail.com', + duration: 'EVERY_24H', + active: true, + onlyDueDate: false, + dayBeforeDueDate: 2 + }; + + notificationService.getNotificationSetting.and.returnValue(of(mockSetting)); + + component.getNotificationSetting(); + + expect(component.notificationSetting).toEqual(mockSetting); + expect(component.emailForm.value).toEqual({ + email: 'test@gmail.com', + duration: 'Every 24 Hours', + notificationsEnabled: true, + dueDateEnabled: false, + dueDateDays: 2 + }); + }); + + it('should open modal and populate form', () => { + const mockSetting: NotificationSetting = { + email: 'test@gmail.com', + duration: 'EVERY_24H', + active: true, + onlyDueDate: false, + dayBeforeDueDate: 2 + }; + + notificationService.getNotificationSetting.and.returnValue(of(mockSetting)); + + const content = {}; // Mock content + component.openEmailSettingsPopUp({content}); + + expect(modalService.open).toHaveBeenCalledWith(content, {size: 'xl', backdrop: 'static', centered: true}); + expect(component.notificationSetting).toEqual(mockSetting); + }); + + it('should update notification settings and close modal when form is valid', () => { + // given + const mockFormValue = { + email: 'test@gmail.com', + duration: 'Every 24 Hours', + notificationsEnabled: true, + dueDateEnabled: false, + dueDateDays: 2 + }; + component.emailForm.setValue(mockFormValue); + + notificationService.updateNotificationSetting.and.returnValue(of(void 0)); + + // when + component.onSubmit(); + + // then + const expectedSetting: NotificationSetting = { + email: 'test@gmail.com', + duration: 'EVERY_24H', + active: true, + onlyDueDate: false, + dayBeforeDueDate: 2 + }; + expect(notificationService.updateNotificationSetting).toHaveBeenCalledWith( + jasmine.objectContaining({ + email: expectedSetting.email, + duration: expectedSetting.duration, + active: expectedSetting.active, + onlyDueDate: expectedSetting.onlyDueDate, + dayBeforeDueDate: expectedSetting.dayBeforeDueDate + })); + expect(modalService.dismissAll).toHaveBeenCalled(); + }); + + it('should show alert when form is not valid', () => { + // given + const mockFormValue = { + email: 'test@gmail.com', + duration: 'EVERY_24H', + notificationsEnabled: true, + dueDateEnabled: false, + dueDateDays: -1 + }; + component.emailForm.setValue(mockFormValue); + + spyOn(window, 'alert'); + + // when + component.onSubmit(); + + // then + expect(window.alert).toHaveBeenCalledWith('Please fill out the form correctly.'); + }); + + it('should reset dueDateDays when dueDateEnabled is false', () => { + component.ngAfterViewInit(); + + component.emailForm.get('dueDateEnabled')?.setValue(false); + + expect(component.emailForm.get('dueDateDays')?.value).toBe(1); + }); +}); diff --git a/src/main/webapp/app/mail/mail-settings/mail-settings.component.ts b/src/main/webapp/app/mail/mail-settings/mail-settings.component.ts new file mode 100644 index 00000000..6efb1d05 --- /dev/null +++ b/src/main/webapp/app/mail/mail-settings/mail-settings.component.ts @@ -0,0 +1,79 @@ +import {Component, Inject, ViewChild} from '@angular/core'; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {FormControl, FormGroup, Validators} from "@angular/forms"; +import {NotificationService} from "../service/notification.service"; +import {NotificationSetting} from "../model/notification-setting"; +import {distinctUntilChanged} from "rxjs"; +import {Duration} from "../enum/duration"; + +@Component({ + selector: 'tiny-mail-settings', + templateUrl: './mail-settings.component.html', + styleUrl: './mail-settings.component.scss', +}) +export class MailSettingsComponent { + + notificationSetting: NotificationSetting = new NotificationSetting(); + durationEnum = Duration; + durations: string[] = []; + emailForm = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + duration: new FormControl('', Validators.required), + notificationsEnabled: new FormControl(false), + dueDateEnabled: new FormControl(false), + dueDateDays: new FormControl(1) + }); + + constructor(private modalService: NgbModal, @Inject('NotificationService') private notificationService: NotificationService) { + this.durations = Object.values(this.durationEnum); + } + + getNotificationSetting() { + this.notificationService.getNotificationSetting().subscribe(res => { + this.notificationSetting = res + this.populateForm(); + }); + } + + populateForm() { + if (this.notificationSetting) { + this.emailForm.patchValue({ + email: this.notificationSetting.email, + duration: Duration[this.notificationSetting.duration as keyof typeof Duration], + notificationsEnabled: this.notificationSetting.active, + dueDateEnabled: this.notificationSetting.onlyDueDate, + dueDateDays: this.notificationSetting.dayBeforeDueDate + }); + } + } + + openEmailSettingsPopUp({content}: { content: any }) { + this.getNotificationSetting(); + this.modalService.open(content, {size: 'xl', backdrop: 'static', centered: true}); + } + + onSubmit(): void { + if (this.emailForm.valid && this.emailForm.value.duration !== undefined && + (this.emailForm.value.dueDateDays === undefined || this.emailForm.value.dueDateDays === null || this.emailForm.value.dueDateDays > 0)) { + this.notificationSetting.email = this.emailForm.value.email!; + this.notificationSetting.duration = Object.keys(Duration)[Object.values(Duration).indexOf(this.emailForm.value.duration as unknown as Duration)]; + this.notificationSetting.active = this.emailForm.value.notificationsEnabled!; + this.notificationSetting.onlyDueDate = this.emailForm.value.dueDateEnabled!; + this.notificationSetting.dayBeforeDueDate = this.emailForm.value.dueDateDays!; + this.notificationService.updateNotificationSetting(this.notificationSetting).subscribe(); + this.modalService.dismissAll(); + } else { + alert('Please fill out the form correctly.'); + } + } + + ngAfterViewInit() { + this.emailForm.get('dueDateEnabled')?.valueChanges + .pipe(distinctUntilChanged()) + .subscribe((enabled: boolean | null) => { + if (enabled === false) { + this.emailForm.get('dueDateDays')?.setValue(1); + } + }); + } +} diff --git a/src/main/webapp/app/mail/mail.module.ts b/src/main/webapp/app/mail/mail.module.ts new file mode 100644 index 00000000..603bd026 --- /dev/null +++ b/src/main/webapp/app/mail/mail.module.ts @@ -0,0 +1,33 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {MailSettingsComponent} from "./mail-settings/mail-settings.component"; +import {MatError, MatFormField, MatLabel, MatSuffix} from "@angular/material/form-field"; +import {MatInput} from "@angular/material/input"; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {MatOption, MatSelect} from "@angular/material/select"; +import {MatButton} from "@angular/material/button"; +import {MatCheckbox} from "@angular/material/checkbox"; + +@NgModule({ + declarations: [MailSettingsComponent], + imports: [ + CommonModule, + FormsModule, + MatButton, + MatCheckbox, + MatIconModule, + MatError, + MatFormField, + MatInput, + MatLabel, + MatOption, + MatSelect, + MatSuffix, + ReactiveFormsModule, + ], + exports: [MailSettingsComponent], + bootstrap: [MailSettingsComponent] +}) +export class MailModule { +} diff --git a/src/main/webapp/app/mail/model/notification-setting.ts b/src/main/webapp/app/mail/model/notification-setting.ts new file mode 100644 index 00000000..a3b871f4 --- /dev/null +++ b/src/main/webapp/app/mail/model/notification-setting.ts @@ -0,0 +1,7 @@ +export class NotificationSetting { + duration?: string; + email?: string; + active?: boolean; + onlyDueDate?: boolean + dayBeforeDueDate?: number +} diff --git a/src/main/webapp/app/mail/service/default-notification-service.ts b/src/main/webapp/app/mail/service/default-notification-service.ts new file mode 100644 index 00000000..3330dbc7 --- /dev/null +++ b/src/main/webapp/app/mail/service/default-notification-service.ts @@ -0,0 +1,21 @@ +import {Inject, Injectable} from "@angular/core"; +import {NotificationService} from "./notification.service"; +import {Observable} from "rxjs"; +import {NotificationSetting} from "../model/notification-setting"; +import {HttpClient} from "@angular/common/http"; +import {BASE_URL} from "../../app.tokens"; + +@Injectable() +export class DefaultNotificationService implements NotificationService { + + constructor(private http: HttpClient, @Inject(BASE_URL) private baseUrl: string) { + } + getNotificationSetting(): Observable { + return this.http.get(this.baseUrl + '/notification'); + } + + updateNotificationSetting(notificationSetting: NotificationSetting): Observable { + return this.http.put(this.baseUrl + '/notification', notificationSetting) + } + +} diff --git a/src/main/webapp/app/mail/service/local-notification.service.spec.ts b/src/main/webapp/app/mail/service/local-notification.service.spec.ts new file mode 100644 index 00000000..d504680f --- /dev/null +++ b/src/main/webapp/app/mail/service/local-notification.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { LocalNotificationService } from './local-notification.service'; + +describe('LocalNotificationService', () => { + let service: LocalNotificationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(LocalNotificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/main/webapp/app/mail/service/local-notification.service.ts b/src/main/webapp/app/mail/service/local-notification.service.ts new file mode 100644 index 00000000..ae5bcd17 --- /dev/null +++ b/src/main/webapp/app/mail/service/local-notification.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalNotificationService { + + constructor() { } +} diff --git a/src/main/webapp/app/mail/service/notification.service.ts b/src/main/webapp/app/mail/service/notification.service.ts new file mode 100644 index 00000000..ef62c128 --- /dev/null +++ b/src/main/webapp/app/mail/service/notification.service.ts @@ -0,0 +1,7 @@ +import {Observable} from "rxjs"; +import {NotificationSetting} from "../model/notification-setting"; + +export interface NotificationService { + getNotificationSetting(): Observable; + updateNotificationSetting(notificationSetting: NotificationSetting): Observable; +} diff --git a/src/main/webapp/app/tasks/default-task.service.ts b/src/main/webapp/app/tasks/default-task.service.ts index 39726b61..d6535d33 100644 --- a/src/main/webapp/app/tasks/default-task.service.ts +++ b/src/main/webapp/app/tasks/default-task.service.ts @@ -20,6 +20,10 @@ export class DefaultTaskService implements TaskService { return this.http.delete(this.baseUrl + '/tasks/' + id); } + updateDueDate(id: string, dueDate: string): Observable { + return this.http.put(this.baseUrl + '/tasks/' + id, dueDate); + } + getAll(): Observable { return this.http.get(this.baseUrl + '/tasks'); } diff --git a/src/main/webapp/app/tasks/local-task.service.spec.ts b/src/main/webapp/app/tasks/local-task.service.spec.ts index d737ff5a..8e998dba 100644 --- a/src/main/webapp/app/tasks/local-task.service.spec.ts +++ b/src/main/webapp/app/tasks/local-task.service.spec.ts @@ -7,6 +7,7 @@ describe('LocalTaskService', () => { const id = 'de4f576e-d1b5-488a-8c77-63d4c8726909'; const name = 'Doing the do!'; const mockTask = `{"id":"${id}","name":"${name}"}`; + const dueDate = '01-01-2025'; let taskService: LocalTaskService; let localStorageGetSpy: jasmine.Spy; @@ -71,6 +72,7 @@ describe('LocalTaskService', () => { expect(localStorage.setItem).toHaveBeenCalled(); }); + it('should handle unknown task on deletion', () => { // when taskService.delete('unknown'); @@ -78,4 +80,21 @@ describe('LocalTaskService', () => { // then expect(localStorage.setItem).not.toHaveBeenCalled(); }); + + it('should update dueDate of task from local storage', () => { + // when + taskService.updateDueDate(id, dueDate); + + // then + expect(localStorage.getItem).toHaveBeenCalled(); + expect(localStorage.setItem).toHaveBeenCalled(); + }); + + it('should handle unknown task from local storage', () => { + // when + taskService.updateDueDate('unknown', 'unknown'); + + // then + expect(localStorage.getItem).toHaveBeenCalled(); + }); }); diff --git a/src/main/webapp/app/tasks/local-task.service.ts b/src/main/webapp/app/tasks/local-task.service.ts index 96b6e3e4..ade95a82 100644 --- a/src/main/webapp/app/tasks/local-task.service.ts +++ b/src/main/webapp/app/tasks/local-task.service.ts @@ -7,6 +7,15 @@ import { TaskService } from './task.service'; @Injectable() export class LocalTaskService implements TaskService { + updateDueDate(id: string, dueDate: string): Observable { + const tasks = this.readTasks(); + const index = tasks.findIndex(task => task.id === id); + if (index !== -1) { + tasks[index].dueDate = dueDate; + this.writeTasks(tasks); + } + return of(void 1); + } private static readonly STORAGE_KEY: string = 'tiny.tasks'; @@ -16,7 +25,7 @@ export class LocalTaskService implements TaskService { create(name: string): Observable { const tasks = this.readTasks(); - const task = {id: uuid(), name}; + const task = {id: uuid(), name, dueDate: '01-01-2029'}; tasks.push(task); this.writeTasks(tasks); return of(task); diff --git a/src/main/webapp/app/tasks/task-list/task-list.component.html b/src/main/webapp/app/tasks/task-list/task-list.component.html index d32c5a6a..189aa4f6 100644 --- a/src/main/webapp/app/tasks/task-list/task-list.component.html +++ b/src/main/webapp/app/tasks/task-list/task-list.component.html @@ -2,8 +2,31 @@ assignment {{task.name}} + {{ task.dueDate ? formatDate(task.dueDate) : 'No Due Date' }} + + + delete + + + + + + + diff --git a/src/main/webapp/app/tasks/task-list/task-list.component.spec.ts b/src/main/webapp/app/tasks/task-list/task-list.component.spec.ts index 7546fa61..0ec209ca 100644 --- a/src/main/webapp/app/tasks/task-list/task-list.component.spec.ts +++ b/src/main/webapp/app/tasks/task-list/task-list.component.spec.ts @@ -1,29 +1,41 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { of } from 'rxjs'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {of} from 'rxjs'; -import { TaskService } from '../task.service'; -import { TaskListComponent } from './task-list.component'; +import {TaskService} from '../task.service'; +import {TaskListComponent} from './task-list.component'; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {Task} from "../task"; +import {FormBuilder, ReactiveFormsModule} from "@angular/forms"; +import {EventEmitter} from "@angular/core"; describe('TaskListComponent', () => { let component: TaskListComponent; let fixture: ComponentFixture; let taskService: jasmine.SpyObj; + let modalService: jasmine.SpyObj + beforeEach(waitForAsync(() => { - taskService = jasmine.createSpyObj('taskService', ['delete']); + taskService = jasmine.createSpyObj('TaskService', ['delete', 'updateDueDate']); + modalService = jasmine.createSpyObj('NgbModal', ['dismissAll', 'open']); + TestBed.configureTestingModule({ declarations: [TaskListComponent], - providers: [{ - provide: 'TaskService', - useValue: taskService - }] - }).overrideTemplate(TaskListComponent, '') + imports: [ReactiveFormsModule], + providers: [ + {provide: 'TaskService', useValue: taskService}, + {provide: 'NgbModal', useValue: modalService}, + FormBuilder + ] + }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(TaskListComponent); component = fixture.componentInstance; + component.updated = new EventEmitter(); + spyOn(component.updated, 'emit'); fixture.detectChanges(); }); @@ -53,4 +65,161 @@ describe('TaskListComponent', () => { // then expect(deleteEmitter).toHaveBeenCalledWith({id: 'id', name: 'My task'}); }); + + describe('updateDueDate function', () => { + it('should update due date and emit updated task', () => { + // given + const taskId: string = 'task-id'; + const dueDate: string = '2030-06-10'; + const initialTask = new Task(); + initialTask.id = taskId; + initialTask.dueDate = dueDate; + component.selectedTask = initialTask; + + const formValue = { dueDate: dueDate }; + component.form.setValue(formValue); + const updatedTask = new Task(); + updatedTask.id = taskId; + updatedTask.dueDate = dueDate; + + taskService.updateDueDate.and.returnValue(of(void 0)); + + // when + component.updateDueDate(); + + // then + expect(taskService.updateDueDate).toHaveBeenCalledWith(taskId, dueDate); + expect(component.updated.emit).toHaveBeenCalledWith(updatedTask); + expect(component.selectedTask.id).toBe(taskId); + expect(component.selectedTask.dueDate).toBe(dueDate); + }); + + it('should show alert when due date is past', () => { + // given + const dueDate = '2020-06-10'; + const formValue = {dueDate: dueDate}; + component.form.setValue(formValue); + + spyOn(window, 'alert'); + + // when + component.updateDueDate(); + + // then + expect(window.alert).toHaveBeenCalledWith('Please fill out the form correctly.'); + }); + }); + + describe('stringToDate function', () => { + it('should return current date if input is empty string', () => { + // given + const emptyDateString = ''; + + // when + const result = component.stringToDate(emptyDateString); + + // then + expect(result).toEqual(new Date().toISOString().split('T')[0]); + }); + + it('should return current date if input is null', () => { + // given + + // when + const result = component.stringToDate(''); + + // then + expect(result).toEqual(new Date().toISOString().split('T')[0]); + }); + + it('should return formatted date if input is in valid format with < 10 month and day', () => { + // given + const validDateString = [2024, 6, 3]; + const expectedDate = '2024-06-03'; + + // when + const result = component.stringToDate(validDateString); + + // then + expect(result).toEqual(expectedDate); + }); + + it('should return formatted date if input is in valid format with > 10 month and day', () => { + // given + const validDateString = [2024, 11, 11]; + const expectedDate = '2024-11-11'; + + // when + const result = component.stringToDate(validDateString); + + // then + expect(result).toEqual(expectedDate); + }); + }); + + describe('isPastDate', () => { + it('should return true if selected date is in the past', () => { + // given + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 1); + const pastDateString = pastDate.toISOString(); + // when + const result = component.isPastDate(pastDateString); + + // then + expect(result).toBe(true); + }); + + it('should return false if selected date is today or in the future', () => { + // given + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 1); + const futureDateString = futureDate.toISOString(); + + // when + const result = component.isPastDate(futureDateString); + + // then + expect(result).toBe(false); + }); + }); + + describe('formatDate', () => { + it('should format date correctly', () => { + // given + const date = new Date('2024-06-03'); + const expectedFormattedDate = '03/06/2024'; + + // when + const formattedDate = component.formatDate(date); + + // then + expect(formattedDate).toBe(expectedFormattedDate); + }); + + it('should return "No Due Date Assigned" if input date is falsy', () => { + // given + const date = null; + + // when + const formattedDate = component.formatDate(date); + + // then + expect(formattedDate).toBe('No Due Date Assigned'); + }); + }); + + it('should open due date popup with correct values', () => { + // given + const task: Task = {id: 'task-id', dueDate: ''}; + const content = '
'; + const expectedDueDate = new Date().toISOString().split('T')[0]; + + // when + component.openDueDatePopUp(task, {content}); + + // then + expect(component.selectedTask).toEqual(task); + expect(component.form.value.dueDate).toBe(expectedDueDate); + }); }); diff --git a/src/main/webapp/app/tasks/task-list/task-list.component.ts b/src/main/webapp/app/tasks/task-list/task-list.component.ts index 3a11ebc2..df4ec1ae 100644 --- a/src/main/webapp/app/tasks/task-list/task-list.component.ts +++ b/src/main/webapp/app/tasks/task-list/task-list.component.ts @@ -1,7 +1,9 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, Output } from '@angular/core'; +import {ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, Output} from '@angular/core'; -import { Task } from '../task'; -import { TaskService } from '../task.service'; +import {Task} from '../task'; +import {TaskService} from '../task.service'; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {FormBuilder, FormGroup} from "@angular/forms"; /** * A list of tiny tasks. @@ -18,11 +20,70 @@ export class TaskListComponent { @Output() deleted: EventEmitter = new EventEmitter(); - constructor(@Inject('TaskService') private taskService: TaskService) {} + @Output() updated: EventEmitter = new EventEmitter(); + + selectedTask: Task = new Task(); + + form: FormGroup; + + constructor(@Inject('TaskService') private taskService: TaskService, private modalService: NgbModal, private formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + dueDate: '' + }); + } delete(task: Task): void { - this.taskService.delete(task.id).subscribe(() => { + this.taskService.delete(task.id!).subscribe(() => { this.deleted.emit(task); }); } + + updateDueDate() { + if (this.form.valid && this.form.value.dueDate !== undefined && !this.isPastDate(this.form.value.dueDate)) { + this.selectedTask.dueDate = this.form.value.dueDate; + this.taskService.updateDueDate(this.selectedTask.id!, this.selectedTask.dueDate!).subscribe(() => { + this.updated.emit(this.selectedTask); + this.modalService.dismissAll(); + }); + } else { + alert('Please fill out the form correctly.'); + } + } + + openDueDatePopUp(task: Task, {content}: { content: any }) { + this.selectedTask = task; + this.form.patchValue({ + dueDate: this.stringToDate(this.selectedTask.dueDate!) + }); + this.modalService.open(content, {size: 'md', backdrop: 'static', centered: true}); + } + + stringToDate(date: any) { + if (date!! && date !== '') { + const dateArray = date; + const year = Number(dateArray[0]); + const month = Number(dateArray[1]) < 10 ? '0' + dateArray[1] : dateArray[1]; + const day = Number(dateArray[2]) < 10 ? '0' + dateArray[2] : dateArray[2]; + return `${year}-${month}-${day}`; + } else { + return new Date().toISOString().split('T')[0]; + } + } + + isPastDate(date: string): boolean { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const selectedDate = new Date(date); + return selectedDate <= yesterday; + } + + formatDate(date: any): string { + if (date) { + const formattedDate = new Date(date); + return formattedDate.toLocaleDateString('en-GB'); + } else { + return 'No Due Date Assigned'; + } + } } diff --git a/src/main/webapp/app/tasks/task.service.ts b/src/main/webapp/app/tasks/task.service.ts index 9ed37439..6b7f3eac 100644 --- a/src/main/webapp/app/tasks/task.service.ts +++ b/src/main/webapp/app/tasks/task.service.ts @@ -29,4 +29,6 @@ export interface TaskService { * @returns an empty `Observable` */ delete(id: string): Observable; + + updateDueDate(id: string, dueDate: string): Observable; } diff --git a/src/main/webapp/app/tasks/task.ts b/src/main/webapp/app/tasks/task.ts index 81947a34..34d3d5fa 100644 --- a/src/main/webapp/app/tasks/task.ts +++ b/src/main/webapp/app/tasks/task.ts @@ -1,7 +1,8 @@ /** * A tiny task. */ -export interface Task { - id: string; - name: string; +export class Task { + id?: string; + name?: string; + dueDate?: string; } diff --git a/src/main/webapp/app/tasks/tasks.module.ts b/src/main/webapp/app/tasks/tasks.module.ts index 3dacdd74..9841502f 100644 --- a/src/main/webapp/app/tasks/tasks.module.ts +++ b/src/main/webapp/app/tasks/tasks.module.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -8,16 +8,27 @@ import { MatListModule } from '@angular/material/list'; import { TaskFormComponent } from './task-form/task-form.component'; import { TaskListComponent } from './task-list/task-list.component'; +import {MatDatepicker, MatDatepickerInput, MatDatepickerToggle} from "@angular/material/datepicker"; +import {MatCheckbox} from "@angular/material/checkbox"; +import {MatOption} from "@angular/material/autocomplete"; +import {MatSelect} from "@angular/material/select"; @NgModule({ declarations: [TaskFormComponent, TaskListComponent], imports: [ CommonModule, + FormsModule, ReactiveFormsModule, MatButtonModule, MatIconModule, MatInputModule, - MatListModule + MatListModule, + MatDatepicker, + MatDatepickerToggle, + MatDatepickerInput, + MatCheckbox, + MatOption, + MatSelect, ], exports: [TaskFormComponent, TaskListComponent] }) diff --git a/src/main/webapp/styles.scss b/src/main/webapp/styles.scss index 222b5329..24b7fe0c 100644 --- a/src/main/webapp/styles.scss +++ b/src/main/webapp/styles.scss @@ -6,3 +6,7 @@ body { padding: 0; background: #ebecf0; } + +.cdk-overlay-container, .cdk-overlay-pane { + z-index: 1600 !important; +} diff --git a/src/test/java/com/coyoapp/tinytask/TinyTaskApplicationTest.java b/src/test/java/com/coyoapp/tinytask/TinyTaskApplicationTest.java index f28ac8cb..3eb4e3ef 100644 --- a/src/test/java/com/coyoapp/tinytask/TinyTaskApplicationTest.java +++ b/src/test/java/com/coyoapp/tinytask/TinyTaskApplicationTest.java @@ -3,11 +3,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import static org.junit.jupiter.api.Assertions.assertTrue; + @SpringBootTest public class TinyTaskApplicationTest { @Test void contextLoads() { + assertTrue(true); } } diff --git a/src/test/java/com/coyoapp/tinytask/scheduler/EMailSchedulerTest.java b/src/test/java/com/coyoapp/tinytask/scheduler/EMailSchedulerTest.java new file mode 100644 index 00000000..e54d3472 --- /dev/null +++ b/src/test/java/com/coyoapp/tinytask/scheduler/EMailSchedulerTest.java @@ -0,0 +1,66 @@ +package com.coyoapp.tinytask.scheduler; + +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.coyoapp.tinytask.dto.TaskResponse; +import com.coyoapp.tinytask.enums.Duration; +import com.coyoapp.tinytask.service.NotificationService; +import com.coyoapp.tinytask.service.NotificationSettingService; +import com.coyoapp.tinytask.service.TaskService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EMailSchedulerTest { + @Mock + private NotificationSettingService notificationSettingService; + + @Mock + private TaskService taskService; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private EMailScheduler objectUnderTest; + + @Test + void shouldSendEmailSchedule() throws Exception { + //given + NotificationSettingModel notificationSetting = mock(NotificationSettingModel.class); + List tasks = Collections.singletonList(mock(TaskResponse.class)); + + LocalDate today = LocalDate.now(); + when(notificationSettingService.getNotificationSetting()).thenReturn(notificationSetting); + when(notificationSetting.isActive()).thenReturn(true); + when(notificationSetting.getRequestedNotificationDate()).thenReturn(today.minusDays(1)); + when(notificationSetting.isOnlyDueDate()).thenReturn(false); + when(taskService.getTasks()).thenReturn(tasks); + when(notificationSetting.getEmail()).thenReturn("notification-email"); + when(notificationSetting.getDuration()).thenReturn("EVERY_24H"); + + //when + objectUnderTest.sendEmailSchedule(); + + //then + verify(notificationSettingService).getNotificationSetting(); + verify(notificationSettingService).updateNotificationRequestedDate(notificationSetting.getEmail(), today.plusDays(Duration.valueOf(notificationSetting.getDuration()).getDurationInDay())); + verify(taskService).getTasks(); + verify(notificationService).sendNotificationAboutTasks(notificationSetting.getEmail(), tasks, "TaskMail.ftl"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> taskCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationService).sendNotificationAboutTasks(eq("notification-email"), taskCaptor.capture(), eq("TaskMail.ftl")); + assertThat(taskCaptor.getValue()).isEqualTo(tasks); + } +} diff --git a/src/test/java/com/coyoapp/tinytask/service/DefaultNotificationServiceTest.java b/src/test/java/com/coyoapp/tinytask/service/DefaultNotificationServiceTest.java new file mode 100644 index 00000000..635e4c4a --- /dev/null +++ b/src/test/java/com/coyoapp/tinytask/service/DefaultNotificationServiceTest.java @@ -0,0 +1,91 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.domain.NotificationSetting; +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.coyoapp.tinytask.repository.NotificationSettingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.modelmapper.ModelMapper; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DefaultNotificationServiceTest { + + @Mock + private NotificationSettingRepository notificationSettingRepository; + + @Mock + private ModelMapper mapper; + + @InjectMocks + private DefaultNotificationService objectUnderTest; + + @Test + void shouldUpdateNotificationSetting() { + // given + NotificationSettingModel notificationModel = new NotificationSettingModel(); + notificationModel.setDuration("EVERY_24H"); + notificationModel.setEmail("notification-email"); + notificationModel.setActive(true); + notificationModel.setOnlyDueDate(true); + notificationModel.setDayBeforeDueDate(3); + + NotificationSetting notificationSetting = new NotificationSetting(); + + when(notificationSettingRepository.findTopByOrderByIdAsc()).thenReturn(Optional.of(notificationSetting)); + + // when + objectUnderTest.updateNotificationSetting(notificationModel); + + // then + ArgumentCaptor settingCaptor = ArgumentCaptor.forClass(NotificationSetting.class); + verify(notificationSettingRepository).save(settingCaptor.capture()); + + NotificationSetting capturedSetting = settingCaptor.getValue(); + assertThat(capturedSetting.getEmail()).isEqualTo(notificationModel.getEmail()); + assertThat(capturedSetting.getDuration()).isEqualTo(notificationModel.getDuration()); + assertThat(capturedSetting.isActive()).isEqualTo(notificationModel.isActive()); + assertThat(capturedSetting.isOnlyDueDate()).isEqualTo(notificationModel.isOnlyDueDate()); + assertThat(capturedSetting.getDayBeforeDueDate()).isEqualTo(notificationModel.getDayBeforeDueDate()); + + verify(notificationSettingRepository, times(1)).findTopByOrderByIdAsc(); + verify(notificationSettingRepository, times(1)).save(notificationSetting); + } + + @Test + void shouldGetNotificationSetting() { + //given + NotificationSetting notificationSetting = mock(NotificationSetting.class); + NotificationSettingModel notificationSettingModel = mock(NotificationSettingModel.class); + when(notificationSettingRepository.findTopByOrderByIdAsc()).thenReturn(Optional.ofNullable(notificationSetting)); + when(mapper.map(notificationSetting, NotificationSettingModel.class)).thenReturn(notificationSettingModel); + + //when + NotificationSettingModel actualTask = objectUnderTest.getNotificationSetting(); + + //then + assertThat(actualTask).isEqualTo(notificationSettingModel); + } + + @Test + void shouldUpdateNotificationRequestedDate() { + //given + String email = "notification-email"; + LocalDate requestedNotificationDate = LocalDate.now(); + + //when + objectUnderTest.updateNotificationRequestedDate(email, requestedNotificationDate); + + //then + verify(notificationSettingRepository, times(1)).updateRequestedNotificationDate(email, requestedNotificationDate); + } +} diff --git a/src/test/java/com/coyoapp/tinytask/service/DefaultTaskServiceTest.java b/src/test/java/com/coyoapp/tinytask/service/DefaultTaskServiceTest.java index 063321c6..05706d28 100644 --- a/src/test/java/com/coyoapp/tinytask/service/DefaultTaskServiceTest.java +++ b/src/test/java/com/coyoapp/tinytask/service/DefaultTaskServiceTest.java @@ -5,6 +5,8 @@ import com.coyoapp.tinytask.dto.TaskResponse; import com.coyoapp.tinytask.exception.TaskNotFoundException; import com.coyoapp.tinytask.repository.TaskRepository; + +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -16,15 +18,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.ThrowableAssert.catchThrowable; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class DefaultTaskServiceTest { - @Mock private TaskRepository taskRepository; @@ -93,4 +92,43 @@ void shouldNotDeleteTask() { // then assertThat(thrown).isInstanceOf(TaskNotFoundException.class); } + + @Test + void testGetTasksWithinDays() { + // Given + int days = 5; + LocalDate today = LocalDate.now(); + LocalDate futureDate = today.plusDays(days); + + Task task1 = new Task(); + task1.setId("task-id"); + task1.setName("Task 1"); + task1.setDueDate(today.plusDays(1)); + List tasks = List.of(task1); + + when(taskRepository.findByDueDateBetween(today, futureDate)).thenReturn(tasks); + when(objectUnderTest.transformToResponse(task1)).thenReturn(new TaskResponse("task-id", "Task 1", today.plusDays(1))); + + // When + List result = objectUnderTest.getTasksWithinDays(days); + + // Then + assertEquals(1, result.size()); + assertEquals("Task 1", result.get(0).getName()); + + verify(taskRepository, times(1)).findByDueDateBetween(today, futureDate); + } + + @Test + void shouldUpdateTask() { + // Given + String taskId = "task-id"; + LocalDate dueDate = LocalDate.now().plusDays(5); + + // When + objectUnderTest.updateTask(taskId, dueDate); + + // Then + verify(taskRepository, times(1)).updateDueDateById(taskId, dueDate); + } } diff --git a/src/test/java/com/coyoapp/tinytask/service/EmailServiceTest.java b/src/test/java/com/coyoapp/tinytask/service/EmailServiceTest.java new file mode 100644 index 00000000..83c982fa --- /dev/null +++ b/src/test/java/com/coyoapp/tinytask/service/EmailServiceTest.java @@ -0,0 +1,73 @@ +package com.coyoapp.tinytask.service; + +import com.coyoapp.tinytask.dto.TaskResponse; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EmailServiceTest { + @Mock + private JavaMailSender mailSender; + @Mock + private Configuration freemarkerConfig; + @Mock + private Template template; + @InjectMocks + private EmailService emailService; + + @Test + void shouldSendNotificationAboutTasks() throws MessagingException, IOException, TemplateException { + // Given + String email = "test@example.com"; + List tasks = Collections.emptyList(); + String templateFileName = "taskMail.ftl"; + + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + when(freemarkerConfig.getTemplate(templateFileName)).thenReturn(template); + + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + ((StringWriter) args[1]).write("Test HTML content"); + return null; + }).when(template).process(any(), any()); + + doNothing().when(mailSender).send(mimeMessage); + + // When + emailService.sendNotificationAboutTasks(email, tasks, templateFileName); + + // Then + verify(mailSender).send(mimeMessage); + + // Verify that MimeMessageHelper is set correctly + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true); + helper.setTo(email); + helper.setSubject("Unfinished Tasks Reminder"); + helper.setText("Test HTML content", true); + + // Verifying methods that are actually called during test + verify(mailSender, times(1)).createMimeMessage(); + verify(freemarkerConfig, times(1)).getTemplate(templateFileName); + verify(template, times(1)).process(any(), any(StringWriter.class)); + } +} diff --git a/src/test/java/com/coyoapp/tinytask/web/BaseControllerTest.java b/src/test/java/com/coyoapp/tinytask/web/BaseControllerTest.java index 50f1b72f..a4283a85 100644 --- a/src/test/java/com/coyoapp/tinytask/web/BaseControllerTest.java +++ b/src/test/java/com/coyoapp/tinytask/web/BaseControllerTest.java @@ -1,5 +1,6 @@ package com.coyoapp.tinytask.web; +import com.coyoapp.tinytask.service.NotificationSettingService; import com.coyoapp.tinytask.service.TaskService; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; @@ -18,4 +19,7 @@ abstract public class BaseControllerTest { @MockBean protected TaskService taskService; + + @MockBean + protected NotificationSettingService notificationSettingService; } diff --git a/src/test/java/com/coyoapp/tinytask/web/NotificationControllerTest.java b/src/test/java/com/coyoapp/tinytask/web/NotificationControllerTest.java new file mode 100644 index 00000000..db29ec61 --- /dev/null +++ b/src/test/java/com/coyoapp/tinytask/web/NotificationControllerTest.java @@ -0,0 +1,78 @@ +package com.coyoapp.tinytask.web; + +import com.coyoapp.tinytask.dto.NotificationSettingModel; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +class NotificationControllerTest extends BaseControllerTest { + + private static final String PATH = "/notification"; + + @Test + void shouldUpdateNotificationSetting() throws Exception { + //given + NotificationSettingModel notificationSettingModel = new NotificationSettingModel( + "notification-duration", "notification-email", true, true, 10, null); + ObjectMapper objectMapper = new ObjectMapper(); + String requestBody = objectMapper.writeValueAsString(notificationSettingModel); + + //when + ResultActions actualResult = this.mockMvc.perform(put(PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + + //then + actualResult + .andDo(print()) + .andExpect(status().isOk()); + + verify(notificationSettingService).updateNotificationSetting(notificationSettingModel); + } + + @Test + void shouldGetNotificationSetting() throws Exception { + // given + String duration = "notification-duration"; + String email = "notification-email"; + boolean isActive = true; + boolean isOnlyDueDate = true; + Integer dayBeforeDueDate = 10; + LocalDate requestedNotificationDate = LocalDate.of(2024, 1, 1); + NotificationSettingModel notificationSettingModel = NotificationSettingModel.builder() + .duration(duration).email(email).isActive(isActive).isOnlyDueDate(isOnlyDueDate) + .dayBeforeDueDate(dayBeforeDueDate).requestedNotificationDate(requestedNotificationDate) + .build(); + when(notificationSettingService.getNotificationSetting()).thenReturn(notificationSettingModel); + + //when + ResultActions actualResult = this.mockMvc.perform(get(PATH)); + + //then + Integer[] dateArray = { requestedNotificationDate.getYear(), requestedNotificationDate.getMonthValue(), requestedNotificationDate.getDayOfMonth() }; + List dateList = Arrays.asList(dateArray); + actualResult + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.duration", is(duration))) + .andExpect(jsonPath("$.email", is(email))) + .andExpect(jsonPath("$.active", is(isActive))) + .andExpect(jsonPath("$.onlyDueDate", is(isOnlyDueDate))) + .andExpect(jsonPath("$.dayBeforeDueDate", is(dayBeforeDueDate))) + .andExpect(jsonPath("$.requestedNotificationDate", is(dateList))); + } +} diff --git a/src/test/java/com/coyoapp/tinytask/web/TaskControllerTest.java b/src/test/java/com/coyoapp/tinytask/web/TaskControllerTest.java index 5659a41b..850776dd 100644 --- a/src/test/java/com/coyoapp/tinytask/web/TaskControllerTest.java +++ b/src/test/java/com/coyoapp/tinytask/web/TaskControllerTest.java @@ -3,7 +3,13 @@ import com.coyoapp.tinytask.dto.TaskRequest; import com.coyoapp.tinytask.dto.TaskResponse; import com.coyoapp.tinytask.exception.TaskNotFoundException; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.Collections; +import java.util.List; + import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; @@ -14,9 +20,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 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.jsonPath; @@ -31,8 +35,9 @@ void shouldCreateTask() throws Exception { // given String id = "task-id"; String name = "task-name"; + LocalDate dueDate = LocalDate.of(2024, 1, 1); TaskRequest taskRequest = TaskRequest.builder().name(name).build(); - TaskResponse taskResponse = TaskResponse.builder().id(id).name(name).build(); + TaskResponse taskResponse = TaskResponse.builder().id(id).name(name).dueDate(dueDate).build(); when(taskService.createTask(taskRequest)).thenReturn(taskResponse); // when @@ -42,12 +47,15 @@ void shouldCreateTask() throws Exception { ); // then + Integer[] dateArray = { dueDate.getYear(), dueDate.getMonthValue(), dueDate.getDayOfMonth() }; + List date = Arrays.asList(dateArray); actualResult .andDo(print()) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.id", is(notNullValue()))) - .andExpect(jsonPath("$.name", is(name))); + .andExpect(jsonPath("$.name", is(name))) + .andExpect(jsonPath("$.dueDate", is(date))); } @Test @@ -55,20 +63,24 @@ void shouldGetTasks() throws Exception { // given String id = "task-id"; String name = "task-name"; - TaskResponse taskResponse = TaskResponse.builder().id(id).name(name).build(); + LocalDate dueDate = LocalDate.of(2024, 1, 1); + TaskResponse taskResponse = TaskResponse.builder().id(id).name(name).dueDate(dueDate).build(); when(taskService.getTasks()).thenReturn(Collections.singletonList(taskResponse)); // when ResultActions actualResult = this.mockMvc.perform(get(PATH)); // then + Integer[] dateArray = { dueDate.getYear(), dueDate.getMonthValue(), dueDate.getDayOfMonth() }; + List date = Arrays.asList(dateArray); actualResult .andDo(print()) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].id", is(notNullValue()))) - .andExpect(jsonPath("$[0].name", is(name))); + .andExpect(jsonPath("$[0].name", is(name))) + .andExpect(jsonPath("$[0].dueDate", is(date))); } @Test @@ -101,4 +113,23 @@ void shouldNotDeleteTask() throws Exception { .andDo(print()) .andExpect(status().isNotFound()); } + + @Test + void shouldUpdateTask() throws Exception { + // given + String id = "task-id"; + String requestBody = "2025-01-01"; + + // when + ResultActions actualResult = this.mockMvc.perform(put(PATH + "/" + id) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + + // then + actualResult + .andDo(print()) + .andExpect(status().isOk()); + + verify(taskService).updateTask(id,LocalDate.parse(requestBody)); + } } diff --git a/yarn.lock b/yarn.lock index 3708d1fb..d72787b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2505,6 +2505,13 @@ "@material/theme" "15.0.0-canary.7f224ddd4.0" tslib "^2.1.0" +"@ng-bootstrap/ng-bootstrap@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-16.0.0.tgz#f2d3679bae59106efbab3469514143c7b7a70d44" + integrity sha512-+FJ3e6cX9DW2t7021Ji3oz433rk3+4jLfqzU+Jyx6/vJz1dIOaML3EAY6lYuW4TLiXgMPOMvs6KzPFALGh4Lag== + dependencies: + tslib "^2.3.0" + "@ngtools/webpack@17.3.7": version "17.3.7" resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-17.3.7.tgz#e12f98a254a9ec179fe519ee7c3c0e12fd250c32" @@ -2697,6 +2704,11 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + "@rollup/rollup-android-arm-eabi@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz#bbd0e616b2078cd2d68afc9824d1fadb2f2ffd27" @@ -3740,6 +3752,11 @@ boolbase@^1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== +bootstrap@^5.3.2: + version "5.3.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.3.tgz#de35e1a765c897ac940021900fcbb831602bac38" + integrity sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -6568,6 +6585,11 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@2.18.1: + version "2.18.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" + integrity sha512-QGcnVKRSEhbWy2i0pqFhjWMCczL/YU5ICMB3maUavFcyUqBszRnzsswvOaGOqSfWZ/R+dMnb9gGBuRT4LMTdVQ== + mrmime@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" @@ -6629,6 +6651,20 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +ng-bootstrap@^1.1.16: + version "1.6.3" + resolved "https://registry.yarnpkg.com/ng-bootstrap/-/ng-bootstrap-1.6.3.tgz#d41fd42154c0593422cb83c473a3828aa7525bf5" + integrity sha512-VrnrgSC0r29Rzppnki6VfyVZpaj3UvkXhTOoPCA5UcTs5eH6pPPTqgNYbXf7Cq9MfNi1Asct2kpMVOGVpLiZnQ== + dependencies: + moment "2.18.1" + +ngx-bootstrap@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/ngx-bootstrap/-/ngx-bootstrap-12.0.0.tgz#fc1c7f74aef3d098cf5ca458121e28d3790eedff" + integrity sha512-6/Hs+FT6peMc+Y2uiOm3IawG06Jh3gLQwwKRBF0U1OMlRbpx4KIyHS9GpZtMevtZaBsCRNfHKiSxwsnvn9wx0Q== + dependencies: + tslib "^2.3.0" + nice-napi@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" From dc219aa61c082233ed90873ac5f5f0767f3f737a Mon Sep 17 00:00:00 2001 From: berkanOzel Date: Tue, 4 Jun 2024 16:45:24 +0300 Subject: [PATCH 2/3] V2__Setup.sql update --- src/main/resources/db/migration/V2__Setup.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V2__Setup.sql b/src/main/resources/db/migration/V2__Setup.sql index a0b1b9ee..b629e55e 100644 --- a/src/main/resources/db/migration/V2__Setup.sql +++ b/src/main/resources/db/migration/V2__Setup.sql @@ -1,4 +1,4 @@ -ALTER TABLE task ADD due_date DATE; +ALTER TABLE task ADD COLUMN IF NOT EXISTS due_date DATE; CREATE TABLE IF NOT EXISTS notification_setting ( id VARCHAR(36) NOT NULL, From 29b949c646c95d06b79800cdb96416a61fb260de Mon Sep 17 00:00:00 2001 From: berkanOzel Date: Tue, 4 Jun 2024 17:06:08 +0300 Subject: [PATCH 3/3] READ.ME Flayway update --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ea8be634..11107a7c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ the PostgreSQL docker container. Run `./gradlew bootRun` for a dev server. The s Run `./gradlew test` to execute the tests. +When any problem occurs during database synchronization, Flyway commands should be executed + +`flyway -url=jdbc:postgresql://localhost:5432/tiny_task -user=tiny_task -password=demo123 -locations=\tiny-tasks\src\main\resources\db\migration repair` + +`flyway -url=jdbc:postgresql://localhost:5432/tiny_task -user=tiny_task -password=demo123 -locations=\tiny-tasks\src\main\resources\db\migration migrate` + ## Let's go As you can see, there's a lot to do. Just pick one of the [issues](https://github.com/mindsmash/tiny-tasks/issues) and