From 79b0246515b0b5ef147d2c377c4ee9223051e726 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 10 Apr 2025 13:35:34 +0700 Subject: [PATCH 01/43] =?UTF-8?q?feat/qg-253(WIP):=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D1=8B=D1=88=D0=B0=D0=BB=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=20=D0=B3=D1=83?= =?UTF-8?q?=D0=B3=D0=BB=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 6 ++ app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt | 2 + .../pro/qyoga/app/infra/WebSecurityConfig.kt | 14 ++++ .../publc/oauth2/GoogleCallbackController.kt | 42 +++++++++++ .../core/schedule/GetCalendarAppointments.kt | 10 ++- .../calendar/google/GoogleCalendarConf.kt | 9 +++ .../calendar/google/GoogleCalendarsService.kt | 69 +++++++++++++++++++ .../core/calendar/ical/ICalCalendarsRepo.kt | 2 +- .../core/calendar/ical/model/ICalCalendar.kt | 16 ++--- .../ical/platform/ical4j/CalendarExt.kt | 12 +++- .../ical/platform/ical4j/ICalIntegration.kt | 4 ++ app/src/main/resources/application.yaml | 22 ++++++ .../therapist/appointments/schedule.html | 2 +- .../core/calendar/ical/ICalCalendarTest.kt | 4 +- queries/queries.sql | 4 +- settings.gradle.kts | 11 +++ 16 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 59c818ff..e4a23002 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") @@ -34,6 +35,11 @@ dependencies { implementation(libs.bundles.poi) implementation(libs.nanocaptcha) implementation(libs.ical4j) + implementation(libs.google.api.client) + implementation(libs.google.calendar.api) + implementation(libs.google.oauth.client) + implementation(platform("com.google.auth:google-auth-library-bom:1.30.1")) + implementation("com.google.auth:google-auth-library-oauth2-http") developmentOnly("org.springframework.boot:spring-boot-docker-compose") developmentOnly("org.springframework.boot:spring-boot-devtools") diff --git a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt index 07574f2e..8b78843e 100644 --- a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt @@ -8,6 +8,7 @@ import pro.azhidkov.platform.spring.sdj.ErgoSdjConfig import pro.qyoga.app.publc.PublicAppConfig import pro.qyoga.app.therapist.TherapistWebAppConfig import pro.qyoga.core.appointments.AppointmentsConfig +import pro.qyoga.core.calendar.google.GoogleCalendarConf import pro.qyoga.core.calendar.ical.ICalCalendarsConfig import pro.qyoga.core.clients.ClientsConfig import pro.qyoga.core.survey_forms.SurveyFormsSettingsConfig @@ -34,6 +35,7 @@ import pro.qyoga.tech.captcha.CaptchaConf UsersConfig::class, SurveyFormsSettingsConfig::class, ICalCalendarsConfig::class, + GoogleCalendarConf::class, // I9ns EmailsConfig::class, diff --git a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt index 0f839be8..6ecb1051 100644 --- a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt @@ -9,6 +9,8 @@ import org.springframework.security.config.Customizer.withDefaults import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository @@ -49,6 +51,11 @@ class WebSecurityConfig( // Therapist .requestMatchers("/therapist/**").hasAnyAuthority(Role.ROLE_THERAPIST.toString()) + + // OAuth2 + .requestMatchers("/oauth2/**", "/therapist/oauth").permitAll() + + // Public .requestMatchers( HttpMethod.GET, @@ -56,6 +63,7 @@ class WebSecurityConfig( "/offline.html", "/manifest.json", "/register", + "/oauth2/**", "/components/**", "/styles/**", "/img/**", @@ -79,6 +87,7 @@ class WebSecurityConfig( .failureForwardUrl("/error-p") .permitAll() } + .oauth2Client(withDefaults()) // Добавьте эту строку! .logout { logout: LogoutConfigurer -> logout.permitAll() } .rememberMe { rememberMeConfigurer -> rememberMeConfigurer @@ -89,6 +98,11 @@ class WebSecurityConfig( return http.build() } + @Bean + fun authorizedClientRepository(): OAuth2AuthorizedClientRepository { + return HttpSessionOAuth2AuthorizedClientRepository() + } + @Bean fun tokenRepository(): PersistentTokenRepository { val jdbcTokenRepositoryImpl = JdbcTokenRepositoryImpl() diff --git a/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt new file mode 100644 index 00000000..34d7c2ee --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt @@ -0,0 +1,42 @@ +package pro.qyoga.app.publc.oauth2 + +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import pro.qyoga.core.users.auth.dtos.QyogaUserDetails +import pro.qyoga.core.users.therapists.Therapist +import pro.qyoga.core.users.therapists.TherapistRef +import java.util.* + +@Controller +class GoogleOAuthController( +) { + + companion object { + + var token = "" + } + + // Этот endpoint теперь будет работать с oauth2Client + @GetMapping("/therapist/oauth2/google/callback") + fun handleOAuthCallback( + @RegisteredOAuth2AuthorizedClient("google") authorizedClient: OAuth2AuthorizedClient, + @AuthenticationPrincipal userDetails: QyogaUserDetails + ): String { + val therapistId = TherapistRef.to(userDetails.id) + + token = authorizedClient.accessToken.tokenValue + // Spring автоматически получил токены! + println("Access Token: ${authorizedClient.accessToken.tokenValue}") + println("Refresh Token: ${authorizedClient.refreshToken?.tokenValue}") + println("Expires At: ${authorizedClient.accessToken.expiresAt}") + + // Сохраняем токены + // googleOAuthService.saveAuthorizedClient(therapistId, authorizedClient) + + return "redirect:/therapist/schedule?google_connected=true" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index 15cac871..a53c4cb4 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.appointments.core.AppointmentsRepo import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.calendar.ical.ICalCalendarsRepo import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo @@ -15,7 +16,8 @@ import java.time.* class GetCalendarAppointmentsOp( private val userSettingsRepo: UserSettingsRepo, private val appointmentsRepo: AppointmentsRepo, - private val iCalCalendarsRepo: ICalCalendarsRepo + private val iCalCalendarsRepo: ICalCalendarsRepo, + private val googleCalendarsService: GoogleCalendarsService ) : (TherapistRef, LocalDate) -> Iterable> { override fun invoke(therapist: TherapistRef, date: LocalDate): Iterable> { @@ -23,6 +25,12 @@ class GetCalendarAppointmentsOp( val interval = calendarIntervalAround(date, currentUserTimeZone) val appointments = appointmentsRepo.findCalendarItemsInInterval(therapist, interval) val drafts = iCalCalendarsRepo.findCalendarItemsInInterval(therapist, interval) + + try { + googleCalendarsService.findCalendarItemsInInterval(therapist, interval) + } catch (ex: Exception) { + ex.printStackTrace() + } return appointments + drafts } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt new file mode 100644 index 00000000..7a88e006 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt @@ -0,0 +1,9 @@ +package pro.qyoga.core.calendar.google + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + + +@ComponentScan +@Configuration +class GoogleCalendarConf \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt new file mode 100644 index 00000000..d9501114 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -0,0 +1,69 @@ +package pro.qyoga.core.calendar.google + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.calendar.Calendar +import org.springframework.stereotype.Service +import pro.azhidkov.platform.java.time.Interval +import pro.azhidkov.platform.uuid.UUIDv7 +import pro.qyoga.app.publc.oauth2.GoogleOAuthController +import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.users.therapists.TherapistRef +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.util.* + + +@Service +class GoogleCalendarsService : CalendarsService { + + override fun findCalendarItemsInInterval( + therapist: TherapistRef, + interval: Interval + ): Iterable> { + val accessToken = GoogleOAuthController.token + + val APPLICATION_NAME = "Trainer Advisor" + val JSON_FACTORY = GsonFactory.getDefaultInstance() + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + + val service = Calendar.Builder(httpTransport, JSON_FACTORY, null) + .setApplicationName(APPLICATION_NAME) + .build() + + service.CalendarList().list() + .setOauthToken(accessToken) + .execute().items.forEach { + println(it.id) + println(it.summary) + println(it) + println() + } + + val now = Date() + val events = + service.events().list("aleksey.zhidkov@gmail.com") // "primary" refers to the user's primary calendar + .setTimeMin(com.google.api.client.util.DateTime(interval.from.toInstant().toEpochMilli())) + .setTimeMax(com.google.api.client.util.DateTime(interval.to.toInstant().toEpochMilli())) + .setOrderBy("startTime") + .setSingleEvents(true) + .setOauthToken(accessToken) // Set the access token + .execute() + .items + .forEach { + println(it.summary + "\n") + } + + return emptyList() + } + +} + +fun main() { + GoogleCalendarsService().findCalendarItemsInInterval( + TherapistRef.to(UUIDv7.randomUUID()), + Interval.of(ZonedDateTime.now(), Duration.ofHours(1)) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt index 4abeb57b..acd9581e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt @@ -63,5 +63,5 @@ class ICalCalendarsRepo( private fun ICalCalendar.localizedICalCalendarItemsIn( interval: Interval, ): List = - this.calendarItemsIn(interval) + (this.calendarItemsIn(interval) ?: emptyList()) .map(ICalCalendarItem::toLocalizedICalCalendarItem) \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt index 34bedd3f..71c09d47 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt @@ -6,10 +6,10 @@ import org.springframework.data.relational.core.mapping.Table import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.calendar.api.Calendar -import pro.qyoga.core.calendar.ical.platform.ical4j.parseIcs import pro.qyoga.core.calendar.ical.platform.ical4j.recurrenceId import pro.qyoga.core.calendar.ical.platform.ical4j.toICalCalendarItem import pro.qyoga.core.calendar.ical.platform.ical4j.toICalPeriod +import pro.qyoga.core.calendar.ical.platform.ical4j.tryParseIcs import pro.qyoga.core.users.therapists.TherapistRef import java.net.URL import java.time.Instant @@ -37,8 +37,8 @@ data class ICalCalendar( @Transient override val type: String = TYPE - val calendar by lazy { - parseIcs(icsFile) + val calendar: net.fortuna.ical4j.model.Calendar? by lazy { + tryParseIcs(icsFile) } fun withIcsFile(icsFile: String) = @@ -50,18 +50,18 @@ data class ICalCalendar( } -fun ICalCalendar.vEvents(): List = - calendar.getComponents("VEVENT") +fun ICalCalendar.vEvents(): List? = + calendar?.getComponents("VEVENT") fun ICalCalendar.findById(eventId: ICalEventId) = vEvents() - .find { it.uid.get().value == eventId.uid && it.recurrenceId == eventId.recurrenceId } + ?.find { it.uid.get().value == eventId.uid && it.recurrenceId == eventId.recurrenceId } fun ICalCalendar.calendarItemsIn( interval: Interval -): List = +): List? = vEvents() - .flatMap { ve: VEvent -> + ?.flatMap { ve: VEvent -> ve.calculateRecurrenceSet(interval.toICalPeriod()) .map { ve.toICalCalendarItem(it) } } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt index 9803dab8..d833a10f 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt @@ -1,14 +1,22 @@ package pro.qyoga.core.calendar.ical.platform.ical4j import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.util.CompatibilityHints +import org.slf4j.LoggerFactory import java.io.StringReader +private val log = LoggerFactory.getLogger(ICalIntegration::class.java) -fun parseIcs(icsData: String): Calendar { +fun tryParseIcs(icsData: String): Calendar? { CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true) val sin = StringReader(icsData.replace("\n", "\r\n")) val builder = CalendarBuilder() - return builder.build(sin) + return try { + builder.build(sin) + } catch (e: ParserException) { + log.error("ics-file parsing failed", e) + null + } } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt new file mode 100644 index 00000000..7d363e06 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt @@ -0,0 +1,4 @@ +package pro.qyoga.core.calendar.ical.platform.ical4j + + +object ICalIntegration \ No newline at end of file diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index 86f7054d..e22a3db3 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -36,6 +36,24 @@ spring: cachecontrol: max-age: 1h + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:client-id} + client-secret: ${GOOGLE_CLIENT_SECRET:client-secret} + scope: + - https://www.googleapis.com/auth/calendar.readonly + redirect-uri: "http://localhost:8080/therapist/oauth2/google/callback" + authorization-grant-type: authorization_code + client-name: Trainer Advisor + client-authentication-method: client_secret_post + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent + token-uri: https://oauth2.googleapis.com/token + management: endpoints: web: @@ -77,4 +95,8 @@ trainer-advisor: admin: email: ta@azhidkov.pro +logging: + level: + org.springframework.security: DEBUG + debug: false \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/schedule.html b/app/src/main/resources/templates/therapist/appointments/schedule.html index 05a1eaef..1a2c199d 100644 --- a/app/src/main/resources/templates/therapist/appointments/schedule.html +++ b/app/src/main/resources/templates/therapist/appointments/schedule.html @@ -108,7 +108,7 @@
- + Подключить Google Calendar
diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt index bc043844..03d46dff 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt @@ -26,7 +26,7 @@ class ICalCalendarTest : FreeSpec({ "при запросе событий за период включающим это событие" - { val interval = Interval.of(ZonedDateTime.now(), Duration.ofDays(7)) - val events = ical.calendarItemsIn(interval) + val events = ical.calendarItemsIn(interval)!! "должен вернуть одно событие" { events.size shouldBe 1 @@ -57,4 +57,4 @@ class ICalCalendarTest : FreeSpec({ }) private val ICalCalendar.lastEvent: VEvent - get() = vEvents().last() + get() = vEvents()!!.last() diff --git a/queries/queries.sql b/queries/queries.sql index 5ccb0ef9..6a035446 100644 --- a/queries/queries.sql +++ b/queries/queries.sql @@ -22,9 +22,11 @@ GROUP BY u.id, t.id; -- Месячная статистика по записям журнала SELECT dates AS date, count(je.id) AS entries_count, - count(distinct c.therapist_ref) AS therapists_count + count(distinct c.therapist_ref) AS therapists_count, + array_agg(distinct t.last_name) FROM generate_series(now() - '1 month'::interval, now(), '1 day'::interval) dates LEFT JOIN journal_entries je ON je.date = date_trunc('days', dates.dates) LEFT JOIN clients c ON c.id = je.client_ref + LEFT JOIN therapists t ON t.id = c.therapist_ref GROUP by dates.dates ORDER BY dates.dates DESC; \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f8abba5c..4adb64e1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,9 @@ dependencyResolutionManagement { // lib versions val poiVersion = version("poi", "5.4.1") + val googleApiClient = version("google-api-client", "2.0.0") + val googleCalendarApi = version("google-calendar-api", "v3-rev20220715-2.0.0") + val googleOAuthClientJetty = version("google-oauth-client", "1.34.1") // plugins plugin("kotlin", "org.jetbrains.kotlin.jvm").versionRef(kotlinVersion) @@ -47,6 +50,14 @@ dependencyResolutionManagement { library("nanocaptcha", "net.logicsquad", "nanocaptcha").version("2.1") library("ical4j", "org.mnode.ical4j", "ical4j").version("4.1.1") + + library("google-api-client", "com.google.api-client", "google-api-client").versionRef(googleApiClient) + library("google-oauth-client", "com.google.oauth-client", "google-oauth-client-jetty").versionRef( + googleOAuthClientJetty + ) + library("google-calendar-api", "com.google.apis", "google-api-services-calendar").versionRef( + googleCalendarApi + ) } create("testLibs") { From 11c72a337eb68ba3493aef44c7acccc6c3a255bc Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 31 Jul 2025 17:19:44 +0700 Subject: [PATCH 02/43] =?UTF-8?q?tests/qg-253:=20webTestClient=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B2=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=83=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для того чтобы его можно было использовать вне контекста базового класса теста --- app/build.gradle.kts | 1 + .../app/publc/surveys/SubmitSurveyTest.kt | 3 ++- .../web/QYogaAppIntegrationBaseKoTest.kt | 24 ------------------ .../tests/infra/web/QYogaAppBaseKoTest.kt | 3 --- .../qyoga/tests/infra/web/WebTestClient.kt | 25 +++++++++++++++++++ 5 files changed, 28 insertions(+), 28 deletions(-) create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4a23002..2538f058 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { testFixturesImplementation(libs.ical4j) testFixturesImplementation("org.springframework.boot:spring-boot-starter-test") + testFixturesImplementation("org.springframework.security:spring-security-test") testFixturesImplementation("org.testcontainers:junit-jupiter") testFixturesImplementation("org.testcontainers:postgresql") testFixturesImplementation(testLibs.testcontainers.minio) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/publc/surveys/SubmitSurveyTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/publc/surveys/SubmitSurveyTest.kt index 2860a27f..f042764c 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/publc/surveys/SubmitSurveyTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/publc/surveys/SubmitSurveyTest.kt @@ -22,12 +22,13 @@ import pro.qyoga.tests.fixture.object_mothers.survey_forms.SurveyFormsSettingsOb import pro.qyoga.tests.fixture.object_mothers.therapists.THE_ADMIN_LOGIN import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.infra.web.mainWebTestClient @DisplayName("Операция создания анкеты") class SubmitSurveyTest : QYogaAppIntegrationBaseKoTest({ - val yandexFormsClient by lazy { YandexFormsClient(client) } + val yandexFormsClient by lazy { YandexFormsClient(mainWebTestClient) } "при отправке новым клиентом корректного запроса со всеми значениями карточки должна" - { // Сетап diff --git a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt index ed5ca274..65641c87 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt @@ -1,34 +1,10 @@ package pro.qyoga.tests.infra.web -import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity -import org.springframework.security.web.FilterChainProxy -import org.springframework.security.web.SecurityFilterChain -import org.springframework.test.web.reactive.server.WebTestClient -import org.springframework.test.web.servlet.client.MockMvcWebTestClient -import org.springframework.web.context.WebApplicationContext -import pro.qyoga.tests.infra.test_config.spring.context - abstract class QYogaAppIntegrationBaseKoTest(body: QYogaAppIntegrationBaseKoTest.() -> Unit = {}) : QYogaAppBaseKoTest() { - private val baseUri = "http://localhost:$port" - - lateinit var client: WebTestClient - - private var securityFilterChain: SecurityFilterChain = getBean("mainSecurityFilterChain") - init { - beforeSpec { - client = MockMvcWebTestClient - .bindToApplicationContext(context as WebApplicationContext) - .apply(springSecurity(FilterChainProxy(securityFilterChain))) - .configureClient() - // без этого фильтра Spring Rest Docs пересоздаёт урл и попутно ломает киррелические символы в нём - .baseUrl(baseUri) - .defaultHeader("Content-Type", "application/json;charset=UTF-8") - .build() - } body() } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt index 6b61db80..3ef1ab22 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt @@ -1,7 +1,6 @@ package pro.qyoga.tests.infra.web import io.kotest.core.spec.style.FreeSpec -import org.springframework.boot.autoconfigure.web.ServerProperties import pro.qyoga.tests.fixture.backgrounds.Backgrounds import pro.qyoga.tests.fixture.data.resetFaker import pro.qyoga.tests.fixture.presets.Presets @@ -15,8 +14,6 @@ abstract class QYogaAppBaseKoTest(body: QYogaAppBaseKoTest.() -> Unit = {}) : Fr private val dataSource: DataSource = context.getBean(DataSource::class.java) - protected val port: Int = context.getBean(ServerProperties::class.java).port - val backgrounds: Backgrounds = context.getBean(Backgrounds::class.java) val presets: Presets = context.getBean(Presets::class.java) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt new file mode 100644 index 00000000..a7e40b28 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt @@ -0,0 +1,25 @@ +package pro.qyoga.tests.infra.web + +import org.springframework.boot.autoconfigure.web.ServerProperties +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.security.web.FilterChainProxy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.web.context.WebApplicationContext + + +val mainWebTestClient: WebTestClient by lazy { createWebTestClient() } + +fun createWebTestClient(context: ConfigurableApplicationContext = pro.qyoga.tests.infra.test_config.spring.context): WebTestClient { + val port = context.getBean(ServerProperties::class.java).port + val mainSecurityFilterChain = context.getBean("mainSecurityFilterChain", SecurityFilterChain::class.java) + return MockMvcWebTestClient + .bindToApplicationContext(context as WebApplicationContext) + .apply(springSecurity(FilterChainProxy(mainSecurityFilterChain))) + .configureClient() + .baseUrl("http://localhost:$port") + .defaultHeader("Content-Type", "application/json;charset=UTF-8") + .build() +} From 4a48eb8f598f70656f33dbd90ded7fc5f3ba8b9f Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 1 Aug 2025 14:02:50 +0700 Subject: [PATCH 03/43] =?UTF-8?q?tests/qg-253:=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D0=BB=D0=B8=D1=86=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=83=D1=80=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt | 6 +++--- .../qyoga/tests/infra/test_config/spring/TestsConfig.kt | 8 ++++++++ .../kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt | 5 ++--- .../test/kotlin/pro/qyoga/tests/infra/QYogaE2EBaseTest.kt | 4 +++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt index 41e9e7d5..3ed95db5 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt @@ -7,12 +7,12 @@ import io.restassured.config.RestAssuredConfig import io.restassured.http.ContentType import org.junit.jupiter.api.BeforeEach import pro.qyoga.tests.clients.TherapistClient +import pro.qyoga.tests.infra.test_config.spring.baseUrl +import pro.qyoga.tests.infra.test_config.spring.context open class QYogaAppIntegrationBaseTest : QYogaAppBaseTest() { - protected val baseUri = "http://localhost:$port" - protected val theTherapist by lazy { TherapistClient.loginAsTheTherapist() } @BeforeEach @@ -21,7 +21,7 @@ open class QYogaAppIntegrationBaseTest : QYogaAppBaseTest() { val config = RestAssuredConfig.config().logConfig(logConfig) RestAssured.requestSpecification = RequestSpecBuilder() - .setBaseUri(baseUri) + .setBaseUri(context.baseUrl) .setAccept(ContentType.HTML) .setContentType("application/x-www-form-urlencoded; charset=UTF-8") .setRelaxedHTTPSValidation() diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt index e02ae6ce..c9c5f128 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt @@ -1,6 +1,8 @@ package pro.qyoga.tests.infra.test_config.spring +import org.springframework.boot.autoconfigure.web.ServerProperties import org.springframework.boot.builder.SpringApplicationBuilder +import org.springframework.context.ApplicationContext import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Configuration @@ -22,6 +24,12 @@ val context: ConfigurableApplicationContext by lazy { .run() } +val ApplicationContext.baseUrl: String + get() { + val port = this.getBean(ServerProperties::class.java).port + return "http://localhost:$port" + } + @Suppress("unused") // можно использовать для быстрых тестов кода, работающего только с БД val sdjContext by lazy { AnnotationConfigApplicationContext(SdjTestsConfig::class.java) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt index a7e40b28..be204597 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt @@ -1,6 +1,5 @@ package pro.qyoga.tests.infra.web -import org.springframework.boot.autoconfigure.web.ServerProperties import org.springframework.context.ConfigurableApplicationContext import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.security.web.FilterChainProxy @@ -8,18 +7,18 @@ import org.springframework.security.web.SecurityFilterChain import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.client.MockMvcWebTestClient import org.springframework.web.context.WebApplicationContext +import pro.qyoga.tests.infra.test_config.spring.baseUrl val mainWebTestClient: WebTestClient by lazy { createWebTestClient() } fun createWebTestClient(context: ConfigurableApplicationContext = pro.qyoga.tests.infra.test_config.spring.context): WebTestClient { - val port = context.getBean(ServerProperties::class.java).port val mainSecurityFilterChain = context.getBean("mainSecurityFilterChain", SecurityFilterChain::class.java) return MockMvcWebTestClient .bindToApplicationContext(context as WebApplicationContext) .apply(springSecurity(FilterChainProxy(mainSecurityFilterChain))) .configureClient() - .baseUrl("http://localhost:$port") + .baseUrl(context.baseUrl) .defaultHeader("Content-Type", "application/json;charset=UTF-8") .build() } diff --git a/e2e-tests/src/test/kotlin/pro/qyoga/tests/infra/QYogaE2EBaseTest.kt b/e2e-tests/src/test/kotlin/pro/qyoga/tests/infra/QYogaE2EBaseTest.kt index 1ee0aa0c..82559677 100644 --- a/e2e-tests/src/test/kotlin/pro/qyoga/tests/infra/QYogaE2EBaseTest.kt +++ b/e2e-tests/src/test/kotlin/pro/qyoga/tests/infra/QYogaE2EBaseTest.kt @@ -13,6 +13,8 @@ import org.openqa.selenium.logging.LogType import org.openqa.selenium.remote.RemoteWebDriver import org.slf4j.LoggerFactory import org.testcontainers.Testcontainers +import pro.qyoga.tests.infra.test_config.spring.baseUrl +import pro.qyoga.tests.infra.test_config.spring.context import pro.qyoga.tests.infra.web.QYogaAppBaseTest @@ -24,7 +26,7 @@ open class QYogaE2EBaseTest : QYogaAppBaseTest() { private val baseUri = if (headless) "http://host.testcontainers.internal:$port" - else "http://localhost:$port" + else context.baseUrl @BeforeEach fun setUp() { From 18fe797162a91383e7740034ac09703032be0bc4 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 1 Aug 2025 14:08:37 +0700 Subject: [PATCH 04/43] =?UTF-8?q?tests/qg-253:=20=D0=B2=20QYogaAppIntegrat?= =?UTF-8?q?ionBaseKoTest=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20RestAssured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для того чтобы в одном тесте можно было использовать и новые методы на базе WebTestClient и старые ещё не переведённые с RestAssured --- .../infra/rest_assured/RestAssuredExt.kt | 20 +++++++++++++++++++ .../web/QYogaAppIntegrationBaseKoTest.kt | 6 ++++++ .../infra/web/QYogaAppIntegrationBaseTest.kt | 18 ++--------------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/src/test/kotlin/pro/qyoga/tests/infra/rest_assured/RestAssuredExt.kt b/app/src/test/kotlin/pro/qyoga/tests/infra/rest_assured/RestAssuredExt.kt index 926958d6..957d6167 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/infra/rest_assured/RestAssuredExt.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/infra/rest_assured/RestAssuredExt.kt @@ -1,10 +1,30 @@ package pro.qyoga.tests.infra.rest_assured +import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder +import io.restassured.config.LogConfig +import io.restassured.config.RestAssuredConfig import io.restassured.filter.log.RequestLoggingFilter import io.restassured.filter.log.ResponseLoggingFilter +import io.restassured.http.ContentType import io.restassured.specification.RequestSpecification +import org.springframework.context.ConfigurableApplicationContext +import pro.qyoga.tests.infra.test_config.spring.baseUrl +fun configureRestAssured(context: ConfigurableApplicationContext) { + val logConfig = LogConfig.logConfig() + val config = RestAssuredConfig.config().logConfig(logConfig) + + RestAssured.requestSpecification = RequestSpecBuilder() + .setBaseUri(context.baseUrl) + .setAccept(ContentType.HTML) + .setContentType("application/x-www-form-urlencoded; charset=UTF-8") + .setRelaxedHTTPSValidation() + .setConfig(config) + .build() +} + fun RequestSpecification.addRequestLogging(): RequestSpecification = filter(RequestLoggingFilter()) diff --git a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt index 65641c87..faa3540a 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseKoTest.kt @@ -1,10 +1,16 @@ package pro.qyoga.tests.infra.web +import pro.qyoga.tests.infra.rest_assured.configureRestAssured +import pro.qyoga.tests.infra.test_config.spring.context + abstract class QYogaAppIntegrationBaseKoTest(body: QYogaAppIntegrationBaseKoTest.() -> Unit = {}) : QYogaAppBaseKoTest() { init { + beforeSpec { + configureRestAssured(context) + } body() } diff --git a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt index 3ed95db5..fcf2ddf9 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/infra/web/QYogaAppIntegrationBaseTest.kt @@ -1,13 +1,8 @@ package pro.qyoga.tests.infra.web -import io.restassured.RestAssured -import io.restassured.builder.RequestSpecBuilder -import io.restassured.config.LogConfig -import io.restassured.config.RestAssuredConfig -import io.restassured.http.ContentType import org.junit.jupiter.api.BeforeEach import pro.qyoga.tests.clients.TherapistClient -import pro.qyoga.tests.infra.test_config.spring.baseUrl +import pro.qyoga.tests.infra.rest_assured.configureRestAssured import pro.qyoga.tests.infra.test_config.spring.context @@ -17,16 +12,7 @@ open class QYogaAppIntegrationBaseTest : QYogaAppBaseTest() { @BeforeEach fun setupRestAssured() { - val logConfig = LogConfig.logConfig() - val config = RestAssuredConfig.config().logConfig(logConfig) - - RestAssured.requestSpecification = RequestSpecBuilder() - .setBaseUri(context.baseUrl) - .setAccept(ContentType.HTML) - .setContentType("application/x-www-form-urlencoded; charset=UTF-8") - .setRelaxedHTTPSValidation() - .setConfig(config) - .build() + configureRestAssured(context) } } \ No newline at end of file From 45f46bb088b973b71cc48f9c7908084c4ec13110 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Sat, 2 Aug 2025 13:43:30 +0700 Subject: [PATCH 05/43] =?UTF-8?q?tests/qg-253:=20WebTestClient=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D0=BF=D0=BE=20=D0=A5?= =?UTF-8?q?=D0=A2=D0=A2=D0=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для того, чтобы работу через него можно было совмещать с работой через RestAssured. Плюс пока основной контекст поднимает Томкэт эффекта от MockMvc не много --- .../pro/qyoga/tests/infra/web/WebTestClient.kt | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt index be204597..1a185a42 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/WebTestClient.kt @@ -1,24 +1,14 @@ package pro.qyoga.tests.infra.web import org.springframework.context.ConfigurableApplicationContext -import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity -import org.springframework.security.web.FilterChainProxy -import org.springframework.security.web.SecurityFilterChain import org.springframework.test.web.reactive.server.WebTestClient -import org.springframework.test.web.servlet.client.MockMvcWebTestClient -import org.springframework.web.context.WebApplicationContext import pro.qyoga.tests.infra.test_config.spring.baseUrl val mainWebTestClient: WebTestClient by lazy { createWebTestClient() } -fun createWebTestClient(context: ConfigurableApplicationContext = pro.qyoga.tests.infra.test_config.spring.context): WebTestClient { - val mainSecurityFilterChain = context.getBean("mainSecurityFilterChain", SecurityFilterChain::class.java) - return MockMvcWebTestClient - .bindToApplicationContext(context as WebApplicationContext) - .apply(springSecurity(FilterChainProxy(mainSecurityFilterChain))) - .configureClient() +fun createWebTestClient(context: ConfigurableApplicationContext = pro.qyoga.tests.infra.test_config.spring.context): WebTestClient = + WebTestClient.bindToServer() .baseUrl(context.baseUrl) .defaultHeader("Content-Type", "application/json;charset=UTF-8") - .build() -} + .build() \ No newline at end of file From 98f9c6cafe10c39832bdcdcf1ffa4b27a50bbe9e Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Mon, 18 Aug 2025 10:29:09 +0700 Subject: [PATCH 06/43] =?UTF-8?q?tests/qg-253:=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D1=81=20WireMock=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=20Java=20DS?= =?UTF-8?q?L?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Потому что Котлин DSL сильно урезанный и его недопиливают - https://github.com/wiremock/kotlin-wiremock/issues/37 Плюс попутно выяснилось, что Kotlin DSL подменял \r\n на \n в ответе, поэтому так же был удалён костыль с обратной заменой в методе париснга ics-строк --- app/build.gradle.kts | 1 - .../ical/platform/ical4j/CalendarExt.kt | 2 +- .../backgrounds/ICalCalendarsBackgrounds.kt | 26 +++++++++---------- settings.gradle.kts | 1 - 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2538f058..ba1a07cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,7 +59,6 @@ dependencies { exclude("org.eclipse.jetty.http2", "http2-server") } testFixturesApi(testLibs.wiremock.jetty12) - testFixturesApi(testLibs.wiremock.kotlin) testFixturesImplementation(kotlin("reflect")) testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jdbc") diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt index d833a10f..e13a7665 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt @@ -11,7 +11,7 @@ private val log = LoggerFactory.getLogger(ICalIntegration::class.java) fun tryParseIcs(icsData: String): Calendar? { CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true) - val sin = StringReader(icsData.replace("\n", "\r\n")) + val sin = StringReader(icsData) val builder = CalendarBuilder() return try { builder.build(sin) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt index 7d3b532a..aca6bc2e 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt @@ -1,8 +1,6 @@ package pro.qyoga.tests.fixture.backgrounds -import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo -import com.marcinziolo.kotlin.wiremock.get -import com.marcinziolo.kotlin.wiremock.returns +import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.stereotype.Component import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.calendar.ical.ICalCalendarsRepo @@ -21,20 +19,22 @@ class ICalCalendarsBackgrounds( URI.create(WireMock.wiremock.baseUrl()).resolve("/ics/${UUIDv7.randomUUID()}.ics").toURL() fun createICalCalendar(ical: ICalCalendar): ICalCalendar { - WireMock.wiremock.get { - urlEqualTo(ical.icsUrl.toString()) - } returns { - body = ical.icsFile - } + WireMock.wiremock.stubFor( + get(urlEqualTo(ical.icsUrl.path)) + .willReturn( + aResponse() + .withHeader("Content-Type", "text/calendar") + .withBody(ical.icsFile) + ) + ) return icalCalendarsRepo.addICal(CreateICalRq(ical.ownerRef, ical.icsUrl, ical.name)) } fun updateICalSource(icsUrl: URL, icsFile: String) { - WireMock.wiremock.get { - urlEqualTo(icsUrl.toString()) - } returns { - body = icsFile - } + WireMock.wiremock.stubFor( + get(urlEqualTo(icsUrl.path.toString())) + .willReturn(aResponse().withBody(icsFile)) + ) } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4adb64e1..9b976fef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -95,7 +95,6 @@ dependencyResolutionManagement { library("wiremock", "org.wiremock", "wiremock").versionRef(wiremockVersion) library("wiremock-jetty12", "org.wiremock", "wiremock-jetty12").versionRef(wiremockVersion) - library("wiremock-kotlin", "com.marcinziolo", "kotlin-wiremock").version("2.1.1") } } } \ No newline at end of file From 3bee7e49bfabfe23b49de742bb8f5e8ca3b2ee44 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 11 Sep 2025 10:19:15 +0700 Subject: [PATCH 07/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B8=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2?= =?UTF-8?q?=20Google?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +- .../publc/oauth2/GoogleCallbackController.kt | 42 ------ .../core/edit/view_model/SourceItem.kt | 7 +- .../core/schedule/CalendarPageModel.kt | 5 + .../core/schedule/GetCalendarAppointments.kt | 9 +- .../GoogleCalendarSettingsController.kt | 19 +++ .../oauth2/GoogleCallbackController.kt | 59 ++++++++ .../core/calendar/google/GoogleAccount.kt | 7 + .../calendar/google/GoogleAccountsRepo.kt | 18 +++ .../core/calendar/google/GoogleCalendar.kt | 15 ++ .../calendar/google/GoogleCalendarItem.kt | 17 +++ .../calendar/google/GoogleCalendarsRepo.kt | 18 +++ .../calendar/google/GoogleCalendarsService.kt | 140 ++++++++++++------ app/src/main/resources/application.yaml | 11 +- .../google-settings-component.html | 9 ++ .../therapist/appointments/schedule.html | 78 +++++++--- .../GoogleAuthorizationIntegrationTest.kt | 102 +++++++++++++ .../google/GoogleCalendarsSettingsTest.kt | 23 +++ .../qyoga/tests/clients/TherapistClient.kt | 11 +- .../qyoga/tests/clients/api/AuthorizedApi.kt | 5 + .../clients/api/TherapistAppointmentsApi.kt | 18 ++- .../TherapistGoogleCalendarIntegrationApi.kt | 49 ++++++ .../tests/assertions/GoogleOAuthMatchers.kt | 27 ++++ .../assertions/MultiValueMapAssertions.kt | 11 ++ .../pro/qyoga/tests/fixture/data/Text.kt | 16 +- .../tests/fixture/oauth/OAuthObjectMother.kt | 32 ++++ .../fixture/wiremocks/MockGoogleCalendar.kt | 50 +++++++ .../wiremocks/MockGoogleOAuthServer.kt | 106 +++++++++++++ .../kotlin/pro/qyoga/tests/infra/db/TestDb.kt | 5 +- .../GoogleCalendarSettingsComponent.kt | 25 ++++ .../GoogleCalendarSettingsLoaderComponent.kt | 20 +++ .../therapist/appointments/SchedulePage.kt | 3 +- .../spring/web_test_client/ResponseSpecExt.kt | 19 +++ .../resources/application-test.yaml | 16 +- deploy/qyoga/docker-compose.yml | 2 + 35 files changed, 869 insertions(+), 128 deletions(-) delete mode 100644 app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt create mode 100644 app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt create mode 100644 app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt create mode 100644 app/src/main/resources/templates/therapist/appointments/google-settings-component.html create mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt create mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt create mode 100644 app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/GoogleOAuthMatchers.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/MultiValueMapAssertions.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/oauth/OAuthObjectMother.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsLoaderComponent.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/platform/spring/web_test_client/ResponseSpecExt.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba1a07cf..efd4e564 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,6 @@ dependencies { implementation("com.google.auth:google-auth-library-oauth2-http") developmentOnly("org.springframework.boot:spring-boot-docker-compose") - developmentOnly("org.springframework.boot:spring-boot-devtools") testFixturesApi("org.springframework.boot:spring-boot-testcontainers") testFixturesApi(testLibs.kotest.assertions) @@ -64,12 +63,14 @@ dependencies { testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jdbc") testFixturesImplementation("org.springframework.boot:spring-boot-starter-web") testFixturesImplementation("org.springframework.boot:spring-boot-starter-security") + testFixturesImplementation("org.springframework.boot:spring-boot-starter-oauth2-client") testFixturesImplementation("com.fasterxml.jackson.core:jackson-databind") testFixturesImplementation(libs.minio) testFixturesImplementation(libs.ical4j) testFixturesImplementation("org.springframework.boot:spring-boot-starter-test") testFixturesImplementation("org.springframework.security:spring-security-test") + testFixturesImplementation("org.springframework.boot:spring-boot-starter-webflux") testFixturesImplementation("org.testcontainers:junit-jupiter") testFixturesImplementation("org.testcontainers:postgresql") testFixturesImplementation(testLibs.testcontainers.minio) diff --git a/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt deleted file mode 100644 index 34d7c2ee..00000000 --- a/app/src/main/kotlin/pro/qyoga/app/publc/oauth2/GoogleCallbackController.kt +++ /dev/null @@ -1,42 +0,0 @@ -package pro.qyoga.app.publc.oauth2 - -import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient -import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.GetMapping -import pro.qyoga.core.users.auth.dtos.QyogaUserDetails -import pro.qyoga.core.users.therapists.Therapist -import pro.qyoga.core.users.therapists.TherapistRef -import java.util.* - -@Controller -class GoogleOAuthController( -) { - - companion object { - - var token = "" - } - - // Этот endpoint теперь будет работать с oauth2Client - @GetMapping("/therapist/oauth2/google/callback") - fun handleOAuthCallback( - @RegisteredOAuth2AuthorizedClient("google") authorizedClient: OAuth2AuthorizedClient, - @AuthenticationPrincipal userDetails: QyogaUserDetails - ): String { - val therapistId = TherapistRef.to(userDetails.id) - - token = authorizedClient.accessToken.tokenValue - // Spring автоматически получил токены! - println("Access Token: ${authorizedClient.accessToken.tokenValue}") - println("Refresh Token: ${authorizedClient.refreshToken?.tokenValue}") - println("Expires At: ${authorizedClient.accessToken.expiresAt}") - - // Сохраняем токены - // googleOAuthService.saveAuthorizedClient(therapistId, authorizedClient) - - return "redirect:/therapist/schedule?google_connected=true" - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index 5ed2d38a..006bed36 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -1,5 +1,6 @@ package pro.qyoga.app.therapist.appointments.core.edit.view_model +import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.calendar.ical.model.ICalEventId @@ -10,7 +11,11 @@ data class SourceItem( companion object { fun icsEvent(eventId: ICalEventId): SourceItem = - SourceItem(ICalCalendar.Companion.TYPE, eventId.toQueryParamStr()) + SourceItem(ICalCalendar.TYPE, eventId.toQueryParamStr()) + + fun googleEvent(eventId: GoogleCalendarItemId): SourceItem = + SourceItem("Google", eventId.value) + } } diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index b74e985a..1cc6a9c8 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -15,6 +15,7 @@ import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Comp import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.calendar.ical.model.ICalEventId import pro.qyoga.l10n.russianDayOfMonthLongFormat import pro.qyoga.l10n.russianTimeFormat @@ -265,6 +266,10 @@ private fun CalendarItem<*, LocalDateTime>.editUri() = dateTime, SourceItem.icsEvent(id as ICalEventId) ) + is GoogleCalendarItemId -> CreateAppointmentPageController.addFromSourceItemUri( + dateTime, + SourceItem.googleEvent(id as GoogleCalendarItemId) + ) else -> error("Unsupported type: $id") } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index a53c4cb4..e7e8c8db 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -24,13 +24,8 @@ class GetCalendarAppointmentsOp( val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapist)) val interval = calendarIntervalAround(date, currentUserTimeZone) val appointments = appointmentsRepo.findCalendarItemsInInterval(therapist, interval) - val drafts = iCalCalendarsRepo.findCalendarItemsInInterval(therapist, interval) - - try { - googleCalendarsService.findCalendarItemsInInterval(therapist, interval) - } catch (ex: Exception) { - ex.printStackTrace() - } + val drafts = iCalCalendarsRepo.findCalendarItemsInInterval(therapist, interval) + + googleCalendarsService.findCalendarItemsInInterval(therapist, interval) return appointments + drafts } diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt new file mode 100644 index 00000000..aa406a40 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -0,0 +1,19 @@ +package pro.qyoga.app.therapist.appointments.core.schedule + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping + + +@Controller +class GoogleCalendarSettingsController { + + @GetMapping(PATH) + fun getGoogleCalendarSettingsComponent(): String { + return "therapist/appointments/google-settings-component.html" + } + + companion object { + const val PATH = "/therapist/schedule/settings/google-calendar" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt new file mode 100644 index 00000000..1e449d8e --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -0,0 +1,59 @@ +package pro.qyoga.app.therapist.oauth2 + +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.client.RestClient +import pro.qyoga.core.calendar.google.GoogleAccount +import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.core.users.auth.dtos.QyogaUserDetails +import pro.qyoga.core.users.therapists.Therapist +import pro.qyoga.core.users.therapists.TherapistRef +import java.util.* + +/** + * Общая логика авторизации в Гугле: + * 1. Пользователь в браузере нажимает кнопку "Подключить Google Calendar", которая ведёт на oauth2/authorization/google + * 2. Этот эндпоинт обрабатывается Spring Security, которая складывает запрос на авторизацию в сессию и перенаправляет на https://accounts.google.com/o/oauth2/v2/auth + * 3. Далее пользователь в Google выдаёт доступ приложению, после чего Google перенаправляет его на /therapist/oauth2/google/callback передавая параметры авторизации (code, state etc) в параметрах запроса + * 4. Этот запрос обрабатывается Spring Security, которая сохраняет параметры в сессию и снова делает редирект на тот же эндпоинт, но уже без параметров + * 5. Этот запрос снова сначала обрабатывается Spring Security, которая достаёт данные из сессии и идёт в гугл, чтобы обменять code на токены и собрать из них OAuth2AuthorizedClient + * 6. После чего (уже без редиректа) запрос доходит в этот эндпоинт + */ +@Controller +class GoogleOAuthController( + private val googleCalendarsService: GoogleCalendarsService +) { + + // Этот endpoint теперь будет работать с oauth2Client + @GetMapping(PATH) + fun handleOAuthCallback( + @RegisteredOAuth2AuthorizedClient("google") authorizedClient: OAuth2AuthorizedClient, + @AuthenticationPrincipal userDetails: QyogaUserDetails, + @Value("\${spring.security.oauth2.client.provider.google.user-info-uri}") googleOicUserInfoUri: String + ): String { + val therapistId = TherapistRef.to(userDetails.id) + + val email = RestClient.create(googleOicUserInfoUri) + .get() + .headers { it.setBearerAuth(authorizedClient.accessToken.tokenValue) } + .retrieve() + .body(Map::class.java) + ?.get("email") as String + + googleCalendarsService.addGoogleAccount( + therapistId, + GoogleAccount(email, authorizedClient.refreshToken!!.tokenValue) + ) + + return "redirect:/therapist/schedule?google_connected=true" + } + + companion object { + const val PATH = "/therapist/oauth2/google/callback" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt new file mode 100644 index 00000000..9a42379b --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -0,0 +1,7 @@ +package pro.qyoga.core.calendar.google + + +data class GoogleAccount( + val email: String, + val refreshToken: String +) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt new file mode 100644 index 00000000..1092ef82 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt @@ -0,0 +1,18 @@ +package pro.qyoga.core.calendar.google + +import pro.qyoga.core.users.therapists.TherapistRef + + +class GoogleAccountsRepo { + + private val repo = HashMap>() + + fun addGoogleAccount(therapist: TherapistRef, googleAccount: GoogleAccount) { + repo[therapist] = repo.getOrDefault(therapist, emptyList()) + googleAccount + } + + fun findGoogleAccounts(therapist: TherapistRef): List { + return repo[therapist] ?: emptyList() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt new file mode 100644 index 00000000..e2d8697f --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt @@ -0,0 +1,15 @@ +package pro.qyoga.core.calendar.google + +import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.users.therapists.TherapistRef + + +data class GoogleCalendar( + override val ownerRef: TherapistRef, + val externalId: String, + override val name: String, +) : Calendar { + + override val type: String = "Google" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt new file mode 100644 index 00000000..e8f637c9 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt @@ -0,0 +1,17 @@ +package pro.qyoga.core.calendar.google + +import pro.qyoga.core.calendar.api.CalendarItem +import java.time.Duration +import java.time.LocalDateTime + +@JvmInline +value class GoogleCalendarItemId(val value: String) + +data class GoogleCalendarItem( + override val id: GoogleCalendarItemId, + override val title: String, + override val description: String, + override val dateTime: LocalDateTime, + override val duration: Duration, + override val location: String? +) : CalendarItem diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt new file mode 100644 index 00000000..b8185c5b --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt @@ -0,0 +1,18 @@ +package pro.qyoga.core.calendar.google + +import pro.qyoga.core.users.therapists.TherapistRef + + +class GoogleCalendarsRepo { + + private val repo = HashMap>() + + fun addCalendarsToTherapist(therapist: TherapistRef, calendars: List) { + repo[therapist] = (repo[therapist] ?: emptyList()) + calendars + } + + fun getCalendars(therapist: TherapistRef): List { + return repo[therapist] ?: emptyList() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index d9501114..28211427 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -1,69 +1,123 @@ package pro.qyoga.core.calendar.google import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.DateTime import com.google.api.services.calendar.Calendar +import com.google.api.services.calendar.model.Event +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.UserCredentials +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval -import pro.azhidkov.platform.uuid.UUIDv7 -import pro.qyoga.app.publc.oauth2.GoogleOAuthController import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.users.therapists.TherapistRef -import java.time.Duration -import java.time.LocalDateTime -import java.time.ZonedDateTime -import java.util.* +import java.net.URI +import java.time.* +const val APPLICATION_NAME = "Trainer Advisor" +val gsonFactory: GsonFactory = GsonFactory.getDefaultInstance() +val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() + @Service -class GoogleCalendarsService : CalendarsService { +class GoogleCalendarsService( + private val googleOAuthProps: OAuth2ClientProperties, + @Value("\${spring.security.oauth2.client.provider.google.token-uri}") private val tokenUri: URI, + @Value("\${trainer-advisor.integrations.google-calendar.root-url}") private val googleCalendarRootUri: URI +) : CalendarsService { + + private val googleAccountsRepo = GoogleAccountsRepo() + + private val googleCalendarsRepo = GoogleCalendarsRepo() + + fun addGoogleAccount(therapist: TherapistRef, googleAccount: GoogleAccount) { + googleAccountsRepo.addGoogleAccount(therapist, googleAccount) + } + + fun findCalendars( + therapist: TherapistRef + ): List { + val accounts = googleAccountsRepo.findGoogleAccounts(therapist) + if (accounts.isEmpty()) { + return emptyList() + } + + val account = accounts.single() + + val credentials = UserCredentials.newBuilder() + .setClientId(googleOAuthProps.registration["google"]!!.clientId) + .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) + .setRefreshToken(account.refreshToken) + .setTokenServerUri(tokenUri) + .build() + + val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) + .setApplicationName(APPLICATION_NAME) + .setRootUrl(googleCalendarRootUri.toURL().toString()) + .build() + + return service.CalendarList().list() + .execute().items.map { + GoogleCalendar(therapist, it.id, it.summary) + } + } override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval ): Iterable> { - val accessToken = GoogleOAuthController.token + val accounts = googleAccountsRepo.findGoogleAccounts(therapist) + if (accounts.isEmpty()) { + return emptyList() + } - val APPLICATION_NAME = "Trainer Advisor" - val JSON_FACTORY = GsonFactory.getDefaultInstance() - val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val events = accounts.flatMap { + val credentials = UserCredentials.newBuilder() + .setClientId(googleOAuthProps.registration["google"]!!.clientId) + .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) + .setRefreshToken(it.refreshToken) + .build() - val service = Calendar.Builder(httpTransport, JSON_FACTORY, null) - .setApplicationName(APPLICATION_NAME) - .build() + val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) + .setApplicationName(APPLICATION_NAME) + .build() - service.CalendarList().list() - .setOauthToken(accessToken) - .execute().items.forEach { - println(it.id) - println(it.summary) - println(it) - println() - } + val events = + service.events().list(it.email) // "primary" refers to the user's primary calendar + .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) + .setTimeMax(DateTime(interval.to.toInstant().toEpochMilli())) + .setOrderBy("startTime") + .setSingleEvents(true) + .execute() + .items + .map { + println(it) + GoogleCalendarItem( + GoogleCalendarItemId(it.id), + it.summary, + it.description ?: "", + startDate(it), + duration(it), + it.location + ) + } + events + } - val now = Date() - val events = - service.events().list("aleksey.zhidkov@gmail.com") // "primary" refers to the user's primary calendar - .setTimeMin(com.google.api.client.util.DateTime(interval.from.toInstant().toEpochMilli())) - .setTimeMax(com.google.api.client.util.DateTime(interval.to.toInstant().toEpochMilli())) - .setOrderBy("startTime") - .setSingleEvents(true) - .setOauthToken(accessToken) // Set the access token - .execute() - .items - .forEach { - println(it.summary + "\n") - } - - return emptyList() + return events } -} + private fun startDate(event: Event): LocalDateTime = + ZonedDateTime.ofInstant( + Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), + ZoneId.of(event.start.timeZone) + ).toLocalDateTime() -fun main() { - GoogleCalendarsService().findCalendarItemsInInterval( - TherapistRef.to(UUIDv7.randomUUID()), - Interval.of(ZonedDateTime.now(), Duration.ofHours(1)) - ) + private fun duration(event: Event): Duration = + Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - + Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) } \ No newline at end of file diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index e22a3db3..dd4ecb24 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -44,15 +44,20 @@ spring: client-id: ${GOOGLE_CLIENT_ID:client-id} client-secret: ${GOOGLE_CLIENT_SECRET:client-secret} scope: + - openid + - email - https://www.googleapis.com/auth/calendar.readonly - redirect-uri: "http://localhost:8080/therapist/oauth2/google/callback" + redirect-uri: "http://localhost:${server.port:8080}/therapist/oauth2/google/callback" authorization-grant-type: authorization_code client-name: Trainer Advisor client-authentication-method: client_secret_post + provider: google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://openidconnect.googleapis.com/v1/userinfo + user-name-attribute: email management: endpoints: @@ -95,6 +100,10 @@ trainer-advisor: admin: email: ta@azhidkov.pro + integrations: + google-calendar: + root-url: https://www.googleapis.com/calendar/v3/ + logging: level: org.springframework.security: DEBUG diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html new file mode 100644 index 00000000..dc0b5e92 --- /dev/null +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/schedule.html b/app/src/main/resources/templates/therapist/appointments/schedule.html index 1a2c199d..95736765 100644 --- a/app/src/main/resources/templates/therapist/appointments/schedule.html +++ b/app/src/main/resources/templates/therapist/appointments/schedule.html @@ -75,30 +75,36 @@
- + + +
+ + - -
@@ -108,7 +114,6 @@
- Подключить Google Calendar
@@ -147,6 +152,31 @@
- + + diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt new file mode 100644 index 00000000..5efd8ccd --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt @@ -0,0 +1,102 @@ +package pro.qyoga.tests.cases.app.therapist.calendars.google + +import io.kotest.core.annotation.DisplayName +import io.kotest.matchers.shouldBe +import org.springframework.core.env.get +import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController +import pro.qyoga.app.therapist.oauth2.GoogleOAuthController +import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.tests.assertions.shouldBeRedirectToGoogleOAuth +import pro.qyoga.tests.clients.TherapistClient +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.oauth.OAuthObjectMother +import pro.qyoga.tests.fixture.oauth.OAuthObjectMother.aOAuth2AuthorizationResponse +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar +import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer +import pro.qyoga.tests.infra.test_config.spring.context +import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.infra.wiremock.WireMock +import pro.qyoga.tests.platform.spring.web_test_client.redirectLocation + + +@DisplayName("Интеграция с Google OAuth") +class GoogleAuthorizationIntegrationTest : QYogaAppIntegrationBaseKoTest({ + + val therapist by lazy { TherapistClient.loginAsTheTherapist() } + val clientId = context.environment["spring.security.oauth2.client.registration.google.client-id"]!! + val clientSecret = context.environment["spring.security.oauth2.client.registration.google.client-secret"]!! + val googleCalendarsService = getBean() + + "Spring Security" - { + "при запросе на авторизацию в Google" - { + // Сетап + + // Действие + val redirectLocation = therapist.googleCalendarIntegration.authorizeInGoogle() + + "должна возвращать корректный редирект" { + redirectLocation.shouldBeRedirectToGoogleOAuth(clientId) + } + } + + "при корректном запросе с колбэком OAuth-авторизации" - { + // Сетап + val oAuthRequest = therapist.googleCalendarIntegration.authorizeInGoogle() + .let { OAuthObjectMother.oAuth2AuthorizationRequest(it) } + val aOAuthResponse = aOAuth2AuthorizationResponse(oAuthRequest.state) + + // Действие + val response = therapist.googleCalendarIntegration.handleOAuthCallbackForResponse(aOAuthResponse) + + "должна возвращать очищенный редирект на URL обработчика авторизации в Google" { + + val location = response.redirectLocation() + + location.path shouldBe GoogleOAuthController.PATH + } + } + + } + + "метод обработки результата авторизации" - { + "в случае успешной авторизации должен" - { + // Сетап + val googleEmail = faker.internet().emailAddress() + val mockGoogleOAuthServer = MockGoogleOAuthServer(WireMock.wiremock) + val oAuthRequest = therapist.googleCalendarIntegration.authorizeInGoogle() + .let { OAuthObjectMother.oAuth2AuthorizationRequest(it) } + val aOAuthResponse = aOAuth2AuthorizationResponse(oAuthRequest.state) + val accessToken = "accessToken" + val refreshToken = "refreshToken" + mockGoogleOAuthServer.OnGetToken(clientId, clientSecret, aOAuthResponse.code) + .returnsToken(accessToken, refreshToken) + mockGoogleOAuthServer.OnGetUserInfo(accessToken).returnsUserInfo(googleEmail) + + val calendars = emptyList() + therapist.googleCalendarIntegration.handleOAuthCallbackForResponse(aOAuthResponse) + + // Действие + val response = therapist.googleCalendarIntegration.finalizeOAuthCallbackForResponse() + + // Проверка + "обеспечивать возможность дальнейших запросов к Google Calendar" { + val mockGoogleCalendar = MockGoogleCalendar(WireMock.wiremock) + mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) + mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars(calendars) + + val gotCalendars = googleCalendarsService.findCalendars(THE_THERAPIST_REF) + gotCalendars shouldBe calendars + } + + "возвращать редирект на страницу календаря с параметром google-connected=true" { + with(response.redirectLocation()) { + path shouldBe SchedulePageController.PATH + query shouldBe "google_connected=true" + } + } + } + } + +}) \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt new file mode 100644 index 00000000..9622214c --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt @@ -0,0 +1,23 @@ +package pro.qyoga.tests.cases.app.therapist.calendars.google + +import io.kotest.core.annotation.DisplayName +import pro.qyoga.tests.assertions.shouldHaveComponent +import pro.qyoga.tests.clients.TherapistClient +import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.pages.therapist.appointments.GoogleCalendarSettingsComponent + + +@DisplayName("UI-компонент настройки интеграции с Google Calendar") +class GoogleCalendarsSettingsTest : QYogaAppIntegrationBaseKoTest({ + + "должен корректно рендерится для терапевта без настроенной интеграции" { + // Сетап + val therapist = TherapistClient.loginAsTheTherapist() + + // Действие + val res = therapist.appointments.getGoogleCalendarComponent() + + res shouldHaveComponent GoogleCalendarSettingsComponent + } + +}) \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt index e7328a3f..8da48227 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt @@ -8,16 +8,21 @@ import io.restassured.module.kotlin.extensions.When import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.springframework.http.HttpStatus +import org.springframework.test.web.reactive.server.WebTestClient import pro.qyoga.tests.clients.api.* import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_LOGIN import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_PASSWORD +import pro.qyoga.tests.infra.web.mainWebTestClient -class TherapistClient(val authCookie: Cookie) { - +class TherapistClient( + val authCookie: Cookie, + webTestClient: WebTestClient = mainWebTestClient +) { // Work - val appointments = TherapistAppointmentsApi(authCookie) + val appointments = TherapistAppointmentsApi(authCookie, webTestClient) + val googleCalendarIntegration = TherapistGoogleCalendarIntegrationApi(authCookie, webTestClient) val clients = TherapistClientsApi(authCookie) val clientJournal = TherapistClientJournalApi(authCookie) val clientFiles = TherapistClientFilesApi(authCookie) diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/AuthorizedApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/AuthorizedApi.kt index 14c63e4d..2e491dd3 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/AuthorizedApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/AuthorizedApi.kt @@ -2,6 +2,7 @@ package pro.qyoga.tests.clients.api import io.restassured.http.Cookie import io.restassured.specification.RequestSpecification +import org.springframework.test.web.reactive.server.WebTestClient interface AuthorizedApi { @@ -12,4 +13,8 @@ interface AuthorizedApi { return cookie(authCookie) } + fun WebTestClient.RequestHeadersSpec<*>.authorized(): WebTestClient.RequestHeadersSpec<*> { + return cookie(authCookie.name, authCookie.value) + } + } \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt index 9e05efb6..265c353c 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt @@ -11,21 +11,27 @@ import org.hamcrest.CoreMatchers.nullValue import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.springframework.http.HttpStatus +import org.springframework.test.web.reactive.server.WebTestClient import pro.azhidkov.platform.java.time.toLocalTimeString import pro.qyoga.app.therapist.appointments.core.edit.CreateAppointmentPageController import pro.qyoga.app.therapist.appointments.core.edit.EditAppointmentPageController import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel +import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.AppointmentRef import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage import pro.qyoga.tests.pages.therapist.appointments.EditAppointmentPage +import pro.qyoga.tests.platform.spring.web_test_client.getBodyAsString import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter -class TherapistAppointmentsApi(override val authCookie: Cookie) : AuthorizedApi { +class TherapistAppointmentsApi( + override val authCookie: Cookie, + private val webTestClient: WebTestClient +) : AuthorizedApi { fun getScheduleForDay(date: LocalDate? = null, appointmentToFocus: AppointmentRef? = null): Document { return Given { @@ -188,4 +194,14 @@ class TherapistAppointmentsApi(override val authCookie: Cookie) : AuthorizedApi } } + fun getGoogleCalendarComponent(): Document { + return webTestClient.get() + .uri(GoogleCalendarSettingsController.PATH) + .authorized() + .exchange() + .expectStatus().isOk + .getBodyAsString() + .let { Jsoup.parse(it) } + } + } diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt new file mode 100644 index 00000000..8d840a36 --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt @@ -0,0 +1,49 @@ +package pro.qyoga.tests.clients.api + +import io.restassured.http.Cookie +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse +import org.springframework.test.web.reactive.server.WebTestClient +import pro.qyoga.tests.platform.spring.web_test_client.redirectLocation +import java.net.URI + +class TherapistGoogleCalendarIntegrationApi( + override val authCookie: Cookie, + private val webTestClient: WebTestClient +) : AuthorizedApi { + + fun authorizeInGoogle(): URI { + val response = webTestClient.get() + .uri("/oauth2/authorization/google") + .authorized() + .exchange() + .expectStatus().isFound + + return response.redirectLocation() + } + + // curl 'http://localhost:8080/therapist/oauth2/google/callback? + // state=EWWVlMDBuTT8NzQvO1MZjrpRa3Kr6Jz_8WRkK9d3gBA%3D^& + // code=4/0AVMBsJi2rnHTaWamYvvRVOxmp8CkQQ4ymoRZsiASDMjPnX7phooZ-P5YnOyQuunowx8F2g^& + // scope=https://www.googleapis.com/auth/calendar.readonly' \ + fun handleOAuthCallbackForResponse( + authResponse: OAuth2AuthorizationResponse + ): WebTestClient.ResponseSpec { + return webTestClient.get() + .uri { + it.path("/therapist/oauth2/google/callback") + .queryParam("state", authResponse.state) + .queryParam("code", authResponse.code) + .build() + } + .authorized() + .exchange() + } + + fun finalizeOAuthCallbackForResponse(): WebTestClient.ResponseSpec { + return webTestClient.get() + .uri("/therapist/oauth2/google/callback") + .authorized() + .exchange() + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/GoogleOAuthMatchers.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/GoogleOAuthMatchers.kt new file mode 100644 index 00000000..006f321f --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/GoogleOAuthMatchers.kt @@ -0,0 +1,27 @@ +package pro.qyoga.tests.assertions + +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.string.shouldEndWith +import io.kotest.matchers.uri.shouldHaveHost +import io.kotest.matchers.uri.shouldHavePath +import io.kotest.matchers.uri.shouldHaveScheme +import org.springframework.web.util.UriComponentsBuilder +import pro.qyoga.app.therapist.oauth2.GoogleOAuthController +import pro.qyoga.tests.fixture.oauth.OAuthObjectMother +import java.net.URI + +// https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent&response_type=code&client_id=000000000000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.apps.googleusercontent.com&scope=https://www.googleapis.com/auth/calendar.readonly&state=hpPCah40YNgllwaMf4MPQdiGGedlmOJHq33zGtuvlAU%3D&redirect_uri=http://localhost:8080/therapist/oauth2/google/callback +fun URI.shouldBeRedirectToGoogleOAuth(clientId: String) { + this shouldHaveScheme OAuthObjectMother.googleAuthUri.scheme + this shouldHaveHost OAuthObjectMother.googleAuthUri.host + this shouldHavePath OAuthObjectMother.googleAuthUri.path + + val queryParams = UriComponentsBuilder.fromUri(this).build().queryParams + queryParams.shouldContainValue("access_type", "offline") + queryParams.shouldContainValue("prompt", "consent") + queryParams.shouldContainValue("response_type", "code") + queryParams.shouldContainValue("client_id", clientId) + queryParams.shouldContainValue("scope", "openid%20email%20https://www.googleapis.com/auth/calendar.readonly") + queryParams.shouldContainKey("state") + queryParams["redirect_uri"]!!.single() shouldEndWith GoogleOAuthController.PATH +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/MultiValueMapAssertions.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/MultiValueMapAssertions.kt new file mode 100644 index 00000000..44c952c7 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/MultiValueMapAssertions.kt @@ -0,0 +1,11 @@ +package pro.qyoga.tests.assertions + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.maps.shouldContainKey +import org.springframework.util.MultiValueMap + + +fun MultiValueMap.shouldContainValue(key: K, value: V) { + this shouldContainKey key + this[key]!! shouldContain value +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Text.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Text.kt index fc6e9f97..9a584603 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Text.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Text.kt @@ -1,5 +1,8 @@ package pro.qyoga.tests.fixture.data +import net.datafaker.Faker +import java.util.* + val lowerCaseCyrillicLetters = ('а'..'я').toList() val upperCaseCyrillicLetters = ('А'..'Я').toList() val cyrillicLetters = lowerCaseCyrillicLetters + upperCaseCyrillicLetters @@ -39,4 +42,15 @@ fun randomEmail(): String = append(randomWord(lowerCaseLatinLetters, 2, 3)) } -fun randomPassword() = randomLatinWord(minLength = 8) \ No newline at end of file +fun randomPassword() = randomLatinWord(minLength = 8) + +fun Faker.randomBase64String( + bytes: Int = 32, + urlSafe: Boolean = false, + withPadding: Boolean = false +): String { + val data = this.random().nextRandomBytes(bytes) + val encoder = if (urlSafe) Base64.getUrlEncoder() else Base64.getEncoder() + val finalEncoder = if (withPadding) encoder else encoder.withoutPadding() + return finalEncoder.encodeToString(data) +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/oauth/OAuthObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/oauth/OAuthObjectMother.kt new file mode 100644 index 00000000..1bf21d16 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/oauth/OAuthObjectMother.kt @@ -0,0 +1,32 @@ +package pro.qyoga.tests.fixture.oauth + +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse +import org.springframework.web.util.UriComponentsBuilder +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.data.randomBase64String +import java.net.URI +import java.net.URLDecoder + +object OAuthObjectMother { + + val googleAuthUri: URI = URI.create("https://accounts.google.com/o/oauth2/v2/auth") + + fun oAuth2AuthorizationRequest(redirectUri: URI): OAuth2AuthorizationRequest { + val queryParams = UriComponentsBuilder.fromUri(redirectUri).build().queryParams + + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(googleAuthUri.toString()) + .clientId(queryParams["client_id"]?.single()?.let { URLDecoder.decode(it, Charsets.UTF_8) }) + .scopes(queryParams["scope"]?.map { URLDecoder.decode(it, Charsets.UTF_8) }?.toSet()?.toSet()) + .state(queryParams["state"]?.single()?.let { URLDecoder.decode(it, Charsets.UTF_8) }) + .build() + } + + fun aOAuth2AuthorizationResponse(state: String): OAuth2AuthorizationResponse = + OAuth2AuthorizationResponse.success(faker.randomBase64String()) + .state(state) + .redirectUri("/") + .build() + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt new file mode 100644 index 00000000..06714be3 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -0,0 +1,50 @@ +package pro.qyoga.tests.fixture.wiremocks + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.equalTo +import org.springframework.http.HttpStatus +import pro.qyoga.core.calendar.api.Calendar + + +class MockGoogleCalendar( + private val wiremockServer: WireMockServer +) { + + inner class OnGetCalendars( + private val accessToken: String + ) { + fun returnsCalendars( + calendars: List + ) { + wiremockServer.stubFor( + WireMock.get( + WireMock.urlEqualTo( + "/google/calendar/v3/users/me/calendarList" + ) + ) + .withHeader("Authorization", equalTo("Bearer $accessToken")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "items": [${calendars.joinToString(",") { it.toJson() }}] + } + """ + ) + ) + ) + } + } + +} + +private fun Calendar.toJson(): String = + """ + { + } + """.trimIndent() diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt new file mode 100644 index 00000000..153895ee --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt @@ -0,0 +1,106 @@ +package pro.qyoga.tests.fixture.wiremocks + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.* +import org.springframework.http.HttpStatus +import pro.qyoga.app.therapist.oauth2.GoogleOAuthController + + +class MockGoogleOAuthServer( + private val wiremockServer: WireMockServer +) { + + inner class OnGetToken( + private val clientId: String, + private val clientSecret: String, + private val code: String, + ) { + + fun returnsToken( + authToken: String = "authToken", + refreshToken: String = "refreshToken" + ) { + + wiremockServer + .stubFor( + post(urlEqualTo("/google/oauth/token")) + .withFormParam("grant_type", equalTo("authorization_code")) + .withFormParam("code", equalTo(code.replace("+", " "))) + .withFormParam("redirect_uri", matching(".*${GoogleOAuthController.PATH}")) + .withFormParam("client_id", equalTo(clientId)) + .withFormParam("client_secret", equalTo(clientSecret)) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "access_token": "$authToken", + "token_type": "Bearer", + "expires_in": 3599, + "refresh_token": "$refreshToken", + "scope": "https://www.googleapis.com/auth/calendar" + } + """ + ) + ) + ) + } + + } + + inner class OnGetUserInfo(private val accessToken: String) { + + fun returnsUserInfo(googleEmail: String) { + wiremockServer.stubFor( + get(urlEqualTo("/google/oauth/userinfo")) + .withHeader("Authorization", equalTo("Bearer $accessToken")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "email": "$googleEmail" + } + """ + ) + ) + ) + } + + } + + inner class OnRefreshToken(private val refreshToken: String) { + + fun returnsToken( + authToken: String = "authToken" + ) { + wiremockServer + .stubFor( + post(urlEqualTo("/google/oauth/token")) + .withFormParam("grant_type", equalTo("refresh_token")) + .withFormParam("refresh_token", equalTo(refreshToken)) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "access_token": "$authToken", + "token_type": "Bearer", + "expires_in": 3599, + "refresh_token": "$refreshToken", + "scope": "https://www.googleapis.com/auth/calendar" + } + """ + ) + ) + ) + } + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/db/TestDb.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/db/TestDb.kt index e84d44bf..a59c4f19 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/db/TestDb.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/db/TestDb.kt @@ -15,7 +15,7 @@ private val log = LoggerFactory.getLogger(TestDb::class.java) private const val DB_USER = "postgres" private const val DB_PASSWORD = "password" -val jdbcUrl: String by lazy { +private val testDbJdbcUrl: String by lazy { try { log.info("Checking for provided db") val con = DriverManager.getConnection( @@ -41,9 +41,10 @@ val jdbcUrl: String by lazy { val testDataSource by lazy { val config = HikariConfig().apply { - this.jdbcUrl = pro.qyoga.tests.infra.db.jdbcUrl + this.jdbcUrl = testDbJdbcUrl this.username = DB_USER this.password = DB_PASSWORD + this.minimumIdle = 0 } HikariDataSource(config) } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt new file mode 100644 index 00000000..c0e7a4a3 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt @@ -0,0 +1,25 @@ +package pro.qyoga.tests.pages.therapist.appointments + +import io.kotest.matchers.Matcher +import org.jsoup.nodes.Element +import pro.qyoga.tests.assertions.haveComponent +import pro.qyoga.tests.platform.html.Component +import pro.qyoga.tests.platform.html.Link +import pro.qyoga.tests.platform.kotest.all + + +object GoogleCalendarSettingsComponent : Component { + + private val connectButton = + Link("connect-google-calendar", "/oauth2/authorization/google", "Подключить Google Calendar") + + override fun selector(): String = + "#google-calendar-settings" + + override fun matcher(): Matcher { + return Matcher.all( + haveComponent(connectButton) + ) + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsLoaderComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsLoaderComponent.kt new file mode 100644 index 00000000..15112122 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsLoaderComponent.kt @@ -0,0 +1,20 @@ +package pro.qyoga.tests.pages.therapist.appointments + +import io.kotest.matchers.Matcher +import org.jsoup.nodes.Element +import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController +import pro.qyoga.tests.assertions.haveAttributeValue +import pro.qyoga.tests.platform.html.Component +import pro.qyoga.tests.platform.kotest.all + + +object GoogleCalendarSettingsLoaderComponent : Component { + + override fun selector(): String = "#google-calendar-settings-container" + + override fun matcher(): Matcher = + Matcher.all( + haveAttributeValue("hx-get", GoogleCalendarSettingsController.PATH) + ) + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index 7031170e..6d649b66 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -40,6 +40,7 @@ object CalendarPage : HtmlPage { override val matcher = Matcher.all( haveComponent(datePickerButton), + haveComponent(GoogleCalendarSettingsLoaderComponent), haveElement("small:contains(07:00)"), haveComponents(goToDayLink, CalendarPageModel.DAYS_IN_WEEK), haveAtLeastElements( @@ -60,7 +61,7 @@ infix fun Elements.shouldMatch(appointments: Iterable) { this shouldBeSameSizeAs appointments val timeAndDateComparator = Comparator.comparing { it.wallClockDateTime.toLocalTime() } - .thenComparing { it -> it.wallClockDateTime.toLocalDate() } + .thenComparing { it.wallClockDateTime.toLocalDate() } val appointmentsInHtmlOrder = appointments.sortedWith(timeAndDateComparator) this.zip(appointmentsInHtmlOrder).forAll { (el, app) -> diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/spring/web_test_client/ResponseSpecExt.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/spring/web_test_client/ResponseSpecExt.kt new file mode 100644 index 00000000..0aa22851 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/spring/web_test_client/ResponseSpecExt.kt @@ -0,0 +1,19 @@ +package pro.qyoga.tests.platform.spring.web_test_client + +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.returnResult +import java.net.URI + + +fun WebTestClient.ResponseSpec.getBodyAsString(): String = + this.returnResult(String::class.java) + .responseBody + .collectList() + .block()!! + .joinToString("\n") + +fun WebTestClient.ResponseSpec.redirectLocation(): URI = + this + .expectStatus().isFound.returnResult() + .responseHeaders + .location!! \ No newline at end of file diff --git a/app/src/testFixtures/resources/application-test.yaml b/app/src/testFixtures/resources/application-test.yaml index bacf92c0..9a5b69a9 100644 --- a/app/src/testFixtures/resources/application-test.yaml +++ b/app/src/testFixtures/resources/application-test.yaml @@ -13,4 +13,18 @@ spring: smtp: auth: true starttls: - enable: true \ No newline at end of file + enable: true + + security: + oauth2: + client: + provider: + google: + token-uri: http://localhost:8089/google/oauth/token + user-info-uri: http://localhost:8089/google/oauth/userinfo + +trainer-advisor: + + integrations: + google-calendar: + root-url: http://localhost:8089/google/ \ No newline at end of file diff --git a/deploy/qyoga/docker-compose.yml b/deploy/qyoga/docker-compose.yml index d66fd250..5b2194b0 100644 --- a/deploy/qyoga/docker-compose.yml +++ b/deploy/qyoga/docker-compose.yml @@ -31,6 +31,8 @@ services: - SPRING_PROFILES_ACTIVE=prod - SPRING_MAIL_PASSWORD=${SPRING_MAIL_PASSWORD} - MINIO_ENDPOINT=http://minio:9000 + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} restart: always From ba181b963b1ddf66cebb246c7025761fb678be7b Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 11 Sep 2025 10:35:42 +0700 Subject: [PATCH 08/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20Google-=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B2=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth2/GoogleCallbackController.kt | 3 +-- .../core/calendar/google/GoogleAccount.kt | 12 +++++++++++- .../calendar/google/GoogleAccountsRepo.kt | 19 +++++++++++++------ .../calendar/google/GoogleCalendarsService.kt | 7 +++---- .../V25091101__add_google_calendars.sql | 7 +++++++ .../resources/db/shared-fixture.sql | 3 ++- 6 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index 1e449d8e..92b8fd26 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -45,8 +45,7 @@ class GoogleOAuthController( ?.get("email") as String googleCalendarsService.addGoogleAccount( - therapistId, - GoogleAccount(email, authorizedClient.refreshToken!!.tokenValue) + GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) ) return "redirect:/therapist/schedule?google_connected=true" diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt index 9a42379b..a375ef78 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -1,7 +1,17 @@ package pro.qyoga.core.calendar.google +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import pro.azhidkov.platform.uuid.UUIDv7 +import pro.qyoga.core.users.therapists.TherapistRef +import java.util.* + +@Table("therapist_google_accounts") data class GoogleAccount( + val ownerRef: TherapistRef, val email: String, - val refreshToken: String + val refreshToken: String, + + @Id val id: UUID = UUIDv7.randomUUID() ) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt index 1092ef82..8d9c154f 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsRepo.kt @@ -1,18 +1,25 @@ package pro.qyoga.core.calendar.google +import org.springframework.data.jdbc.core.JdbcAggregateTemplate +import org.springframework.stereotype.Repository +import pro.azhidkov.platform.spring.sdj.query.query import pro.qyoga.core.users.therapists.TherapistRef -class GoogleAccountsRepo { +@Repository +class GoogleAccountsRepo( + private val jdbcAggregateTemplate: JdbcAggregateTemplate +) { - private val repo = HashMap>() - - fun addGoogleAccount(therapist: TherapistRef, googleAccount: GoogleAccount) { - repo[therapist] = repo.getOrDefault(therapist, emptyList()) + googleAccount + fun addGoogleAccount(googleAccount: GoogleAccount) { + jdbcAggregateTemplate.insert(googleAccount) } fun findGoogleAccounts(therapist: TherapistRef): List { - return repo[therapist] ?: emptyList() + val query = query { + GoogleAccount::ownerRef isEqual therapist + } + return jdbcAggregateTemplate.findAll(query, GoogleAccount::class.java) } } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 28211427..dd73d1d0 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -26,16 +26,15 @@ val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport @Service class GoogleCalendarsService( private val googleOAuthProps: OAuth2ClientProperties, + private val googleAccountsRepo: GoogleAccountsRepo, @Value("\${spring.security.oauth2.client.provider.google.token-uri}") private val tokenUri: URI, @Value("\${trainer-advisor.integrations.google-calendar.root-url}") private val googleCalendarRootUri: URI ) : CalendarsService { - private val googleAccountsRepo = GoogleAccountsRepo() - private val googleCalendarsRepo = GoogleCalendarsRepo() - fun addGoogleAccount(therapist: TherapistRef, googleAccount: GoogleAccount) { - googleAccountsRepo.addGoogleAccount(therapist, googleAccount) + fun addGoogleAccount(googleAccount: GoogleAccount) { + googleAccountsRepo.addGoogleAccount(googleAccount) } fun findCalendars( diff --git a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql new file mode 100644 index 00000000..bd638321 --- /dev/null +++ b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql @@ -0,0 +1,7 @@ +CREATE TABLE therapist_google_accounts +( + id UUID PRIMARY KEY, + owner_ref UUID REFERENCES therapists NOT NULL, + email varchar NOT NULL, + refresh_token varchar NOT NULL +) \ No newline at end of file diff --git a/app/src/testFixtures/resources/db/shared-fixture.sql b/app/src/testFixtures/resources/db/shared-fixture.sql index ff7d8d1c..28cf5e54 100644 --- a/app/src/testFixtures/resources/db/shared-fixture.sql +++ b/app/src/testFixtures/resources/db/shared-fixture.sql @@ -6,7 +6,8 @@ TRUNCATE programs, program_exercises, appointments, appointment_types, survey_forms_settings, - ical_calendars + ical_calendars, + therapist_google_accounts RESTART IDENTITY; INSERT INTO users (id, email, password_hash, roles, created_at, version) From 37593562030cc01c5afb1ce8da58c8e602a3cb69 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 11 Sep 2025 11:43:29 +0700 Subject: [PATCH 09/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoogleCalendarSettingsController.kt | 26 ++++++++++++++-- .../calendar/google/GoogleCalendarsService.kt | 31 +++++++++++++++++++ app/src/main/resources/application.yaml | 6 ++-- .../google-settings-component.html | 17 ++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt index aa406a40..30b21d60 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -1,15 +1,35 @@ package pro.qyoga.app.therapist.appointments.core.schedule +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.servlet.ModelAndView +import pro.qyoga.core.calendar.google.GoogleAccountCalendars +import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.core.users.auth.dtos.QyogaUserDetails +import pro.qyoga.core.users.therapists.ref +data class GoogleCalendarSettingsPageModel( + val accounts: List +) : ModelAndView("therapist/appointments/google-settings-component.html") { + + init { + addObject("accounts", accounts) + } + +} @Controller -class GoogleCalendarSettingsController { +class GoogleCalendarSettingsController( + private val googleCalendarsService: GoogleCalendarsService +) { @GetMapping(PATH) - fun getGoogleCalendarSettingsComponent(): String { - return "therapist/appointments/google-settings-component.html" + fun getGoogleCalendarSettingsComponent( + @AuthenticationPrincipal therapist: QyogaUserDetails + ): GoogleCalendarSettingsPageModel { + val googleAccounts = googleCalendarsService.findGoogleAccountCalendars(therapist.ref) + return GoogleCalendarSettingsPageModel(googleAccounts) } companion object { diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index dd73d1d0..ac87a143 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -23,6 +23,16 @@ const val APPLICATION_NAME = "Trainer Advisor" val gsonFactory: GsonFactory = GsonFactory.getDefaultInstance() val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() +data class GoogleCalendarView( + val title: String, + val shouldBeShown: Boolean +) + +data class GoogleAccountCalendars( + val email: String, + val calendars: List +) + @Service class GoogleCalendarsService( private val googleOAuthProps: OAuth2ClientProperties, @@ -37,6 +47,20 @@ class GoogleCalendarsService( googleAccountsRepo.addGoogleAccount(googleAccount) } + fun findGoogleAccountCalendars( + therapist: TherapistRef + ): List { + val accounts = googleAccountsRepo.findGoogleAccounts(therapist) + return accounts.map { + GoogleAccountCalendars( + it.email, + getAccountCalendars(therapist, it).map { + GoogleCalendarView(it.name, false) + } + ) + } + } + fun findCalendars( therapist: TherapistRef ): List { @@ -47,6 +71,13 @@ class GoogleCalendarsService( val account = accounts.single() + return getAccountCalendars(therapist, account) + } + + private fun getAccountCalendars( + therapist: TherapistRef, + account: GoogleAccount + ): List { val credentials = UserCredentials.newBuilder() .setClientId(googleOAuthProps.registration["google"]!!.clientId) .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index dd4ecb24..1986f1c0 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -70,9 +70,9 @@ management: endpoint: configprops: show-values: when_authorized - enabled: true + access: read_only env: - enabled: true + access: read_only show-values: when_authorized info: @@ -102,7 +102,7 @@ trainer-advisor: integrations: google-calendar: - root-url: https://www.googleapis.com/calendar/v3/ + root-url: https://www.googleapis.com/ logging: level: diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index dc0b5e92..98a0386a 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -1,5 +1,22 @@
Google Calendar
+
+
+
+
email@example.com
+
    +
  • + Calendar name +
    + +
    +
  • +
+
+
+
From d233a7131fb7ff004fb0db2ed37551076f77b9ee Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 11 Sep 2025 12:15:02 +0700 Subject: [PATCH 10/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BA=D1=8D=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=BE=D0=B2=20Google=20=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/schedule/CalendarPageModel.kt | 2 + .../calendar/google/GoogleCalendarsService.kt | 42 ++++++++++--------- .../core/calendar/ical/ICalCalendarsRepo.kt | 2 +- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index 1cc6a9c8..db410745 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -1,3 +1,5 @@ +@file:Suppress("IDENTITY_SENSITIVE_OPERATIONS_WITH_VALUE_TYPE") + package pro.qyoga.app.therapist.appointments.core.schedule import org.springframework.web.servlet.ModelAndView diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index ac87a143..76c3737d 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -43,6 +43,9 @@ class GoogleCalendarsService( private val googleCalendarsRepo = GoogleCalendarsRepo() + private val servicesCache = mutableMapOf() + .withDefault { createCalendarService(it) } + fun addGoogleAccount(googleAccount: GoogleAccount) { googleAccountsRepo.addGoogleAccount(googleAccount) } @@ -78,17 +81,7 @@ class GoogleCalendarsService( therapist: TherapistRef, account: GoogleAccount ): List { - val credentials = UserCredentials.newBuilder() - .setClientId(googleOAuthProps.registration["google"]!!.clientId) - .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(account.refreshToken) - .setTokenServerUri(tokenUri) - .build() - - val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) - .setApplicationName(APPLICATION_NAME) - .setRootUrl(googleCalendarRootUri.toURL().toString()) - .build() + val service = servicesCache.getValue(account) return service.CalendarList().list() .execute().items.map { @@ -106,15 +99,7 @@ class GoogleCalendarsService( } val events = accounts.flatMap { - val credentials = UserCredentials.newBuilder() - .setClientId(googleOAuthProps.registration["google"]!!.clientId) - .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(it.refreshToken) - .build() - - val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) - .setApplicationName(APPLICATION_NAME) - .build() + val service = servicesCache.getValue(it) val events = service.events().list(it.email) // "primary" refers to the user's primary calendar @@ -141,6 +126,22 @@ class GoogleCalendarsService( return events } + private fun createCalendarService(account: GoogleAccount): Calendar { + val credentials = UserCredentials.newBuilder() + .setClientId(googleOAuthProps.registration["google"]!!.clientId) + .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) + .setRefreshToken(account.refreshToken) + .setRefreshToken(account.refreshToken) + .setTokenServerUri(tokenUri) + .build() + + val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) + .setApplicationName(APPLICATION_NAME) + .setRootUrl(googleCalendarRootUri.toURL().toString()) + .build() + return service + } + private fun startDate(event: Event): LocalDateTime = ZonedDateTime.ofInstant( Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), @@ -150,4 +151,5 @@ class GoogleCalendarsService( private fun duration(event: Event): Duration = Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) + } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt index acd9581e..fc19c94e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt @@ -52,7 +52,7 @@ class ICalCalendarsRepo( @Scheduled(cron = "0 */10 * * * *") fun sync() { - log.info("Syncing calendars") + log.info("Syncing ical calendars") iCalCalendarsDao.forAll { Sync.syncCalendar(iCalCalendarsDao, it) } From e11841929c6923ae2dd71288c0ffa99437213733 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 12 Sep 2025 12:11:36 +0700 Subject: [PATCH 11/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=BA=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D1=80=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=B3=D1=83=D0=B3=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Попутно добавлено кэширование календарей и эвентов, чтобы повысить UX возврата в приложение и навешан констрейнт уникальности на гугл аккаунт терапевта --- app/build.gradle.kts | 4 +- app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt | 4 +- .../GoogleCalendarSettingsController.kt | 5 ++- .../oauth2/GoogleCallbackController.kt | 9 +++- .../calendar/google/GoogleCalendarConf.kt | 42 ++++++++++++++++++- .../calendar/google/GoogleCalendarsService.kt | 20 +++++++-- .../kotlin/pro/qyoga/infra/cache/CacheConf.kt | 9 ++++ .../V25091101__add_google_calendars.sql | 4 +- .../google-settings-component.html | 26 ++++++------ .../therapist/appointments/schedule.html | 16 +++++++ .../GetGoogleCalendarsSettingsEndpointTest.kt | 26 ++++++++++++ .../google/GoogleCalendarsSettingsTest.kt | 23 ---------- .../GoogleCalendarSettingsComponent.kt | 5 ++- 13 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 app/src/main/kotlin/pro/qyoga/infra/cache/CacheConf.kt create mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt delete mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efd4e564..8cfb19e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-database-postgresql") implementation(libs.jackarta.validation) implementation(libs.thymeleaf.extras.java8time) diff --git a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt index 8b78843e..cebc9eec 100644 --- a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt @@ -16,6 +16,7 @@ import pro.qyoga.core.therapy.TherapyConfig import pro.qyoga.core.users.UsersConfig import pro.qyoga.i9ns.email.EmailsConfig import pro.qyoga.infra.auth.AuthConfig +import pro.qyoga.infra.cache.CacheConf import pro.qyoga.infra.db.SdjConfig import pro.qyoga.infra.minio.MinioConfig import pro.qyoga.infra.timezones.TimeZonesConfig @@ -51,7 +52,8 @@ import pro.qyoga.tech.captcha.CaptchaConf ErgoSdjConfig::class, MinioConfig::class, FilesStorageConfig::class, - TimeZonesConfig::class + TimeZonesConfig::class, + CacheConf::class ) @SpringBootApplication class QYogaApp diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt index 30b21d60..199d019d 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -4,17 +4,18 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.servlet.ModelAndView -import pro.qyoga.core.calendar.google.GoogleAccountCalendars +import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref data class GoogleCalendarSettingsPageModel( - val accounts: List + val accounts: List ) : ModelAndView("therapist/appointments/google-settings-component.html") { init { addObject("accounts", accounts) + addObject("hasAccounts", accounts.isNotEmpty()) } } diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index 92b8fd26..e45829d6 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -37,18 +37,23 @@ class GoogleOAuthController( ): String { val therapistId = TherapistRef.to(userDetails.id) - val email = RestClient.create(googleOicUserInfoUri) + val response = RestClient.create(googleOicUserInfoUri) .get() .headers { it.setBearerAuth(authorizedClient.accessToken.tokenValue) } .retrieve() .body(Map::class.java) + val email = response ?.get("email") as String + val picture = response["picture"] as String googleCalendarsService.addGoogleAccount( GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) ) - return "redirect:/therapist/schedule?google_connected=true" + // Греем кэш, чтобы улучшить UX пользователя при возврате на страницу расписания + googleCalendarsService.findGoogleAccountCalendars(therapistId) + + return "redirect:/therapist/schedule?google-connected=true" } companion object { diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt index 7a88e006..aee85e45 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt @@ -1,9 +1,49 @@ package pro.qyoga.core.calendar.google +import com.github.benmanes.caffeine.cache.Caffeine +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.caffeine.CaffeineCache +import org.springframework.cache.support.SimpleCacheManager +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration +import java.util.concurrent.TimeUnit @ComponentScan @Configuration -class GoogleCalendarConf \ No newline at end of file +@EnableCaching +class GoogleCalendarConf { + + object CacheNames { + const val CALENDAR_EVENTS = "calendarEvents" + const val GOOGLE_ACCOUNT_CALENDARS = "googleAccountCalendars" + } + + @Bean + fun cacheManager(): CacheManager { + val eventsCache = CaffeineCache( + CacheNames.CALENDAR_EVENTS, + Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .maximumSize(5_000) + .recordStats() + .build() + ) + + val calendarsCache = CaffeineCache( + CacheNames.GOOGLE_ACCOUNT_CALENDARS, + Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.MINUTES) + .maximumSize(1_000) + .recordStats() + .build() + ) + + return SimpleCacheManager().apply { + setCaches(listOf(eventsCache, calendarsCache)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 76c3737d..191de6e5 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -10,6 +10,8 @@ import com.google.auth.http.HttpCredentialsAdapter import com.google.auth.oauth2.UserCredentials import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.calendar.api.CalendarItem @@ -28,7 +30,7 @@ data class GoogleCalendarView( val shouldBeShown: Boolean ) -data class GoogleAccountCalendars( +data class GoogleAccountCalendarsView( val email: String, val calendars: List ) @@ -46,16 +48,24 @@ class GoogleCalendarsService( private val servicesCache = mutableMapOf() .withDefault { createCalendarService(it) } + @CacheEvict( + cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], + key = "#googleAccount.ownerRef.id" + ) fun addGoogleAccount(googleAccount: GoogleAccount) { googleAccountsRepo.addGoogleAccount(googleAccount) } + @Cacheable( + cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], + key = "#therapist.id" + ) fun findGoogleAccountCalendars( therapist: TherapistRef - ): List { + ): List { val accounts = googleAccountsRepo.findGoogleAccounts(therapist) return accounts.map { - GoogleAccountCalendars( + GoogleAccountCalendarsView( it.email, getAccountCalendars(therapist, it).map { GoogleCalendarView(it.name, false) @@ -89,6 +99,10 @@ class GoogleCalendarsService( } } + @Cacheable( + cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], + key = "#therapist.id + ':' + #interval.from.toInstant().toEpochMilli() + ':' + #interval.to.toInstant().toEpochMilli()" + ) override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval diff --git a/app/src/main/kotlin/pro/qyoga/infra/cache/CacheConf.kt b/app/src/main/kotlin/pro/qyoga/infra/cache/CacheConf.kt new file mode 100644 index 00000000..9b45cf20 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/infra/cache/CacheConf.kt @@ -0,0 +1,9 @@ +package pro.qyoga.infra.cache + +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Configuration + + +@EnableCaching +@Configuration +class CacheConf \ No newline at end of file diff --git a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql index bd638321..09459218 100644 --- a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql +++ b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql @@ -3,5 +3,7 @@ CREATE TABLE therapist_google_accounts id UUID PRIMARY KEY, owner_ref UUID REFERENCES therapists NOT NULL, email varchar NOT NULL, - refresh_token varchar NOT NULL + refresh_token varchar NOT NULL, + + UNIQUE (owner_ref, email) ) \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index 98a0386a..89882712 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -1,11 +1,15 @@
Google Calendar
-
-
-
-
email@example.com
-
    -
  • + \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/schedule.html b/app/src/main/resources/templates/therapist/appointments/schedule.html index 95736765..898d8aec 100644 --- a/app/src/main/resources/templates/therapist/appointments/schedule.html +++ b/app/src/main/resources/templates/therapist/appointments/schedule.html @@ -65,7 +65,23 @@ revealAppointment(); }); + + + diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt new file mode 100644 index 00000000..88e0b8ec --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt @@ -0,0 +1,26 @@ +package pro.qyoga.tests.cases.app.therapist.calendars.google + +import io.kotest.core.annotation.DisplayName +import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.tests.assertions.shouldHaveComponent +import pro.qyoga.tests.clients.TherapistClient +import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.pages.therapist.appointments.GoogleCalendarSettingsComponent + + +@DisplayName("Эндпоинт получения компонента настройки интеграции с Google Calendar") +class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ + + "должен возвращать пустой список аккаунтов для терапевта без настроенной интеграции" { + // Сетап + val therapist = TherapistClient.loginAsTheTherapist() + val accounts = emptyList() + + // Действие + val res = therapist.appointments.getGoogleCalendarComponent() + + // Проверка + res shouldHaveComponent GoogleCalendarSettingsComponent(accounts) + } + +}) \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt deleted file mode 100644 index 9622214c..00000000 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleCalendarsSettingsTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package pro.qyoga.tests.cases.app.therapist.calendars.google - -import io.kotest.core.annotation.DisplayName -import pro.qyoga.tests.assertions.shouldHaveComponent -import pro.qyoga.tests.clients.TherapistClient -import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest -import pro.qyoga.tests.pages.therapist.appointments.GoogleCalendarSettingsComponent - - -@DisplayName("UI-компонент настройки интеграции с Google Calendar") -class GoogleCalendarsSettingsTest : QYogaAppIntegrationBaseKoTest({ - - "должен корректно рендерится для терапевта без настроенной интеграции" { - // Сетап - val therapist = TherapistClient.loginAsTheTherapist() - - // Действие - val res = therapist.appointments.getGoogleCalendarComponent() - - res shouldHaveComponent GoogleCalendarSettingsComponent - } - -}) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt index c0e7a4a3..81684f0b 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt @@ -2,13 +2,16 @@ package pro.qyoga.tests.pages.therapist.appointments import io.kotest.matchers.Matcher import org.jsoup.nodes.Element +import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView import pro.qyoga.tests.assertions.haveComponent import pro.qyoga.tests.platform.html.Component import pro.qyoga.tests.platform.html.Link import pro.qyoga.tests.platform.kotest.all -object GoogleCalendarSettingsComponent : Component { +class GoogleCalendarSettingsComponent( + private val accounts: List +) : Component { private val connectButton = Link("connect-google-calendar", "/oauth2/authorization/google", "Подключить Google Calendar") From 2b4f42188b3a711fd23a4d3cf6e555186c913f91 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Sun, 14 Sep 2025 11:59:20 +0700 Subject: [PATCH 12/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20Google-=D0=BA=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=80=D1=8F=20=D0=B2=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .run/QYogaApp.run.xml | 2 +- .../platform/spring/jdbc/RowMapperExt.kt | 19 +++++++ .../GoogleCalendarSettingsController.kt | 21 +++++++- .../therapist/clients/list/SearchClientsOp.kt | 21 ++------ .../oauth2/GoogleCallbackController.kt | 2 +- .../appointments/core/AppointmentsRepo.kt | 11 +--- ...leAccountsRepo.kt => GoogleAccountsDao.kt} | 2 +- .../calendar/google/GoogleCalendarsDao.kt | 51 +++++++++++++++++++ .../calendar/google/GoogleCalendarsService.kt | 46 ++++++++++------- .../main/resources/application-local-dev.yaml | 7 +++ app/src/main/resources/application.yaml | 2 +- .../V25091101__add_google_calendars.sql | 12 ++++- .../resources/templates/fragments/header.html | 1 + .../google-settings-component.html | 7 +++ .../core/CreateAppointmentPageTest.kt | 6 +-- .../appointments/core/SchedulePageTest.kt | 6 +-- .../GetGoogleCalendarsSettingsEndpointTest.kt | 2 +- .../GoogleAuthorizationIntegrationTest.kt | 24 ++++----- .../google/SetCalendarShouldBeShownTest.kt | 38 ++++++++++++++ .../calendar/ical/ICalCalendarsRepoTest.kt | 4 +- .../clients/api/TherapistAppointmentsApi.kt | 12 ----- .../TherapistGoogleCalendarIntegrationApi.kt | 28 ++++++++++ .../google/GoogleCalendarObjectMother.kt | 35 +++++++++++++ .../presets/AppointmentsFixturePresets.kt | 4 +- .../presets/GoogleCalendarFixturePresets.kt | 27 ++++++++++ ...ets.kt => ICalsCalendarsFixturePresets.kt} | 2 +- .../test_apis/GoogleCalendarTestApi.kt | 28 ++++++++++ .../tests/fixture/test_apis/TestApisConf.kt | 9 ++++ .../fixture/wiremocks/MockGoogleCalendar.kt | 8 +-- .../infra/test_config/spring/TestsConfig.kt | 2 + .../GoogleCalendarSettingsComponent.kt | 2 +- .../resources/db/shared-fixture.sql | 3 +- 33 files changed, 355 insertions(+), 90 deletions(-) rename app/src/main/kotlin/pro/qyoga/core/calendar/google/{GoogleAccountsRepo.kt => GoogleAccountsDao.kt} (96%) create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt create mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt rename app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/{CalendarsFixturePresets.kt => ICalsCalendarsFixturePresets.kt} (96%) create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt diff --git a/.gitignore b/.gitignore index 5f827af8..6fec507a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ out/ /build/ /deploy/host/secrets.sh +/app/src/main/resources/application-local-dev-secrets.yaml diff --git a/.run/QYogaApp.run.xml b/.run/QYogaApp.run.xml index 0d01998f..f52e0e45 100644 --- a/.run/QYogaApp.run.xml +++ b/.run/QYogaApp.run.xml @@ -1,7 +1,7 @@ -
  • diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 8e78bd8a..f41bbce1 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -21,7 +21,7 @@ import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMot import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother.randomFullEditAppointmentRequest import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDate import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem -import pro.qyoga.tests.fixture.presets.CalendarsFixturePresets +import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseTest import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentForm import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage @@ -43,7 +43,7 @@ private val aTime = LocalTime.now() @DisplayName("Страница создания приёма") class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { - private val calendarsFixturePresets = getBean() + private val ICalsCalendarsFixturePresets = getBean() @Test fun `должна рендерится корректно`() { @@ -159,7 +159,7 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { set(field(ICalCalendarItem::duration), Duration.ofMinutes(75)) set(field(ICalCalendarItem::description), randomSentence()) } - calendarsFixturePresets.createICalCalendarWithSingleEvent(event) + ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) // Действие val document = theTherapist.appointments.getCreateAppointmentPage( diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt index 3de46220..42e909fd 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt @@ -14,7 +14,7 @@ import pro.qyoga.tests.fixture.data.randomWorkingTime import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother import pro.qyoga.tests.fixture.object_mothers.appointments.DURATION_FOR_FULL_LABEL import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem -import pro.qyoga.tests.fixture.presets.CalendarsFixturePresets +import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseTest import pro.qyoga.tests.pages.therapist.appointments.CalendarPage import pro.qyoga.tests.pages.therapist.appointments.appointmentCards @@ -26,7 +26,7 @@ import java.time.LocalDate @DisplayName("Страница календаря") class SchedulePageTest : QYogaAppIntegrationBaseTest() { - private val calendarsFixturePresets = getBean() + private val ICalsCalendarsFixturePresets = getBean() @Test fun `должна корректно рендерить пустой календарь за текущую дату`() { @@ -116,7 +116,7 @@ class SchedulePageTest : QYogaAppIntegrationBaseTest() { set(field(ICalCalendarItem::duration), AppointmentsObjectMother.fullCardDuration) } - calendarsFixturePresets.createICalCalendarWithSingleEvent(event) + ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) // Действие val document = theTherapist.appointments.getScheduleForDay(today) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt index 88e0b8ec..91005ce2 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt @@ -17,7 +17,7 @@ class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ val accounts = emptyList() // Действие - val res = therapist.appointments.getGoogleCalendarComponent() + val res = therapist.googleCalendarIntegration.getGoogleCalendarComponent() // Проверка res shouldHaveComponent GoogleCalendarSettingsComponent(accounts) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt index 5efd8ccd..68c321eb 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt @@ -5,7 +5,7 @@ import io.kotest.matchers.shouldBe import org.springframework.core.env.get import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.oauth2.GoogleOAuthController -import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.tests.assertions.shouldBeRedirectToGoogleOAuth import pro.qyoga.tests.clients.TherapistClient @@ -65,37 +65,37 @@ class GoogleAuthorizationIntegrationTest : QYogaAppIntegrationBaseKoTest({ // Сетап val googleEmail = faker.internet().emailAddress() val mockGoogleOAuthServer = MockGoogleOAuthServer(WireMock.wiremock) + val mockGoogleCalendar = MockGoogleCalendar(WireMock.wiremock) val oAuthRequest = therapist.googleCalendarIntegration.authorizeInGoogle() .let { OAuthObjectMother.oAuth2AuthorizationRequest(it) } val aOAuthResponse = aOAuth2AuthorizationResponse(oAuthRequest.state) val accessToken = "accessToken" val refreshToken = "refreshToken" + val calendars = emptyList() mockGoogleOAuthServer.OnGetToken(clientId, clientSecret, aOAuthResponse.code) .returnsToken(accessToken, refreshToken) mockGoogleOAuthServer.OnGetUserInfo(accessToken).returnsUserInfo(googleEmail) + mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) + mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars(calendars) - val calendars = emptyList() therapist.googleCalendarIntegration.handleOAuthCallbackForResponse(aOAuthResponse) // Действие val response = therapist.googleCalendarIntegration.finalizeOAuthCallbackForResponse() // Проверка - "обеспечивать возможность дальнейших запросов к Google Calendar" { - val mockGoogleCalendar = MockGoogleCalendar(WireMock.wiremock) - mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) - mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars(calendars) - - val gotCalendars = googleCalendarsService.findCalendars(THE_THERAPIST_REF) - gotCalendars shouldBe calendars - } - "возвращать редирект на страницу календаря с параметром google-connected=true" { with(response.redirectLocation()) { path shouldBe SchedulePageController.PATH - query shouldBe "google_connected=true" + query shouldBe "google-connected=true" } } + + "обеспечивать возможность дальнейших запросов к Google Calendar" { + val gotCalendars = googleCalendarsService.findCalendars(THE_THERAPIST_REF) + gotCalendars shouldBe calendars + } + } } diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt new file mode 100644 index 00000000..3fb5303b --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt @@ -0,0 +1,38 @@ +package pro.qyoga.tests.cases.app.therapist.calendars.google + +import io.kotest.core.annotation.DisplayName +import io.kotest.matchers.shouldBe +import pro.qyoga.tests.clients.TherapistClient.Companion.loginAsTheTherapist +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets +import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi +import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar +import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer +import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.infra.wiremock.WireMock + + +@DisplayName("Эндпоинт установки флага отображения Google-календаря") +class SetCalendarShouldBeShownTest : QYogaAppIntegrationBaseKoTest({ + + val googleCalendarsTestApi = getBean() + val mockGoogleOAuthServer = MockGoogleOAuthServer(WireMock.wiremock) + val mockGoogleCalendar = MockGoogleCalendar(WireMock.wiremock) + val googleCalendarFixturePresets = + GoogleCalendarFixturePresets(mockGoogleOAuthServer, mockGoogleCalendar, googleCalendarsTestApi) + + "должен сохранять заданное значение в БД" { + // Сетап + val calendarId = "calendarId" + val therapist = loginAsTheTherapist() + googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId) + + // Действие + therapist.googleCalendarIntegration.setShouldBeShown(calendarId, true) + + // Проверка + val settings = googleCalendarsTestApi.getGoogleCalendarsSettings(THE_THERAPIST_REF) + settings.single().calendars.single { it.id == calendarId }.shouldBeShown shouldBe true + } + +}) \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt index c77af48f..fc14d020 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt @@ -8,7 +8,7 @@ import pro.qyoga.tests.fixture.backgrounds.ICalCalendarsBackgrounds import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem import pro.qyoga.tests.fixture.object_mothers.calendars.ical.ICalCalendarsObjectMother import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF -import pro.qyoga.tests.fixture.presets.CalendarsFixturePresets +import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets import pro.qyoga.tests.infra.web.QYogaAppBaseKoTest import pro.qyoga.tests.platform.instancio.KSelect.Companion.field @@ -23,7 +23,7 @@ class ICalCalendarsRepoTest : QYogaAppBaseKoTest({ "при изменении состояния календаря в источнике" - { // Сетап val iCalEvent = aCalendarItem() - val iCal = getBean().createICalCalendarWithSingleEvent(iCalEvent) + val iCal = getBean().createICalCalendarWithSingleEvent(iCalEvent) val updatedEvent = aCalendarItem { set(field(ICalCalendarItem::id), iCalEvent.id) } diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt index 265c353c..04d8cd93 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt @@ -17,13 +17,11 @@ import pro.qyoga.app.therapist.appointments.core.edit.CreateAppointmentPageContr import pro.qyoga.app.therapist.appointments.core.edit.EditAppointmentPageController import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel -import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.AppointmentRef import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage import pro.qyoga.tests.pages.therapist.appointments.EditAppointmentPage -import pro.qyoga.tests.platform.spring.web_test_client.getBodyAsString import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -194,14 +192,4 @@ class TherapistAppointmentsApi( } } - fun getGoogleCalendarComponent(): Document { - return webTestClient.get() - .uri(GoogleCalendarSettingsController.PATH) - .authorized() - .exchange() - .expectStatus().isOk - .getBodyAsString() - .let { Jsoup.parse(it) } - } - } diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt index 8d840a36..ab93de61 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt @@ -1,8 +1,13 @@ package pro.qyoga.tests.clients.api import io.restassured.http.Cookie +import org.jsoup.Jsoup +import org.jsoup.nodes.Document import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.web.reactive.function.BodyInserters.fromValue +import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController +import pro.qyoga.tests.platform.spring.web_test_client.getBodyAsString import pro.qyoga.tests.platform.spring.web_test_client.redirectLocation import java.net.URI @@ -46,4 +51,27 @@ class TherapistGoogleCalendarIntegrationApi( .exchange() } + fun getGoogleCalendarComponent(): Document { + return webTestClient.get() + .uri(GoogleCalendarSettingsController.PATH) + .authorized() + .exchange() + .expectStatus().isOk + .getBodyAsString() + .let { Jsoup.parse(it) } + } + + fun setShouldBeShown(calendarId: String, shouldBeShown: Boolean) { + val body = mapOf( + "shouldBeShown" to shouldBeShown + ) + + webTestClient.patch() + .uri(GoogleCalendarSettingsController.updateCalendarSettingsPath(calendarId)) + .body(fromValue(body)) + .authorized() + .exchange() + .expectStatus().isNoContent + } + } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt new file mode 100644 index 00000000..52dd7e61 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt @@ -0,0 +1,35 @@ +package pro.qyoga.tests.fixture.object_mothers.calendars.google + +import pro.qyoga.core.calendar.google.GoogleCalendar +import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.data.randomElementOf + + +object GoogleCalendarObjectMother { + + fun aGoogleCalendar( + ownerRef: TherapistRef, + externalId: String, + name: String = aCalendarName(), + ) = GoogleCalendar( + ownerRef, + externalId, + name, + ) + + fun aCalendarName(): String = faker.random().randomElementOf( + (1..10).map { faker.internet().emailAddress() } + + listOf( + "Рабочий календарь", + "Личные занятия", + "Групповые тренировки", + "Онлайн сессии", + "Терапевтические занятия", + "Йога практики", + "Индивидуальные консультации", + "Семинары и мастер-классы" + ) + ) + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt index 53bb8a96..71689ad5 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt @@ -14,13 +14,13 @@ import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF class AppointmentsFixturePresets( private val appointmentsBackgrounds: AppointmentsBackgrounds, private val clientBackgrounds: ClientsBackgrounds, - private val calendarsFixturePresets: CalendarsFixturePresets + private val ICalsCalendarsFixturePresets: ICalsCalendarsFixturePresets ) { fun createAppointmentFromIcsEvent(): Appointment { val client = clientBackgrounds.aClient() val icsEvent = aCalendarItem() - calendarsFixturePresets.createICalCalendarWithSingleEvent(icsEvent) + ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(icsEvent) val app = appointmentsBackgrounds.create( randomEditAppointmentRequest( client = client.ref(), diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt new file mode 100644 index 00000000..d807a5f6 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -0,0 +1,27 @@ +package pro.qyoga.tests.fixture.presets + +import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar +import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi +import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar +import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer + + +class GoogleCalendarFixturePresets( + private val mockGoogleOAuthServer: MockGoogleOAuthServer, + private val mockGoogleCalendar: MockGoogleCalendar, + private val googleCalendarsService: GoogleCalendarTestApi +) { + + fun setupCalendar(therapistRef: TherapistRef, calendarId: String) { + val refreshToken = "refreshToken" + val accessToken = "accessToken" + mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) + mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars( + listOf(aGoogleCalendar(ownerRef = therapistRef, externalId = calendarId)) + ) + googleCalendarsService.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/CalendarsFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt similarity index 96% rename from app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/CalendarsFixturePresets.kt rename to app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt index dba5063e..a9b398c4 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/CalendarsFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt @@ -9,7 +9,7 @@ import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF @Component -class CalendarsFixturePresets( +class ICalsCalendarsFixturePresets( private val iCalCalendarsBackgrounds: ICalCalendarsBackgrounds ) { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt new file mode 100644 index 00000000..83495a7c --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt @@ -0,0 +1,28 @@ +package pro.qyoga.tests.fixture.test_apis + +import org.springframework.stereotype.Component +import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController +import pro.qyoga.core.calendar.google.GoogleAccount +import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.tests.fixture.object_mothers.therapists.idOnlyUserDetails + + +@Component +class GoogleCalendarTestApi( + private val googleCalendarSettingsController: GoogleCalendarSettingsController, + private val googleCalendarsService: GoogleCalendarsService +) { + + fun getGoogleCalendarsSettings(therapistRef: TherapistRef): List { + return googleCalendarSettingsController.getGoogleCalendarSettingsComponent( + idOnlyUserDetails(therapistRef.id!!) + ).accounts + } + + fun addAccount(therapistRef: TherapistRef, email: String, refreshToken: String) { + googleCalendarsService.addGoogleAccount(GoogleAccount(therapistRef, email, refreshToken)) + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt new file mode 100644 index 00000000..00289ebd --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt @@ -0,0 +1,9 @@ +package pro.qyoga.tests.fixture.test_apis + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + + +@Configuration +@ComponentScan +class TestApisConf \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index 06714be3..a776d46c 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -5,7 +5,7 @@ import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.client.WireMock.aResponse import com.github.tomakehurst.wiremock.client.WireMock.equalTo import org.springframework.http.HttpStatus -import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.calendar.google.GoogleCalendar class MockGoogleCalendar( @@ -16,7 +16,7 @@ class MockGoogleCalendar( private val accessToken: String ) { fun returnsCalendars( - calendars: List + calendars: List ) { wiremockServer.stubFor( WireMock.get( @@ -43,8 +43,10 @@ class MockGoogleCalendar( } -private fun Calendar.toJson(): String = +private fun GoogleCalendar.toJson(): String = """ { + "id": "${this.externalId}", + "summary": "${this.name}" } """.trimIndent() diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt index c9c5f128..69330080 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt @@ -12,6 +12,7 @@ import pro.qyoga.infra.db.SdjConfig import pro.qyoga.tests.fixture.FailingController import pro.qyoga.tests.fixture.backgrounds.BackgroundsConfig import pro.qyoga.tests.fixture.presets.Presets +import pro.qyoga.tests.fixture.test_apis.TestApisConf import pro.qyoga.tests.infra.test_config.spring.auth.TestPasswordEncoderConfig import pro.qyoga.tests.infra.test_config.spring.db.TestDataSourceConfig import pro.qyoga.tests.infra.test_config.spring.minio.TestMinioConfig @@ -38,6 +39,7 @@ val sdjContext by lazy { @Import( QYogaApp::class, BackgroundsConfig::class, + TestApisConf::class, Presets::class, TestPasswordEncoderConfig::class, TestDataSourceConfig::class, diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt index 81684f0b..2476558b 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt @@ -14,7 +14,7 @@ class GoogleCalendarSettingsComponent( ) : Component { private val connectButton = - Link("connect-google-calendar", "/oauth2/authorization/google", "Подключить Google Calendar") + Link("connect-google-calendar", "/oauth2/authorization/google", "Добавить аккаунт") override fun selector(): String = "#google-calendar-settings" diff --git a/app/src/testFixtures/resources/db/shared-fixture.sql b/app/src/testFixtures/resources/db/shared-fixture.sql index 28cf5e54..7afa30e6 100644 --- a/app/src/testFixtures/resources/db/shared-fixture.sql +++ b/app/src/testFixtures/resources/db/shared-fixture.sql @@ -7,7 +7,8 @@ TRUNCATE appointments, appointment_types, survey_forms_settings, ical_calendars, - therapist_google_accounts + therapist_google_accounts, + therapist_google_calendar_settings RESTART IDENTITY; INSERT INTO users (id, email, password_hash, roles, created_at, version) From f03c728db3f336b867857f7a5ebe41f74bf9b10b Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Sun, 14 Sep 2025 18:35:30 +0700 Subject: [PATCH 13/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы при фетче событий можно было из БД доставать только включенные календари и аккаунты, нужные для их чтения --- .../schedule/GoogleCalendarSettingsController.kt | 12 ++++++++---- .../qyoga/core/calendar/google/GoogleAccount.kt | 8 ++++++-- .../core/calendar/google/GoogleCalendarsDao.kt | 14 ++++++++++---- .../core/calendar/google/GoogleCalendarsService.kt | 6 +++++- .../current/V25091101__add_google_calendars.sql | 11 ++++++----- .../appointments/google-settings-component.html | 2 +- .../google/SetCalendarShouldBeShownTest.kt | 5 +++-- .../api/TherapistGoogleCalendarIntegrationApi.kt | 5 +++-- .../presets/GoogleCalendarFixturePresets.kt | 5 +++-- .../fixture/test_apis/GoogleCalendarTestApi.kt | 6 ++++-- 10 files changed, 49 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt index 1990fba1..3064793d 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -6,6 +6,7 @@ import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.ModelAndView import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.core.calendar.google.GoogleAccountRef import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref @@ -37,11 +38,12 @@ class GoogleCalendarSettingsController( @PatchMapping(UPDATE_CALENDAR_SETTINGS_PATH) @ResponseStatus(HttpStatus.NO_CONTENT) fun updateCalendarSettings( + @PathVariable googleAccount: GoogleAccountRef, @PathVariable calendarId: String, @RequestBody settingsPatch: Map, @AuthenticationPrincipal therapist: QyogaUserDetails ): ModelAndView { - googleCalendarsService.updateCalendarSettings(therapist.ref, calendarId, settingsPatch) + googleCalendarsService.updateCalendarSettings(therapist.ref, googleAccount, calendarId, settingsPatch) return getGoogleCalendarSettingsComponent(therapist) } @@ -49,10 +51,12 @@ class GoogleCalendarSettingsController( const val PATH = "/therapist/schedule/settings/google-calendar" - const val UPDATE_CALENDAR_SETTINGS_PATH = "$PATH/calendars/{calendarId}" + const val UPDATE_CALENDAR_SETTINGS_PATH = "$PATH/{googleAccount}/calendars/{calendarId}" - fun updateCalendarSettingsPath(calendarId: String): String = - UPDATE_CALENDAR_SETTINGS_PATH.replace("{calendarId}", calendarId) + fun updateCalendarSettingsPath(googleAccount: GoogleAccountRef, calendarId: String): String = + UPDATE_CALENDAR_SETTINGS_PATH + .replace("{googleAccount}", googleAccount.id.toString()) + .replace("{calendarId}", calendarId) } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt index a375ef78..abaa5b7d 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -1,17 +1,21 @@ package pro.qyoga.core.calendar.google import org.springframework.data.annotation.Id +import org.springframework.data.jdbc.core.mapping.AggregateReference import org.springframework.data.relational.core.mapping.Table +import pro.azhidkov.platform.spring.sdj.ergo.hydration.Identifiable import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.users.therapists.TherapistRef import java.util.* +typealias GoogleAccountRef = AggregateReference + @Table("therapist_google_accounts") data class GoogleAccount( val ownerRef: TherapistRef, val email: String, val refreshToken: String, - @Id val id: UUID = UUIDv7.randomUUID() -) + @Id override val id: UUID = UUIDv7.randomUUID() +) : Identifiable \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt index 8df41177..142bdbf4 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt @@ -21,16 +21,22 @@ class GoogleCalendarsDao( private val googleCalendarSettingsRowMapper = taDataClassRowMapper() - fun patchCalendarSettings(therapist: TherapistRef, calendarId: String, settingsPatch: GoogleCalendarSettingsPatch) { + fun patchCalendarSettings( + therapist: TherapistRef, + googleAccount: GoogleAccountRef, + calendarId: String, + settingsPatch: GoogleCalendarSettingsPatch + ) { val query = """ - INSERT INTO therapist_google_calendar_settings (id, owner_ref, calendar_id, should_be_shown) - VALUES (:id, :ownerRef, :calendarId, :shouldBeShown) - ON CONFLICT (owner_ref, calendar_id) DO UPDATE SET should_be_shown = :shouldBeShown + INSERT INTO therapist_google_calendar_settings (id, owner_ref, google_account_ref, calendar_id, should_be_shown) + VALUES (:id, :ownerRef, :googleAccountRef::uuid, :calendarId, :shouldBeShown) + ON CONFLICT (owner_ref, google_account_ref, calendar_id) DO UPDATE SET should_be_shown = EXCLUDED.should_be_shown """.trimIndent() jdbcClient.sql(query) .param("id", UUIDv7.randomUUID()) .param("ownerRef", therapist.id) + .param("googleAccountRef", googleAccount.id) .param("calendarId", calendarId) .param("shouldBeShown", settingsPatch["shouldBeShown"] as Boolean) .update() diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 360bfc7a..294a515f 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -19,6 +19,7 @@ import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.users.therapists.TherapistRef import java.net.URI import java.time.* +import java.util.* const val APPLICATION_NAME = "Trainer Advisor" @@ -32,6 +33,7 @@ data class GoogleCalendarView( ) data class GoogleAccountCalendarsView( + val id: UUID, val email: String, val calendars: List ) @@ -66,6 +68,7 @@ class GoogleCalendarsService( val calendarSettings = googleCalendarsDao.findCalendarsSettings(therapist) return accounts.zip(accountCalendars).map { (account, calendar) -> GoogleAccountCalendarsView( + account.id, account.email, calendar.map { GoogleCalendarView(it.externalId, it.name, calendarSettings[it.externalId]?.shouldBeShown ?: false) @@ -145,10 +148,11 @@ class GoogleCalendarsService( ) fun updateCalendarSettings( therapist: TherapistRef, + googleAccount: GoogleAccountRef, calendarId: String, settingsPatch: GoogleCalendarSettingsPatch ) { - googleCalendarsDao.patchCalendarSettings(therapist, calendarId, settingsPatch) + googleCalendarsDao.patchCalendarSettings(therapist, googleAccount, calendarId, settingsPatch) } private fun createCalendarService(account: GoogleAccount): Calendar { diff --git a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql index 40a630e3..a625e117 100644 --- a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql +++ b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql @@ -10,10 +10,11 @@ CREATE TABLE therapist_google_accounts CREATE TABLE therapist_google_calendar_settings ( - id UUID PRIMARY KEY, - owner_ref UUID REFERENCES therapists NOT NULL, - calendar_id varchar NOT NULL, - should_be_shown BOOLEAN NOT NULL, + id UUID PRIMARY KEY, + owner_ref UUID REFERENCES therapists NOT NULL, + google_account_ref UUID REFERENCES therapist_google_accounts NOT NULL, + calendar_id varchar NOT NULL, + should_be_shown BOOLEAN NOT NULL, - UNIQUE (owner_ref, calendar_id) + UNIQUE (owner_ref, google_account_ref, calendar_id) ); \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index add7fbf8..834ae2d2 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -20,7 +20,7 @@
    Google Calendar
    hx-trigger="change" hx-vals='js:{ "shouldBeShown": !!event.target.checked }' name="shouldBeShown" - th:attr="hx-patch=@{/therapist/schedule/settings/google-calendar/calendars/{id}(id=${cal.id})}" + th:attr="hx-patch=@{/therapist/schedule/settings/google-calendar/{accountId}/calendars/{id}(accountId=${account.id},id=${cal.id})}" type="checkbox">
diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt index 3fb5303b..55f4e639 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt @@ -2,6 +2,7 @@ package pro.qyoga.tests.cases.app.therapist.calendars.google import io.kotest.core.annotation.DisplayName import io.kotest.matchers.shouldBe +import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.tests.clients.TherapistClient.Companion.loginAsTheTherapist import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets @@ -25,10 +26,10 @@ class SetCalendarShouldBeShownTest : QYogaAppIntegrationBaseKoTest({ // Сетап val calendarId = "calendarId" val therapist = loginAsTheTherapist() - googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId) + val googleAccount = googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId) // Действие - therapist.googleCalendarIntegration.setShouldBeShown(calendarId, true) + therapist.googleCalendarIntegration.setShouldBeShown(googleAccount.ref(), calendarId, true) // Проверка val settings = googleCalendarsTestApi.getGoogleCalendarsSettings(THE_THERAPIST_REF) diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt index ab93de61..2e3e36c6 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt @@ -7,6 +7,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.function.BodyInserters.fromValue import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController +import pro.qyoga.core.calendar.google.GoogleAccountRef import pro.qyoga.tests.platform.spring.web_test_client.getBodyAsString import pro.qyoga.tests.platform.spring.web_test_client.redirectLocation import java.net.URI @@ -61,13 +62,13 @@ class TherapistGoogleCalendarIntegrationApi( .let { Jsoup.parse(it) } } - fun setShouldBeShown(calendarId: String, shouldBeShown: Boolean) { + fun setShouldBeShown(googleAccount: GoogleAccountRef, calendarId: String, shouldBeShown: Boolean) { val body = mapOf( "shouldBeShown" to shouldBeShown ) webTestClient.patch() - .uri(GoogleCalendarSettingsController.updateCalendarSettingsPath(calendarId)) + .uri(GoogleCalendarSettingsController.updateCalendarSettingsPath(googleAccount, calendarId)) .body(fromValue(body)) .authorized() .exchange() diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index d807a5f6..a74f0346 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -1,5 +1,6 @@ package pro.qyoga.tests.fixture.presets +import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar @@ -14,14 +15,14 @@ class GoogleCalendarFixturePresets( private val googleCalendarsService: GoogleCalendarTestApi ) { - fun setupCalendar(therapistRef: TherapistRef, calendarId: String) { + fun setupCalendar(therapistRef: TherapistRef, calendarId: String): GoogleAccount { val refreshToken = "refreshToken" val accessToken = "accessToken" mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars( listOf(aGoogleCalendar(ownerRef = therapistRef, externalId = calendarId)) ) - googleCalendarsService.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) + return googleCalendarsService.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) } } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt index 83495a7c..39cdc05d 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt @@ -21,8 +21,10 @@ class GoogleCalendarTestApi( ).accounts } - fun addAccount(therapistRef: TherapistRef, email: String, refreshToken: String) { - googleCalendarsService.addGoogleAccount(GoogleAccount(therapistRef, email, refreshToken)) + fun addAccount(therapistRef: TherapistRef, email: String, refreshToken: String): GoogleAccount { + val googleAccount = GoogleAccount(therapistRef, email, refreshToken) + googleCalendarsService.addGoogleAccount(googleAccount) + return googleAccount } } \ No newline at end of file From 88e8b0a57a8797f8dfd86f1f6f1ada8a11b5ae2e Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Mon, 15 Sep 2025 13:13:12 +0700 Subject: [PATCH 14/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=B3=D0=B0=20calendar.shouldBeShown=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20=D0=B3=D1=83?= =?UTF-8?q?=D0=B3=D0=BB=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/calendar/google/GoogleAccountsDao.kt | 5 ++ .../calendar/google/GoogleCalendarsDao.kt | 1 + .../calendar/google/GoogleCalendarsService.kt | 80 ++++++++++++------- .../core/SchedulePageControllerTest.kt | 63 +++++++++++++++ .../presets/GoogleCalendarFixturePresets.kt | 15 +++- .../test_apis/GoogleCalendarTestApi.kt | 15 ++++ .../fixture/wiremocks/MockGoogleCalendar.kt | 63 +++++++++++++-- .../resources/application-test.yaml | 3 + 8 files changed, 208 insertions(+), 37 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt index 539eb0c5..ff74615f 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt @@ -1,6 +1,7 @@ package pro.qyoga.core.calendar.google import org.springframework.data.jdbc.core.JdbcAggregateTemplate +import org.springframework.data.jdbc.core.findAllById import org.springframework.stereotype.Repository import pro.azhidkov.platform.spring.sdj.query.query import pro.qyoga.core.users.therapists.TherapistRef @@ -22,4 +23,8 @@ class GoogleAccountsDao( return jdbcAggregateTemplate.findAll(query, GoogleAccount::class.java) } + fun findGoogleAccounts(accountIds: List): List { + return jdbcAggregateTemplate.findAllById(accountIds.map { it.id }) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt index 142bdbf4..c6e4766c 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt @@ -10,6 +10,7 @@ typealias GoogleCalendarSettingsPatch = Map data class GoogleCalendarSettings( val ownerRef: TherapistRef, + val googleAccountRef: GoogleAccountRef, val calendarId: String, val shouldBeShown: Boolean, ) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 294a515f..bab1890a 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -12,8 +12,10 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable +import org.springframework.cache.annotation.Caching import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval +import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.users.therapists.TherapistRef @@ -110,41 +112,63 @@ class GoogleCalendarsService( therapist: TherapistRef, interval: Interval ): Iterable> { - val accounts = googleAccountsDao.findGoogleAccounts(therapist) - if (accounts.isEmpty()) { + val googleCalendarSettings = googleCalendarsDao.findCalendarsSettings(therapist) + if (googleCalendarSettings.isEmpty()) { return emptyList() } - - val events = accounts.flatMap { - val service = servicesCache.getValue(it) - - val events = - service.events().list(it.email) - .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) - .setTimeMax(DateTime(interval.to.toInstant().toEpochMilli())) - .setOrderBy("startTime") - .setSingleEvents(true) - .execute() - .items - .map { - GoogleCalendarItem( - GoogleCalendarItemId(it.id), - it.summary, - it.description ?: "", - startDate(it), - duration(it), - it.location - ) + val accountCalendars = googleCalendarSettings.values.groupBy { it.googleAccountRef.id } + val accountIds = googleCalendarSettings.values.map { it.googleAccountRef } + .distinct() + val accounts = googleAccountsDao.findGoogleAccounts(accountIds) + + val events = accounts + .flatMap { account -> + val service = servicesCache.getValue(account) + + val settings = accountCalendars[account.ref().id] + ?: return@flatMap emptyList() + + settings + .filter { it.shouldBeShown } + .flatMap { calendarSettings -> + val events = + service.events().list(calendarSettings.calendarId) + .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) + .setTimeMax(DateTime(interval.to.toInstant().toEpochMilli())) + .setOrderBy("startTime") + .setSingleEvents(true) + .execute() + .items + .map { + GoogleCalendarItem( + GoogleCalendarItemId(it.id), + it.summary, + it.description ?: "", + startDate(it), + duration(it), + it.location + ) + } + events } - events - } + } return events } - @CacheEvict( - cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], - key = "#therapist.id" + @Caching( + evict = [ + CacheEvict( + cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], + key = "#therapist.id", + beforeInvocation = true + ), + CacheEvict( + cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], + allEntries = true, + beforeInvocation = true + ) + ] ) fun updateCalendarSettings( therapist: TherapistRef, diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt index 4498add9..deca408e 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt @@ -3,10 +3,23 @@ package pro.qyoga.tests.cases.app.therapist.appointments.core import io.kotest.core.annotation.DisplayName import io.kotest.matchers.collections.shouldHaveSize import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController +import pro.qyoga.core.calendar.google.GoogleCalendarItem +import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.tests.assertions.shouldMatch +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.data.randomWorkingTime +import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDuration +import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aAppointmentEventTitle +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.object_mothers.therapists.theTherapistUserDetails import pro.qyoga.tests.fixture.presets.AppointmentsFixturePresets +import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets +import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi +import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar +import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import pro.qyoga.tests.infra.wiremock.WireMock +import java.time.LocalDate @DisplayName("Контроллер страницы календаря") @@ -15,6 +28,12 @@ class SchedulePageControllerTest : QYogaAppIntegrationBaseKoTest({ val appointmentsFixturePresets = getBean() val schedulePageController = getBean() + val googleCalendarsTestApi = getBean() + val mockGoogleOAuthServer = MockGoogleOAuthServer(WireMock.wiremock) + val mockGoogleCalendar = MockGoogleCalendar(WireMock.wiremock) + val googleCalendarFixturePresets = + GoogleCalendarFixturePresets(mockGoogleOAuthServer, mockGoogleCalendar, googleCalendarsTestApi) + "при наличии приёма, созданного на базе события ics-календаря" - { // Сетап val app = appointmentsFixturePresets.createAppointmentFromIcsEvent() @@ -30,4 +49,48 @@ class SchedulePageControllerTest : QYogaAppIntegrationBaseKoTest({ } } + "если есть подключенный, но не настроенный Google-аккаунт с календарём с событием 14 сентября 25 года, то при запросе расписания за 13-15 сентября метод должен не включать это событие" { + // Сетап + val date = LocalDate.of(2025, 9, 14) + val calendarId = "calendarId" + val event = GoogleCalendarItem( + GoogleCalendarItemId(faker.internet().uuid()), + aAppointmentEventTitle(), + "", + date.atTime(randomWorkingTime()), + randomAppointmentDuration(), + null + ) + googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId, event) + + // Действие + val calendarPageModel = + schedulePageController.getCalendarPage(date, null, theTherapistUserDetails) + + // Проверка + calendarPageModel.appointmentCards() shouldHaveSize 0 + } + + "если есть подключенный и настроенный Google-аккаунт с календарём с событием 14 сентября 25 года, то при запросе расписания за 13-15 сентября метод должен включать это событие" { + // Сетап + val date = LocalDate.of(2025, 9, 14) + val calendarId = "calendarId" + val event = GoogleCalendarItem( + GoogleCalendarItemId(faker.internet().uuid()), + aAppointmentEventTitle(), + "", + date.atTime(randomWorkingTime()), + randomAppointmentDuration(), + null + ) + googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId, event, shouldBeShown = true) + + // Действие + val calendarPageModel = + schedulePageController.getCalendarPage(date, null, theTherapistUserDetails) + + // Проверка + calendarPageModel.appointmentCards() shouldHaveSize 1 + } + }) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index a74f0346..bedffee2 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -1,6 +1,7 @@ package pro.qyoga.tests.fixture.presets import pro.qyoga.core.calendar.google.GoogleAccount +import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar @@ -12,17 +13,25 @@ import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer class GoogleCalendarFixturePresets( private val mockGoogleOAuthServer: MockGoogleOAuthServer, private val mockGoogleCalendar: MockGoogleCalendar, - private val googleCalendarsService: GoogleCalendarTestApi + private val googleCalendarsTestApi: GoogleCalendarTestApi ) { - fun setupCalendar(therapistRef: TherapistRef, calendarId: String): GoogleAccount { + fun setupCalendar( + therapistRef: TherapistRef, + calendarId: String, + vararg events: GoogleCalendarItem, + shouldBeShown: Boolean = false, + ): GoogleAccount { val refreshToken = "refreshToken" val accessToken = "accessToken" mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars( listOf(aGoogleCalendar(ownerRef = therapistRef, externalId = calendarId)) ) - return googleCalendarsService.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) + mockGoogleCalendar.OnGetEvents(accessToken, calendarId).returnsEvents(*events) + val account = googleCalendarsTestApi.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) + googleCalendarsTestApi.setShouldBeShown(therapistRef, account, calendarId, shouldBeShown) + return account } } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt index 39cdc05d..01c84ec1 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt @@ -1,6 +1,7 @@ package pro.qyoga.tests.fixture.test_apis import org.springframework.stereotype.Component +import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView @@ -27,4 +28,18 @@ class GoogleCalendarTestApi( return googleAccount } + fun setShouldBeShown( + therapistRef: TherapistRef, + account: GoogleAccount, + calendarId: String, + shouldBeShown: Boolean + ) { + googleCalendarsService.updateCalendarSettings( + therapistRef, + account.ref(), + calendarId, + mapOf("shouldBeShown" to shouldBeShown) + ) + } + } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index a776d46c..1b3298f0 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -1,13 +1,16 @@ package pro.qyoga.tests.fixture.wiremocks import com.github.tomakehurst.wiremock.WireMockServer -import com.github.tomakehurst.wiremock.client.WireMock -import com.github.tomakehurst.wiremock.client.WireMock.aResponse -import com.github.tomakehurst.wiremock.client.WireMock.equalTo +import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.http.HttpStatus import pro.qyoga.core.calendar.google.GoogleCalendar +import pro.qyoga.core.calendar.google.GoogleCalendarItem +import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone +import java.time.format.DateTimeFormatter - +/** + * Mock implementation of Google Calendar API for testing purposes. + */ class MockGoogleCalendar( private val wiremockServer: WireMockServer ) { @@ -19,8 +22,8 @@ class MockGoogleCalendar( calendars: List ) { wiremockServer.stubFor( - WireMock.get( - WireMock.urlEqualTo( + get( + urlEqualTo( "/google/calendar/v3/users/me/calendarList" ) ) @@ -41,6 +44,34 @@ class MockGoogleCalendar( } } + inner class OnGetEvents( + private val accessToken: String, + private val calendarId: String + ) { + + fun returnsEvents(vararg events: GoogleCalendarItem) { + wiremockServer.stubFor( + get( + urlPathEqualTo("/google/calendar/v3/calendars/$calendarId/events") + ) + .withHeader("Authorization", equalTo("Bearer $accessToken")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "items": [${events.joinToString(",") { it.toJson() }}] + } + """ + ) + ) + ) + } + + } + } private fun GoogleCalendar.toJson(): String = @@ -49,4 +80,24 @@ private fun GoogleCalendar.toJson(): String = "id": "${this.externalId}", "summary": "${this.name}" } + """ + +private fun GoogleCalendarItem.toJson(): String { + val start = dateTime.atZone(asiaNovosibirskTimeZone).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + val end = endDateTime.atZone(asiaNovosibirskTimeZone).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + return """ + { + "id": "$id", + "summary": "$title", + "description": "$description", + "start": { + "dateTime": "$start", + "timeZone": "${asiaNovosibirskTimeZone.id}" + }, + "end": { + "dateTime": "$end", + "timeZone": "${asiaNovosibirskTimeZone.id}" + } + } """.trimIndent() +} \ No newline at end of file diff --git a/app/src/testFixtures/resources/application-test.yaml b/app/src/testFixtures/resources/application-test.yaml index 9a5b69a9..e65ebdc7 100644 --- a/app/src/testFixtures/resources/application-test.yaml +++ b/app/src/testFixtures/resources/application-test.yaml @@ -18,6 +18,9 @@ spring: security: oauth2: client: + registration: + google: + redirect-uri: "http://localhost:${server.port:8080}/therapist/oauth2/google/callback" provider: google: token-uri: http://localhost:8089/google/oauth/token From 4645144abc899371f47416ebf604a792abaecbcf Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Tue, 16 Sep 2025 12:34:33 +0700 Subject: [PATCH 15/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=D1=81=20GoogleCalendar=20=D0=B8=D0=BD=D0=BA=D0=B0=D0=BF=D1=81?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=20GoogleCalendarClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/google/GoogleCalendarsClient.kt | 100 ++++++++++++ .../calendar/google/GoogleCalendarsService.kt | 145 +++--------------- .../GoogleAuthorizationIntegrationTest.kt | 4 +- 3 files changed, 127 insertions(+), 122 deletions(-) create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt new file mode 100644 index 00000000..8825e003 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -0,0 +1,100 @@ +package pro.qyoga.core.calendar.google + +import com.google.api.client.util.DateTime +import com.google.api.services.calendar.Calendar +import com.google.api.services.calendar.model.Event +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.UserCredentials +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Component +import pro.azhidkov.platform.java.time.Interval +import pro.qyoga.core.users.therapists.TherapistRef +import java.net.URI +import java.time.* + + +@Component +class GoogleCalendarsClient( + private val googleOAuthProps: OAuth2ClientProperties, + @Value("\${spring.security.oauth2.client.provider.google.token-uri}") private val tokenUri: URI, + @Value("\${trainer-advisor.integrations.google-calendar.root-url}") private val googleCalendarRootUri: URI +) { + + private val servicesCache = mutableMapOf() + .withDefault { createCalendarService(it) } + + @Cacheable( + cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], + key = "#calendarSettings.calendarId + ':' + #interval.from.toInstant().toEpochMilli() + ':' + #interval.to.toInstant().toEpochMilli()" + ) + fun getEvents( + account: GoogleAccount, + calendarSettings: GoogleCalendarSettings, + interval: Interval + ): List { + val service = servicesCache.getValue(account) + val events = + service.events().list(calendarSettings.calendarId) + .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) + .setTimeMax(DateTime(interval.to.toInstant().toEpochMilli())) + .setOrderBy("startTime") + .setSingleEvents(true) + .execute() + .items + .map { + GoogleCalendarItem( + GoogleCalendarItemId(it.id), + it.summary, + it.description ?: "", + startDate(it), + duration(it), + it.location + ) + } + return events + } + + private fun startDate(event: Event): LocalDateTime = + ZonedDateTime.ofInstant( + Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), + ZoneId.of(event.start.timeZone) + ).toLocalDateTime() + + private fun duration(event: Event): Duration = + Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - + Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) + + @Cacheable( + cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], + key = "#therapist.id + ':' + #account.id" + ) + fun getAccountCalendars( + therapist: TherapistRef, + account: GoogleAccount + ): List { + val service = servicesCache.getValue(account) + + return service.CalendarList().list() + .execute().items.map { + GoogleCalendar(therapist, it.id, it.summary) + } + } + + private fun createCalendarService(account: GoogleAccount): Calendar { + val credentials = UserCredentials.newBuilder() + .setClientId(googleOAuthProps.registration["google"]!!.clientId) + .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) + .setRefreshToken(account.refreshToken) + .setTokenServerUri(tokenUri) + .build() + + val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) + .setApplicationName(APPLICATION_NAME) + .setRootUrl(googleCalendarRootUri.toURL().toString()) + .build() + return service + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index bab1890a..1b0265a6 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -3,24 +3,14 @@ package pro.qyoga.core.calendar.google import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory -import com.google.api.client.util.DateTime -import com.google.api.services.calendar.Calendar -import com.google.api.services.calendar.model.Event -import com.google.auth.http.HttpCredentialsAdapter -import com.google.auth.oauth2.UserCredentials -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -import org.springframework.cache.annotation.CacheEvict -import org.springframework.cache.annotation.Cacheable -import org.springframework.cache.annotation.Caching import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.users.therapists.TherapistRef -import java.net.URI -import java.time.* +import java.time.LocalDateTime +import java.time.ZonedDateTime import java.util.* @@ -38,24 +28,32 @@ data class GoogleAccountCalendarsView( val id: UUID, val email: String, val calendars: List -) +) { + + companion object { + + fun of( + account: GoogleAccount, + calendars: List, + calendarSettings: Map + ): GoogleAccountCalendarsView = GoogleAccountCalendarsView( + account.id, + account.email, + calendars.map { + GoogleCalendarView(it.externalId, it.name, calendarSettings[it.externalId]?.shouldBeShown ?: false) + } + ) + } + +} @Service class GoogleCalendarsService( - private val googleOAuthProps: OAuth2ClientProperties, private val googleAccountsDao: GoogleAccountsDao, private val googleCalendarsDao: GoogleCalendarsDao, - @Value("\${spring.security.oauth2.client.provider.google.token-uri}") private val tokenUri: URI, - @Value("\${trainer-advisor.integrations.google-calendar.root-url}") private val googleCalendarRootUri: URI + private val googleCalendarsClient: GoogleCalendarsClient, ) : CalendarsService { - private val servicesCache = mutableMapOf() - .withDefault { createCalendarService(it) } - - @CacheEvict( - cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], - key = "#googleAccount.ownerRef.id" - ) fun addGoogleAccount(googleAccount: GoogleAccount) { googleAccountsDao.addGoogleAccount(googleAccount) } @@ -65,49 +63,14 @@ class GoogleCalendarsService( ): List { val accounts = googleAccountsDao.findGoogleAccounts(therapist) val accountCalendars = accounts.map { - getAccountCalendars(therapist, it) + googleCalendarsClient.getAccountCalendars(therapist, it) } val calendarSettings = googleCalendarsDao.findCalendarsSettings(therapist) - return accounts.zip(accountCalendars).map { (account, calendar) -> - GoogleAccountCalendarsView( - account.id, - account.email, - calendar.map { - GoogleCalendarView(it.externalId, it.name, calendarSettings[it.externalId]?.shouldBeShown ?: false) - } - ) - } - } - - fun findCalendars( - therapist: TherapistRef - ): List { - val accounts = googleAccountsDao.findGoogleAccounts(therapist) - if (accounts.isEmpty()) { - return emptyList() + return accounts.zip(accountCalendars).map { (account, calendars) -> + GoogleAccountCalendarsView.of(account, calendars, calendarSettings) } - - val account = accounts.single() - - return getAccountCalendars(therapist, account) } - private fun getAccountCalendars( - therapist: TherapistRef, - account: GoogleAccount - ): List { - val service = servicesCache.getValue(account) - - return service.CalendarList().list() - .execute().items.map { - GoogleCalendar(therapist, it.id, it.summary) - } - } - - @Cacheable( - cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], - key = "#therapist.id + ':' + #interval.from.toInstant().toEpochMilli() + ':' + #interval.to.toInstant().toEpochMilli()" - ) override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval @@ -123,7 +86,6 @@ class GoogleCalendarsService( val events = accounts .flatMap { account -> - val service = servicesCache.getValue(account) val settings = accountCalendars[account.ref().id] ?: return@flatMap emptyList() @@ -131,45 +93,13 @@ class GoogleCalendarsService( settings .filter { it.shouldBeShown } .flatMap { calendarSettings -> - val events = - service.events().list(calendarSettings.calendarId) - .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) - .setTimeMax(DateTime(interval.to.toInstant().toEpochMilli())) - .setOrderBy("startTime") - .setSingleEvents(true) - .execute() - .items - .map { - GoogleCalendarItem( - GoogleCalendarItemId(it.id), - it.summary, - it.description ?: "", - startDate(it), - duration(it), - it.location - ) - } - events + googleCalendarsClient.getEvents(account, calendarSettings, interval) } } return events } - @Caching( - evict = [ - CacheEvict( - cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], - key = "#therapist.id", - beforeInvocation = true - ), - CacheEvict( - cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], - allEntries = true, - beforeInvocation = true - ) - ] - ) fun updateCalendarSettings( therapist: TherapistRef, googleAccount: GoogleAccountRef, @@ -179,29 +109,4 @@ class GoogleCalendarsService( googleCalendarsDao.patchCalendarSettings(therapist, googleAccount, calendarId, settingsPatch) } - private fun createCalendarService(account: GoogleAccount): Calendar { - val credentials = UserCredentials.newBuilder() - .setClientId(googleOAuthProps.registration["google"]!!.clientId) - .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(account.refreshToken) - .setTokenServerUri(tokenUri) - .build() - - val service = Calendar.Builder(httpTransport, gsonFactory, HttpCredentialsAdapter(credentials)) - .setApplicationName(APPLICATION_NAME) - .setRootUrl(googleCalendarRootUri.toURL().toString()) - .build() - return service - } - - private fun startDate(event: Event): LocalDateTime = - ZonedDateTime.ofInstant( - Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), - ZoneId.of(event.start.timeZone) - ).toLocalDateTime() - - private fun duration(event: Event): Duration = - Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - - Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) - } \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt index 68c321eb..052a1e61 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt @@ -92,8 +92,8 @@ class GoogleAuthorizationIntegrationTest : QYogaAppIntegrationBaseKoTest({ } "обеспечивать возможность дальнейших запросов к Google Calendar" { - val gotCalendars = googleCalendarsService.findCalendars(THE_THERAPIST_REF) - gotCalendars shouldBe calendars + val gotCalendars = googleCalendarsService.findGoogleAccountCalendars(THE_THERAPIST_REF) + gotCalendars.single().calendars shouldBe calendars } } From d3dac2a577179b904248780a0aa63ad4b8d344ff Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Tue, 16 Sep 2025 15:53:05 +0700 Subject: [PATCH 16/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B2=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=B8=D0=B7=20Google=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BA=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B7=D0=BE=D0=BD=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BF=D0=B5=D0=B2=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth2/GoogleCallbackController.kt | 2 +- .../calendar/google/GoogleCalendarItem.kt | 22 ++++++-- .../calendar/google/GoogleCalendarsClient.kt | 11 ++-- .../calendar/google/GoogleCalendarsService.kt | 2 + .../google/GoogleCalendarsServiceTest.kt | 53 +++++++++++++++++++ .../google/GoogleCalendarObjectMother.kt | 19 +++++++ .../presets/GoogleCalendarFixturePresets.kt | 13 ++++- .../fixture/wiremocks/MockGoogleCalendar.kt | 15 ++++-- 8 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index 5e01e31f..bf78527e 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -44,7 +44,7 @@ class GoogleOAuthController( .body(Map::class.java) val email = response ?.get("email") as String - val picture = response["picture"] as String? + val picture = response["picture"] as? String? googleCalendarsService.addGoogleAccount( GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt index e8f637c9..b7135a52 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt @@ -1,17 +1,31 @@ package pro.qyoga.core.calendar.google +import pro.azhidkov.platform.java.time.toLocalDateTime import pro.qyoga.core.calendar.api.CalendarItem import java.time.Duration -import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.Temporal @JvmInline value class GoogleCalendarItemId(val value: String) -data class GoogleCalendarItem( +data class GoogleCalendarItem( override val id: GoogleCalendarItemId, override val title: String, override val description: String, - override val dateTime: LocalDateTime, + override val dateTime: DATE, override val duration: Duration, override val location: String? -) : CalendarItem +) : CalendarItem { + + fun toLocalizedCalendarItem(zoneId: ZoneId) = + GoogleCalendarItem( + id, + title, + description, + dateTime.toLocalDateTime(zoneId), + duration, + location + ) + +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt index 8825e003..03cc981c 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -12,7 +12,10 @@ import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.users.therapists.TherapistRef import java.net.URI -import java.time.* +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime @Component @@ -33,7 +36,7 @@ class GoogleCalendarsClient( account: GoogleAccount, calendarSettings: GoogleCalendarSettings, interval: Interval - ): List { + ): List> { val service = servicesCache.getValue(account) val events = service.events().list(calendarSettings.calendarId) @@ -56,11 +59,11 @@ class GoogleCalendarsClient( return events } - private fun startDate(event: Event): LocalDateTime = + private fun startDate(event: Event): ZonedDateTime = ZonedDateTime.ofInstant( Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), ZoneId.of(event.start.timeZone) - ).toLocalDateTime() + ) private fun duration(event: Event): Duration = Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 1b0265a6..3fb8fafb 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -5,6 +5,7 @@ import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval +import pro.azhidkov.platform.java.time.zoneId import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService @@ -96,6 +97,7 @@ class GoogleCalendarsService( googleCalendarsClient.getEvents(account, calendarSettings, interval) } } + .map { it.toLocalizedCalendarItem(interval.zoneId) } return events } diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt new file mode 100644 index 00000000..856b3f05 --- /dev/null +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt @@ -0,0 +1,53 @@ +package pro.qyoga.tests.cases.i9ns.calendar.google + +import io.kotest.core.annotation.DisplayName +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import pro.azhidkov.platform.java.time.Interval +import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendarItem +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import pro.qyoga.tests.fixture.presets.googleCalendarFixturePresets +import pro.qyoga.tests.infra.test_config.spring.context +import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + + +@DisplayName("Сервис Google календарей") +class GoogleCalendarsServiceTest : QYogaAppIntegrationBaseKoTest({ + + val googleCalendarsService = getBean() + val googleCalendarFixturePresets = context.googleCalendarFixturePresets() + + "метод получения событий в интервале" - { + + "должен возвращать события приведённые к таймзоне запрошенного интервала" { + // Сетап + googleCalendarFixturePresets.setupCalendar( + THE_THERAPIST_REF, GoogleCalendarObjectMother.aCalendarName(), + aGoogleCalendarItem( + date = { ZonedDateTime.of(2025, 9, 16, 6, 0, 0, 0, ZoneId.of("Europe/Moscow")) }, + duration = Duration.ofMinutes(60) + ), + shouldBeShown = true + ) + + // Действие + val items = googleCalendarsService.findCalendarItemsInInterval( + THE_THERAPIST_REF, + Interval.of(ZonedDateTime.of(2025, 9, 16, 0, 0, 0, 0, asiaNovosibirskTimeZone), Duration.ofDays(1)) + ) + + // Проверка + items shouldHaveSize 1 + items.first().dateTime shouldBe LocalDateTime.of(2025, 9, 16, 10, 0, 0, 0) + items.first().endDateTime shouldBe LocalDateTime.of(2025, 9, 16, 11, 0, 0, 0) + } + } + +}) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt index 52dd7e61..75ef9f19 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt @@ -1,9 +1,15 @@ package pro.qyoga.tests.fixture.object_mothers.calendars.google import pro.qyoga.core.calendar.google.GoogleCalendar +import pro.qyoga.core.calendar.google.GoogleCalendarItem +import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomElementOf +import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDuration +import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aAppointmentEventTitle +import java.time.Duration +import java.time.temporal.Temporal object GoogleCalendarObjectMother { @@ -18,6 +24,19 @@ object GoogleCalendarObjectMother { name, ) + fun aGoogleCalendarItem( + date: () -> DATE, + duration: Duration = randomAppointmentDuration() + ) = + GoogleCalendarItem( + GoogleCalendarItemId(faker.internet().uuid()), + aAppointmentEventTitle(), + "", + date(), + duration, + null + ) + fun aCalendarName(): String = faker.random().randomElementOf( (1..10).map { faker.internet().emailAddress() } + listOf( diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index bedffee2..d33d0ef0 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -1,5 +1,6 @@ package pro.qyoga.tests.fixture.presets +import org.springframework.context.ApplicationContext import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.core.users.therapists.TherapistRef @@ -8,6 +9,8 @@ import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObj import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer +import pro.qyoga.tests.infra.wiremock.WireMock +import pro.qyoga.tests.platform.spring.context.getBean class GoogleCalendarFixturePresets( @@ -19,7 +22,7 @@ class GoogleCalendarFixturePresets( fun setupCalendar( therapistRef: TherapistRef, calendarId: String, - vararg events: GoogleCalendarItem, + vararg events: GoogleCalendarItem<*>, shouldBeShown: Boolean = false, ): GoogleAccount { val refreshToken = "refreshToken" @@ -34,4 +37,10 @@ class GoogleCalendarFixturePresets( return account } -} \ No newline at end of file +} + +fun ApplicationContext.googleCalendarFixturePresets() = GoogleCalendarFixturePresets( + MockGoogleOAuthServer(WireMock.wiremock), + MockGoogleCalendar(WireMock.wiremock), + getBean() +) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index 1b3298f0..2fea6f53 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -1,11 +1,16 @@ package pro.qyoga.tests.fixture.wiremocks import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.MappingBuilder import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.http.HttpStatus +import org.springframework.web.util.UriUtils import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone +import java.nio.charset.StandardCharsets.UTF_8 +import java.time.LocalDateTime +import java.time.ZonedDateTime import java.time.format.DateTimeFormatter /** @@ -49,7 +54,7 @@ class MockGoogleCalendar( private val calendarId: String ) { - fun returnsEvents(vararg events: GoogleCalendarItem) { + fun returnsEvents(vararg events: GoogleCalendarItem<*>) { wiremockServer.stubFor( get( urlPathEqualTo("/google/calendar/v3/calendars/$calendarId/events") @@ -82,9 +87,11 @@ private fun GoogleCalendar.toJson(): String = } """ -private fun GoogleCalendarItem.toJson(): String { - val start = dateTime.atZone(asiaNovosibirskTimeZone).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) - val end = endDateTime.atZone(asiaNovosibirskTimeZone).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) +private fun GoogleCalendarItem<*>.toJson(): String { + val start = ((dateTime as? ZonedDateTime) ?: (dateTime as LocalDateTime).atZone(asiaNovosibirskTimeZone)) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + val end = ((endDateTime as? ZonedDateTime) ?: (endDateTime as LocalDateTime).atZone(asiaNovosibirskTimeZone)) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) return """ { "id": "$id", From f2a06c0ed8b2e46634f7c1023c5a018bb6242a51 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Tue, 16 Sep 2025 16:27:12 +0700 Subject: [PATCH 17/43] =?UTF-8?q?fix/qg-253:=20WIP:=20GoogleAccount.refres?= =?UTF-8?q?hToken=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D1=91=D0=BD?= =?UTF-8?q?=20=D0=BD=D0=B0=20CharArray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы он не светился в логах через toString --- .../oauth2/GoogleCallbackController.kt | 2 +- .../core/calendar/google/GoogleAccount.kt | 34 +++++++++++++++++-- .../calendar/google/GoogleCalendarsClient.kt | 8 ++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index bf78527e..eb42981d 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -47,7 +47,7 @@ class GoogleOAuthController( val picture = response["picture"] as? String? googleCalendarsService.addGoogleAccount( - GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) + GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue.toCharArray()) ) // Греем кэш, чтобы улучшить UX пользователя при возврате на страницу расписания diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt index abaa5b7d..d9bdde40 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -15,7 +15,37 @@ typealias GoogleAccountRef = AggregateReference data class GoogleAccount( val ownerRef: TherapistRef, val email: String, - val refreshToken: String, + val refreshToken: CharArray, @Id override val id: UUID = UUIDv7.randomUUID() -) : Identifiable \ No newline at end of file +) : Identifiable { + + constructor( + ownerRef: TherapistRef, + email: String, + refreshToken: String + ) : this(ownerRef, email, refreshToken.toCharArray()) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GoogleAccount + + if (ownerRef != other.ownerRef) return false + if (email != other.email) return false + if (!refreshToken.contentEquals(other.refreshToken)) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = ownerRef.hashCode() + result = 31 * result + email.hashCode() + result = 31 * result + refreshToken.contentHashCode() + result = 31 * result + id.hashCode() + return result + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt index 03cc981c..0c5ae963 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -5,6 +5,7 @@ import com.google.api.services.calendar.Calendar import com.google.api.services.calendar.model.Event import com.google.auth.http.HttpCredentialsAdapter import com.google.auth.oauth2.UserCredentials +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties import org.springframework.cache.annotation.Cacheable @@ -25,6 +26,8 @@ class GoogleCalendarsClient( @Value("\${trainer-advisor.integrations.google-calendar.root-url}") private val googleCalendarRootUri: URI ) { + private val log = LoggerFactory.getLogger(javaClass) + private val servicesCache = mutableMapOf() .withDefault { createCalendarService(it) } @@ -37,6 +40,8 @@ class GoogleCalendarsClient( calendarSettings: GoogleCalendarSettings, interval: Interval ): List> { + log.info("Fetching events in {} for calendar {} using {}", interval, calendarSettings.calendarId, account) + val service = servicesCache.getValue(account) val events = service.events().list(calendarSettings.calendarId) @@ -77,6 +82,7 @@ class GoogleCalendarsClient( therapist: TherapistRef, account: GoogleAccount ): List { + log.info("Fetching calendars for therapist {} using {}", therapist, account) val service = servicesCache.getValue(account) return service.CalendarList().list() @@ -89,7 +95,7 @@ class GoogleCalendarsClient( val credentials = UserCredentials.newBuilder() .setClientId(googleOAuthProps.registration["google"]!!.clientId) .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(account.refreshToken) + .setRefreshToken(String(account.refreshToken)) .setTokenServerUri(tokenUri) .build() From 27aeda28b032c98672f43ef0d4e87fe33189ca35 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Wed, 17 Sep 2025 10:24:11 +0700 Subject: [PATCH 18/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20Google=20Calendars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdj/converters/CharArrayConverters.kt | 30 +++++++++ .../oauth2/GoogleCallbackController.kt | 2 +- .../core/calendar/google/GoogleAccount.kt | 27 +------- .../calendar/google/GoogleCalendarsClient.kt | 30 +++++++-- .../calendar/google/GoogleCalendarsService.kt | 33 +++++++-- .../kotlin/pro/qyoga/infra/db/SdjConfig.kt | 2 + .../google-settings-component.html | 67 +++++++++++++------ .../GetGoogleCalendarsSettingsEndpointTest.kt | 34 ++++++++++ .../GoogleAuthorizationIntegrationTest.kt | 3 +- .../google/SetCalendarShouldBeShownTest.kt | 3 +- .../presets/GoogleCalendarFixturePresets.kt | 5 +- .../fixture/wiremocks/MockGoogleCalendar.kt | 58 ++++++++++++++-- .../GoogleCalendarSettingsComponent.kt | 16 ++++- 13 files changed, 240 insertions(+), 70 deletions(-) create mode 100644 app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt new file mode 100644 index 00000000..9d325654 --- /dev/null +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt @@ -0,0 +1,30 @@ +package pro.azhidkov.platform.spring.sdj.converters + +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.data.convert.WritingConverter + +data class SecretChars(val value: CharArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SecretChars + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return value.contentHashCode() + } +} + +@WritingConverter +class SecretCharsToString : Converter { + override fun convert(source: SecretChars) = String(source.value) +} + +@ReadingConverter +class StringToSecretChars : Converter { + override fun convert(source: String) = SecretChars(source.toCharArray()) +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index eb42981d..bf78527e 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -47,7 +47,7 @@ class GoogleOAuthController( val picture = response["picture"] as? String? googleCalendarsService.addGoogleAccount( - GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue.toCharArray()) + GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) ) // Греем кэш, чтобы улучшить UX пользователя при возврате на страницу расписания diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt index d9bdde40..7b521ec5 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -3,6 +3,7 @@ package pro.qyoga.core.calendar.google import org.springframework.data.annotation.Id import org.springframework.data.jdbc.core.mapping.AggregateReference import org.springframework.data.relational.core.mapping.Table +import pro.azhidkov.platform.spring.sdj.converters.SecretChars import pro.azhidkov.platform.spring.sdj.ergo.hydration.Identifiable import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.users.therapists.TherapistRef @@ -15,7 +16,7 @@ typealias GoogleAccountRef = AggregateReference data class GoogleAccount( val ownerRef: TherapistRef, val email: String, - val refreshToken: CharArray, + val refreshToken: SecretChars, @Id override val id: UUID = UUIDv7.randomUUID() ) : Identifiable { @@ -24,28 +25,6 @@ data class GoogleAccount( ownerRef: TherapistRef, email: String, refreshToken: String - ) : this(ownerRef, email, refreshToken.toCharArray()) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as GoogleAccount - - if (ownerRef != other.ownerRef) return false - if (email != other.email) return false - if (!refreshToken.contentEquals(other.refreshToken)) return false - if (id != other.id) return false - - return true - } - - override fun hashCode(): Int { - var result = ownerRef.hashCode() - result = 31 * result + email.hashCode() - result = 31 * result + refreshToken.contentHashCode() - result = 31 * result + id.hashCode() - return result - } + ) : this(ownerRef, email, SecretChars(refreshToken.toCharArray())) } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt index 0c5ae963..36546173 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -12,11 +12,14 @@ import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.users.therapists.TherapistRef +import java.io.IOException import java.net.URI import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime +import kotlin.Result.Companion.failure +import kotlin.Result.Companion.success @Component @@ -81,21 +84,27 @@ class GoogleCalendarsClient( fun getAccountCalendars( therapist: TherapistRef, account: GoogleAccount - ): List { + ): Result> { log.info("Fetching calendars for therapist {} using {}", therapist, account) val service = servicesCache.getValue(account) - return service.CalendarList().list() - .execute().items.map { - GoogleCalendar(therapist, it.id, it.summary) - } + val getCalendarsListRequest = service.CalendarList().list() + + val calendarListDto = tryExecute { getCalendarsListRequest.execute() } + .getOrElse { return failure(it) } + + val calendarsList = calendarListDto.items.map { + GoogleCalendar(therapist, it.id, it.summary) + } + + return success(calendarsList) } private fun createCalendarService(account: GoogleAccount): Calendar { val credentials = UserCredentials.newBuilder() .setClientId(googleOAuthProps.registration["google"]!!.clientId) .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(String(account.refreshToken)) + .setRefreshToken(String(account.refreshToken.value)) .setTokenServerUri(tokenUri) .build() @@ -106,4 +115,11 @@ class GoogleCalendarsClient( return service } -} \ No newline at end of file +} + +private fun tryExecute(eventsRequest: () -> T): Result = + try { + success(eventsRequest()) + } catch (e: IOException) { + failure(e) + } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 3fb8fafb..9f955ccc 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -25,24 +25,47 @@ data class GoogleCalendarView( val shouldBeShown: Boolean ) +private const val DEFAULT_CALENDAR_VISIBILITY = false + +sealed interface GoogleAccountContentView { + data class Calendars(val calendars: List) : GoogleAccountContentView + data object Error : GoogleAccountContentView + + companion object { + operator fun invoke( + calendars: Result>, + calendarSettings: Map + ): GoogleAccountContentView = + if (calendars.isSuccess) { + Calendars(calendars.getOrThrow().map { + GoogleCalendarView( + it.externalId, + it.name, + calendarSettings[it.externalId]?.shouldBeShown ?: DEFAULT_CALENDAR_VISIBILITY + ) + }) + } else { + Error + } + } +} + data class GoogleAccountCalendarsView( val id: UUID, val email: String, - val calendars: List + val content: GoogleAccountContentView ) { companion object { fun of( account: GoogleAccount, - calendars: List, + calendars: Result>, calendarSettings: Map ): GoogleAccountCalendarsView = GoogleAccountCalendarsView( account.id, account.email, - calendars.map { - GoogleCalendarView(it.externalId, it.name, calendarSettings[it.externalId]?.shouldBeShown ?: false) - } + GoogleAccountContentView(calendars, calendarSettings) ) } diff --git a/app/src/main/kotlin/pro/qyoga/infra/db/SdjConfig.kt b/app/src/main/kotlin/pro/qyoga/infra/db/SdjConfig.kt index c908e427..605d313d 100644 --- a/app/src/main/kotlin/pro/qyoga/infra/db/SdjConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/infra/db/SdjConfig.kt @@ -18,6 +18,8 @@ class SdjConfig( PGIntervalToDurationConverter(), URLToStringConverter(), StringToURLConverter(), + SecretCharsToString(), + StringToSecretChars(), *modulesConverters.flatMap { it.converters() }.toTypedArray() ) } diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index 834ae2d2..a4941885 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -6,30 +6,55 @@
Google Calendar
-
-
email@example.com
-
    -
  • - Calendar name -
    - -
    -
  • -
+
- + G Добавить аккаунт + class="me-2" + src="https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s48-fcrop64=1,00000000ffffffff-rw" + width="20"> Добавить аккаунт
\ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt index 91005ce2..70eb2955 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt @@ -2,15 +2,23 @@ package pro.qyoga.tests.cases.app.therapist.calendars.google import io.kotest.core.annotation.DisplayName import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.core.calendar.google.GoogleAccountContentView import pro.qyoga.tests.assertions.shouldHaveComponent import pro.qyoga.tests.clients.TherapistClient +import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import pro.qyoga.tests.fixture.presets.googleCalendarFixturePresets +import pro.qyoga.tests.infra.test_config.spring.context import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseKoTest import pro.qyoga.tests.pages.therapist.appointments.GoogleCalendarSettingsComponent +import java.util.* @DisplayName("Эндпоинт получения компонента настройки интеграции с Google Calendar") class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ + val googleCalendarsFixturePresets = context.googleCalendarFixturePresets() + "должен возвращать пустой список аккаунтов для терапевта без настроенной интеграции" { // Сетап val therapist = TherapistClient.loginAsTheTherapist() @@ -23,4 +31,30 @@ class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ res shouldHaveComponent GoogleCalendarSettingsComponent(accounts) } + "в случае если запрос каленадрей в гугле возвращает ошибку" - { + // Сетап + val therapist = TherapistClient.loginAsTheTherapist() + val accessToken = "accessToken" + val account = googleCalendarsFixturePresets.setupCalendar(THE_THERAPIST_REF) + googleCalendarsFixturePresets.mockGoogleCalendar.OnGetCalendars(accessToken) + .returnsForbidden() + val accounts = listOf( + GoogleAccountCalendarsView( + UUID.fromString(faker.internet().uuid()), + account.email, + GoogleAccountContentView.Error + ) + ) + + "при запросе настроек" - { + // Действие + val res = therapist.googleCalendarIntegration.getGoogleCalendarComponent() + + "должен корректно вернуть компонент, в котором у аккаунта вместо списка календарей выведена ошибка" { + // Проверка + res shouldHaveComponent GoogleCalendarSettingsComponent(accounts) + } + } + + } }) \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt index 052a1e61..b0d1c1d8 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt @@ -5,6 +5,7 @@ import io.kotest.matchers.shouldBe import org.springframework.core.env.get import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.oauth2.GoogleOAuthController +import pro.qyoga.core.calendar.google.GoogleAccountContentView import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.tests.assertions.shouldBeRedirectToGoogleOAuth @@ -93,7 +94,7 @@ class GoogleAuthorizationIntegrationTest : QYogaAppIntegrationBaseKoTest({ "обеспечивать возможность дальнейших запросов к Google Calendar" { val gotCalendars = googleCalendarsService.findGoogleAccountCalendars(THE_THERAPIST_REF) - gotCalendars.single().calendars shouldBe calendars + (gotCalendars.single().content as GoogleAccountContentView.Calendars).calendars shouldBe calendars } } diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt index 55f4e639..cb553c0a 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt @@ -3,6 +3,7 @@ package pro.qyoga.tests.cases.app.therapist.calendars.google import io.kotest.core.annotation.DisplayName import io.kotest.matchers.shouldBe import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref +import pro.qyoga.core.calendar.google.GoogleAccountContentView import pro.qyoga.tests.clients.TherapistClient.Companion.loginAsTheTherapist import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets @@ -33,7 +34,7 @@ class SetCalendarShouldBeShownTest : QYogaAppIntegrationBaseKoTest({ // Проверка val settings = googleCalendarsTestApi.getGoogleCalendarsSettings(THE_THERAPIST_REF) - settings.single().calendars.single { it.id == calendarId }.shouldBeShown shouldBe true + (settings.single().content as GoogleAccountContentView.Calendars).calendars.single { it.id == calendarId }.shouldBeShown shouldBe true } }) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index d33d0ef0..b80b3ac4 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -5,6 +5,7 @@ import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aCalendarName import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar @@ -21,12 +22,12 @@ class GoogleCalendarFixturePresets( fun setupCalendar( therapistRef: TherapistRef, - calendarId: String, + calendarId: String = aCalendarName(), vararg events: GoogleCalendarItem<*>, shouldBeShown: Boolean = false, + accessToken: String = "accessToken" ): GoogleAccount { val refreshToken = "refreshToken" - val accessToken = "accessToken" mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars( listOf(aGoogleCalendar(ownerRef = therapistRef, externalId = calendarId)) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index 2fea6f53..dc6df136 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -8,10 +8,10 @@ import org.springframework.web.util.UriUtils import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone -import java.nio.charset.StandardCharsets.UTF_8 import java.time.LocalDateTime import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import kotlin.text.Charsets.UTF_8 /** * Mock implementation of Google Calendar API for testing purposes. @@ -23,15 +23,12 @@ class MockGoogleCalendar( inner class OnGetCalendars( private val accessToken: String ) { + fun returnsCalendars( calendars: List ) { wiremockServer.stubFor( - get( - urlEqualTo( - "/google/calendar/v3/users/me/calendarList" - ) - ) + getCalendarsRequest() .withHeader("Authorization", equalTo("Bearer $accessToken")) .willReturn( aResponse() @@ -47,6 +44,46 @@ class MockGoogleCalendar( ) ) } + + fun returnsForbidden() { + wiremockServer.stubFor( + getCalendarsRequest() + .willReturn( + aResponse() + .withStatus(HttpStatus.FORBIDDEN.value()) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "code": 403, + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "ACCESS_TOKEN_SCOPE_INSUFFICIENT" + } + ], + "errors": [ + { + "domain": "global", + "message": "Insufficient Permission", + "reason": "insufficientPermissions" + } + ], + "message": "Request had insufficient authentication scopes.", + "status": "PERMISSION_DENIED" + } + """ + ) + ) + ) + } + + private fun getCalendarsRequest(): MappingBuilder = get( + urlEqualTo( + "/google/calendar/v3/users/me/calendarList" + ) + ) + } inner class OnGetEvents( @@ -57,7 +94,14 @@ class MockGoogleCalendar( fun returnsEvents(vararg events: GoogleCalendarItem<*>) { wiremockServer.stubFor( get( - urlPathEqualTo("/google/calendar/v3/calendars/$calendarId/events") + urlPathEqualTo( + "/google/calendar/v3/calendars/${ + UriUtils.encodePathSegment( + calendarId, + UTF_8 + ) + }/events" + ) ) .withHeader("Authorization", equalTo("Bearer $accessToken")) .willReturn( diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt index 2476558b..c369200a 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt @@ -21,8 +21,22 @@ class GoogleCalendarSettingsComponent( override fun matcher(): Matcher { return Matcher.all( - haveComponent(connectButton) + haveComponent(connectButton), + *(accounts.map { haveComponent(GoogleAccountComponent(it)) }.toTypedArray()) ) } + class GoogleAccountComponent( + private val account: GoogleAccountCalendarsView + ) : Component { + + override fun selector(): String = + ".google-account-item" + + override fun matcher(): Matcher { + return haveComponent(".google-account-error-content") + } + + } + } \ No newline at end of file From 58f3650975b6b90ee5684818a6adb89601137ea6 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Wed, 17 Sep 2025 16:07:01 +0700 Subject: [PATCH 19/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20Google-=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pro/azhidkov/platform/kotlin/ResultExt.kt | 20 +++- .../sdj/converters/CharArrayConverters.kt | 5 + .../core/schedule/CalendarPageModel.kt | 10 +- .../core/schedule/GetCalendarAppointments.kt | 26 ++++- .../core/calendar/google/GoogleAccount.kt | 4 +- .../calendar/google/GoogleCalendarsClient.kt | 11 +- .../qyoga/core/clients/cards/model/Client.kt | 3 +- .../pro/qyoga/infra/web/ThymeleafConfig.kt | 2 + app/src/main/resources/application.yaml | 2 +- .../therapist/appointments/schedule.html | 18 ++- .../core/CalendarPageModelTest.kt | 15 +-- .../appointments/core/SchedulePageTest.kt | 22 ++++ .../backgrounds/AppointmentsBackgrounds.kt | 8 +- .../fixture/backgrounds/ClientsBackgrounds.kt | 2 +- .../kotlin/pro/qyoga/tests/fixture/data/Id.kt | 8 ++ .../google/GoogleCalendarObjectMother.kt | 32 +++++- .../clients/ClientsObjectMother.kt | 2 +- .../presets/GoogleCalendarFixturePresets.kt | 17 ++- .../fixture/presets/ScheduleFixturePreset.kt | 106 ++++++++++++++++++ .../test_apis/GoogleCalendarTestApi.kt | 9 +- .../fixture/wiremocks/MockGoogleCalendar.kt | 2 + .../wiremocks/MockGoogleOAuthServer.kt | 2 + .../fixture/wiremocks/MockServersConf.kt | 9 ++ .../infra/test_config/spring/TestsConfig.kt | 5 +- .../infra/test_config/spring/WireMockConf.kt | 18 +++ .../qyoga/tests/infra/wiremock/WireMock.kt | 4 + .../therapist/appointments/SchedulePage.kt | 14 +++ .../resources/application-test.yaml | 3 + .../testFixtures/resources/logback-test.xml | 4 +- 29 files changed, 323 insertions(+), 60 deletions(-) create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Id.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockServersConf.kt create mode 100644 app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/WireMockConf.kt diff --git a/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt b/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt index a7e67bfe..160eb936 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt @@ -1,5 +1,8 @@ package pro.azhidkov.platform.kotlin +import kotlin.Result.Companion.failure +import kotlin.Result.Companion.success + inline fun Result<*>.isFailureOf(): Boolean = this.exceptionOrNull() is T fun Result<*>.value(): Any? = if (this.isSuccess) this.getOrThrow() else this.exceptionOrNull()!! @@ -9,7 +12,7 @@ inline fun Result.mapSuccessOrNull(transform: (T @Suppress("UNCHECKED_CAST") val result = when { - value != null -> Result.success(transform(value)) + value != null -> success(transform(value)) else -> this as Result } return result @@ -20,7 +23,7 @@ inline fun Result.mapSuccess(transform: (T) -> R): @Suppress("UNCHECKED_CAST") val result = when { - value != null -> Result.success(transform(value)) + value != null -> success(transform(value)) else -> this as Result } return result @@ -29,12 +32,19 @@ inline fun Result.mapSuccess(transform: (T) -> R): @Suppress("UNCHECKED_CAST") inline fun Result.mapNull(transform: () -> R): Result = when { - this.isSuccess && this.getOrNull() == null -> Result.success(transform()) + this.isSuccess && this.getOrNull() == null -> success(transform()) else -> this as Result } inline fun Result.recoverFailure(block: (T) -> R): Result = - if (this.exceptionOrNull() is T) Result.success(block(this.exceptionOrNull() as T)) else this + if (this.exceptionOrNull() is T) success(block(this.exceptionOrNull() as T)) else this inline fun Result.mapFailure(block: (T) -> Throwable): Result = - if (this.exceptionOrNull() is T) Result.failure(block(this.exceptionOrNull() as T)) else this + if (this.exceptionOrNull() is T) failure(block(this.exceptionOrNull() as T)) else this + +fun tryExecute(eventsRequest: () -> T): Result = + try { + success(eventsRequest()) + } catch (e: Exception) { + failure(e) + } diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt index 9d325654..d59e83e1 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt @@ -5,6 +5,10 @@ import org.springframework.data.convert.ReadingConverter import org.springframework.data.convert.WritingConverter data class SecretChars(val value: CharArray) { + + fun show() = + String(value) + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -17,6 +21,7 @@ data class SecretChars(val value: CharArray) { override fun hashCode(): Int { return value.contentHashCode() } + } @WritingConverter diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index db410745..5da3f1ed 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -45,7 +45,8 @@ data class CalendarPageModel( val date: LocalDate, val timeMarks: List, val calendarDays: Collection, - val appointmentToFocus: UUID? + val appointmentToFocus: UUID?, + val hasSyncErrors: Boolean ) : ModelAndView("therapist/appointments/schedule.html") { init { @@ -54,18 +55,19 @@ data class CalendarPageModel( addObject("calendarDays", calendarDays) addObject("selectedDayLabel", date.format(russianDayOfMonthLongFormat)) addObject(FOCUSED_APPOINTMENT, appointmentToFocus) + addObject("hasSyncErrors", hasSyncErrors) } companion object { fun of( date: LocalDate, - appointments: Iterable>, + appointments: GetCalendarAppointmentsRs, appointmentToFocus: UUID? = null ): CalendarPageModel { - val timeMarks = generateTimeMarks(appointments, date) + val timeMarks = generateTimeMarks(appointments.appointments, date) val weekCalendar = generateDaysAround(date) - return CalendarPageModel(date, timeMarks, weekCalendar, appointmentToFocus) + return CalendarPageModel(date, timeMarks, weekCalendar, appointmentToFocus, appointments.hasErrors) } const val FOCUSED_APPOINTMENT = "focusedAppointment" diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index e7e8c8db..7f84d1f5 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -2,6 +2,7 @@ package pro.qyoga.app.therapist.appointments.core.schedule import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval +import pro.azhidkov.platform.kotlin.tryExecute import pro.qyoga.core.appointments.core.AppointmentsRepo import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.google.GoogleCalendarsService @@ -12,21 +13,36 @@ import pro.qyoga.core.users.therapists.TherapistRef import java.time.* +data class GetCalendarAppointmentsRs( + val appointments: List>, + val hasErrors: Boolean +) + @Component class GetCalendarAppointmentsOp( private val userSettingsRepo: UserSettingsRepo, private val appointmentsRepo: AppointmentsRepo, private val iCalCalendarsRepo: ICalCalendarsRepo, private val googleCalendarsService: GoogleCalendarsService -) : (TherapistRef, LocalDate) -> Iterable> { +) : (TherapistRef, LocalDate) -> GetCalendarAppointmentsRs { - override fun invoke(therapist: TherapistRef, date: LocalDate): Iterable> { + override fun invoke(therapist: TherapistRef, date: LocalDate): GetCalendarAppointmentsRs { val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapist)) val interval = calendarIntervalAround(date, currentUserTimeZone) val appointments = appointmentsRepo.findCalendarItemsInInterval(therapist, interval) - val drafts = iCalCalendarsRepo.findCalendarItemsInInterval(therapist, interval) + - googleCalendarsService.findCalendarItemsInInterval(therapist, interval) - return appointments + drafts + + val iCalEventsResult = + tryExecute { iCalCalendarsRepo.findCalendarItemsInInterval(therapist, interval) } + + val googleCalendarEventsResult = + tryExecute { googleCalendarsService.findCalendarItemsInInterval(therapist, interval) } + + val drafts = + iCalEventsResult.getOrElse { emptyList() } + + googleCalendarEventsResult.getOrElse { emptyList() } + + val hasErrors = iCalEventsResult.isFailure || googleCalendarEventsResult.isFailure + return GetCalendarAppointmentsRs(appointments + drafts, hasErrors) } } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt index 7b521ec5..6456a665 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt @@ -12,13 +12,15 @@ import java.util.* typealias GoogleAccountRef = AggregateReference +typealias GoogleAccountId = UUID + @Table("therapist_google_accounts") data class GoogleAccount( val ownerRef: TherapistRef, val email: String, val refreshToken: SecretChars, - @Id override val id: UUID = UUIDv7.randomUUID() + @Id override val id: GoogleAccountId = UUIDv7.randomUUID() ) : Identifiable { constructor( diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt index 36546173..45f43572 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -11,8 +11,8 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2Clien import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval +import pro.azhidkov.platform.kotlin.tryExecute import pro.qyoga.core.users.therapists.TherapistRef -import java.io.IOException import java.net.URI import java.time.Duration import java.time.Instant @@ -115,11 +115,4 @@ class GoogleCalendarsClient( return service } -} - -private fun tryExecute(eventsRequest: () -> T): Result = - try { - success(eventsRequest()) - } catch (e: IOException) { - failure(e) - } \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/clients/cards/model/Client.kt b/app/src/main/kotlin/pro/qyoga/core/clients/cards/model/Client.kt index 837d1439..9e32a3a7 100644 --- a/app/src/main/kotlin/pro/qyoga/core/clients/cards/model/Client.kt +++ b/app/src/main/kotlin/pro/qyoga/core/clients/cards/model/Client.kt @@ -15,6 +15,7 @@ import java.time.LocalDate import java.util.* typealias ClientRef = AggregateReference +typealias ClientId = UUID @Table("clients") data class Client( @@ -32,7 +33,7 @@ data class Client( val therapistRef: TherapistRef, @Id - override val id: UUID = UUIDv7.randomUUID(), + override val id: ClientId = UUIDv7.randomUUID(), @CreatedDate val createdAt: Instant = Instant.now(), @LastModifiedDate diff --git a/app/src/main/kotlin/pro/qyoga/infra/web/ThymeleafConfig.kt b/app/src/main/kotlin/pro/qyoga/infra/web/ThymeleafConfig.kt index 673eb926..4a165a84 100644 --- a/app/src/main/kotlin/pro/qyoga/infra/web/ThymeleafConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/infra/web/ThymeleafConfig.kt @@ -3,12 +3,14 @@ package pro.qyoga.infra.web import com.fasterxml.jackson.databind.ObjectMapper import jakarta.annotation.PostConstruct import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy import org.thymeleaf.spring6.SpringTemplateEngine import org.thymeleaf.standard.StandardDialect import org.thymeleaf.standard.serializer.IStandardJavaScriptSerializer @Configuration +@Lazy(false) class ThymeleafConfig( private val objectMapper: ObjectMapper, private val engine: SpringTemplateEngine diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index ef30c734..4e01713d 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -106,6 +106,6 @@ trainer-advisor: logging: level: - org.springframework.security: DEBUG + org.springframework.security: WARN debug: false \ No newline at end of file diff --git a/app/src/main/resources/templates/therapist/appointments/schedule.html b/app/src/main/resources/templates/therapist/appointments/schedule.html index 898d8aec..7863d31f 100644 --- a/app/src/main/resources/templates/therapist/appointments/schedule.html +++ b/app/src/main/resources/templates/therapist/appointments/schedule.html @@ -92,10 +92,11 @@
- @@ -115,11 +116,22 @@ >
-
diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CalendarPageModelTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CalendarPageModelTest.kt index f6d5eb64..5fe9dff3 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CalendarPageModelTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CalendarPageModelTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel +import pro.qyoga.app.therapist.appointments.core.schedule.GetCalendarAppointmentsRs import pro.qyoga.app.therapist.appointments.core.schedule.TimeMark import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother.randomLocalizedAppointmentSummary import java.time.Duration @@ -31,7 +32,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.timeMarks.first().time shouldBe firstAppointmentStartTime @@ -56,7 +57,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка val lastTimeMark = calendarPageModel.timeMarks.last() @@ -78,7 +79,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.timeMarks.first().time shouldBe LocalTime.MIDNIGHT @@ -99,7 +100,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(yesterday, appointments) + val calendarPageModel = CalendarPageModel.of(yesterday, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.timeMarks.first().time shouldBe LocalTime.MIDNIGHT @@ -121,7 +122,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.timeMarks.first().time.hour shouldBe CalendarPageModel.DEFAULT_START_HOUR @@ -144,7 +145,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.timeMarks.first().time shouldBe LocalTime.MIDNIGHT @@ -185,7 +186,7 @@ class CalendarPageModelTest { ) // Действие - val calendarPageModel = CalendarPageModel.of(today, appointments) + val calendarPageModel = CalendarPageModel.of(today, GetCalendarAppointmentsRs(appointments, false)) // Проверка calendarPageModel.appointmentCards() shouldHaveSize 2 diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt index 42e909fd..574c3e38 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt @@ -7,7 +7,9 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.tests.assertions.SelectorOnlyComponent import pro.qyoga.tests.assertions.shouldBePage +import pro.qyoga.tests.assertions.shouldHaveComponent import pro.qyoga.tests.clients.TherapistClient import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone import pro.qyoga.tests.fixture.data.randomWorkingTime @@ -15,6 +17,7 @@ import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMot import pro.qyoga.tests.fixture.object_mothers.appointments.DURATION_FOR_FULL_LABEL import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets +import pro.qyoga.tests.fixture.presets.ScheduleFixturePreset import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseTest import pro.qyoga.tests.pages.therapist.appointments.CalendarPage import pro.qyoga.tests.pages.therapist.appointments.appointmentCards @@ -127,4 +130,23 @@ class SchedulePageTest : QYogaAppIntegrationBaseTest() { document.appointmentCards().single() shouldMatch event } + @Test + fun `должна рендериться корректно, даже если у терапевта есть подключенный Google-календарь и запрос событий из него приводит к ошибке`() { + // Arrange + val fixture = ScheduleFixturePreset.fixtureWithAppointmentAndGoogleCalendar() + val appointment = fixture.theAppointment() + val day = appointment.dateTime.toLocalDate() + getBean().insertFixture(fixture) + // в моке не установлен ответ на запрос получения событий + + // Act + val document = theTherapist.appointments.getScheduleForDay(day) + + // Assert + document shouldBePage CalendarPage + document.appointmentCards() shouldHaveSize 1 + document.appointmentCards().single() shouldMatch appointment + document shouldHaveComponent SelectorOnlyComponent(CalendarPage.SYNC_ERROR_ICON_SELECTOR) + } + } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/AppointmentsBackgrounds.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/AppointmentsBackgrounds.kt index 78bd6bdc..d18c63fe 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/AppointmentsBackgrounds.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/AppointmentsBackgrounds.kt @@ -14,11 +14,7 @@ import pro.qyoga.core.therapy.therapeutic_tasks.model.TherapeuticTaskRef import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.core.users.therapists.ref -import pro.qyoga.tests.fixture.data.faker -import pro.qyoga.tests.fixture.data.randomCyrillicWord -import pro.qyoga.tests.fixture.data.randomElement -import pro.qyoga.tests.fixture.data.randomSentence -import pro.qyoga.tests.fixture.data.randomTimeZone +import pro.qyoga.tests.fixture.data.* import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentCost import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDate @@ -50,7 +46,7 @@ class AppointmentsBackgrounds( date: LocalDate, therapistUserDetails: QyogaUserDetails = theTherapistUserDetails ): Iterable> { - return getCalendarAppointments(therapistUserDetails.ref, date) + return getCalendarAppointments(therapistUserDetails.ref, date).appointments } fun createFull( diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ClientsBackgrounds.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ClientsBackgrounds.kt index 1e721402..aa24b7dd 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ClientsBackgrounds.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ClientsBackgrounds.kt @@ -29,7 +29,7 @@ class ClientsBackgrounds( return createClients(1, THE_THERAPIST_ID).single() } - fun createClients(clients: List, therapistId: UUID = THE_THERAPIST_ID): Iterable { + fun createClients(clients: Collection, therapistId: UUID = THE_THERAPIST_ID): Iterable { return clientsRepo.saveAll(clients.map { ClientsObjectMother.createClient(therapistId, it) }) } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Id.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Id.kt new file mode 100644 index 00000000..968b635e --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/data/Id.kt @@ -0,0 +1,8 @@ +package pro.qyoga.tests.fixture.data + +import net.datafaker.Faker +import java.util.* + + +fun Faker.randomUUID(): UUID = + UUID.fromString(faker.internet().uuid()) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt index 75ef9f19..260b65bf 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt @@ -1,19 +1,32 @@ package pro.qyoga.tests.fixture.object_mothers.calendars.google -import pro.qyoga.core.calendar.google.GoogleCalendar -import pro.qyoga.core.calendar.google.GoogleCalendarItem -import pro.qyoga.core.calendar.google.GoogleCalendarItemId +import pro.qyoga.core.calendar.google.* import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomElementOf import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDuration import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aAppointmentEventTitle +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import java.time.Duration import java.time.temporal.Temporal +import java.util.* object GoogleCalendarObjectMother { + fun aGoogleAccount(therapist: TherapistRef): GoogleAccount = + GoogleAccount( + therapist, + faker.internet().emailAddress(), + aGoogleToken() + ) + + fun aGoogleToken(): String { + val bytes = faker.random().nextRandomBytes(64) + val core = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + return "1//$core" + } + fun aGoogleCalendar( ownerRef: TherapistRef, externalId: String, @@ -51,4 +64,17 @@ object GoogleCalendarObjectMother { ) ) + fun aGoogleCalendarSettings( + googleAccountRef: GoogleAccountRef, + therapistRef: TherapistRef = THE_THERAPIST_REF, + externalId: String = faker.internet().uuid(), + shouldBeShown: Boolean = false + ): GoogleCalendarSettings = + GoogleCalendarSettings( + therapistRef, + googleAccountRef, + externalId, + shouldBeShown + ) + } \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/clients/ClientsObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/clients/ClientsObjectMother.kt index 8a5d6500..39eecf8d 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/clients/ClientsObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/clients/ClientsObjectMother.kt @@ -107,7 +107,7 @@ object ClientsObjectMother { ) fun randomId() = - UUIDv7.randomUUID() + UUID.fromString(UUIDv7.randomUUID().toString()) val fakeClientRef: ClientRef = AggregateReferenceTarget( createClient(THE_THERAPIST_ID, createClientCardDtoMinimal()) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index b80b3ac4..ba4659ff 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -1,6 +1,7 @@ package pro.qyoga.tests.fixture.presets import org.springframework.context.ApplicationContext +import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.core.users.therapists.TherapistRef @@ -15,9 +16,9 @@ import pro.qyoga.tests.platform.spring.context.getBean class GoogleCalendarFixturePresets( - private val mockGoogleOAuthServer: MockGoogleOAuthServer, - private val mockGoogleCalendar: MockGoogleCalendar, - private val googleCalendarsTestApi: GoogleCalendarTestApi + val mockGoogleOAuthServer: MockGoogleOAuthServer, + val mockGoogleCalendar: MockGoogleCalendar, + val googleCalendarsTestApi: GoogleCalendarTestApi ) { fun setupCalendar( @@ -33,8 +34,14 @@ class GoogleCalendarFixturePresets( listOf(aGoogleCalendar(ownerRef = therapistRef, externalId = calendarId)) ) mockGoogleCalendar.OnGetEvents(accessToken, calendarId).returnsEvents(*events) - val account = googleCalendarsTestApi.addAccount(therapistRef, faker.internet().emailAddress(), refreshToken) - googleCalendarsTestApi.setShouldBeShown(therapistRef, account, calendarId, shouldBeShown) + val account = googleCalendarsTestApi.addAccount( + GoogleAccount( + therapistRef, + faker.internet().emailAddress(), + refreshToken + ) + ) + googleCalendarsTestApi.setShouldBeShown(therapistRef, account.ref(), calendarId, shouldBeShown) return account } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt new file mode 100644 index 00000000..d49616d9 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt @@ -0,0 +1,106 @@ +package pro.qyoga.tests.fixture.presets + +import org.springframework.stereotype.Component +import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref +import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest +import pro.qyoga.core.calendar.google.GoogleAccount +import pro.qyoga.core.calendar.google.GoogleAccountId +import pro.qyoga.core.calendar.google.GoogleAccountRef +import pro.qyoga.core.calendar.google.GoogleCalendarSettings +import pro.qyoga.core.clients.cards.Client +import pro.qyoga.core.clients.cards.dtos.ClientCardDto +import pro.qyoga.core.clients.cards.model.ClientId +import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.tests.fixture.backgrounds.AppointmentsBackgrounds +import pro.qyoga.tests.fixture.backgrounds.ClientsBackgrounds +import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleToken +import pro.qyoga.tests.fixture.object_mothers.clients.ClientsObjectMother.createClientCardDtoMinimal +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_ID +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi +import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar +import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer + + +typealias GoogleAccessToken = String + +data class ScheduleFixture( + val clients: Map, + val appointments: Map>, + val googleAccounts: List>, + val googleCalendars: Map>, + val therapist: TherapistRef = THE_THERAPIST_REF +) { + + fun theAppointment() = + appointments.values.single().single() + +} + +@Component +class ScheduleFixturePreset( + private val clientsBackgrounds: ClientsBackgrounds, + private val appointmentsBackgrounds: AppointmentsBackgrounds, + private val googlCalendarTestApi: GoogleCalendarTestApi, + private val mockGoogleOAuthServer: MockGoogleOAuthServer, + private val mockGoogleCalendar: MockGoogleCalendar +) { + + fun insertFixture(scheduleFixture: ScheduleFixture) { + val idMapping = clientsBackgrounds.createClients(scheduleFixture.clients.values, scheduleFixture.therapist.id!!) + .zip(scheduleFixture.clients.keys) + .associate { it.second to it.first.ref() } + + scheduleFixture.appointments.forEach { (clientId, appointments) -> + appointments.forEach { + appointmentsBackgrounds.create(it.copy(client = idMapping[it.client.id]!!), scheduleFixture.therapist) + } + } + scheduleFixture.googleAccounts.forEach { (acc, accessToken) -> + googlCalendarTestApi.addAccount(acc) + mockGoogleOAuthServer.OnRefreshToken(acc.refreshToken.show()).returnsToken() + mockGoogleCalendar.OnGetCalendars(accessToken) + } + scheduleFixture.googleCalendars + .forEach { (accountId, calendars) -> + calendars + .filter { it.shouldBeShown } + .forEach { + googlCalendarTestApi.setShouldBeShown( + scheduleFixture.therapist, + GoogleAccountRef.to(accountId), + it.calendarId, + true + ) + } + } + } + + companion object { + + fun fixtureWithAppointmentAndGoogleCalendar(): ScheduleFixture { + val client = createClientCardDtoMinimal() + val client1 = Client(THE_THERAPIST_ID, client) + val clientId = client1.id + + val appointment = AppointmentsObjectMother.randomEditAppointmentRequest(client1.ref()) + + val googleAccount = GoogleCalendarObjectMother.aGoogleAccount(THE_THERAPIST_REF) + val googleCalendar = GoogleCalendarObjectMother.aGoogleCalendarSettings( + googleAccountRef = googleAccount.ref(), + shouldBeShown = true + ) + + return ScheduleFixture( + mapOf(clientId to client), + mapOf(clientId to listOf(appointment)), + listOf(googleAccount to aGoogleToken()), + mapOf(googleAccount.id to listOf(googleCalendar)) + ) + } + + } + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt index 01c84ec1..14b4bb23 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt @@ -1,10 +1,10 @@ package pro.qyoga.tests.fixture.test_apis import org.springframework.stereotype.Component -import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.core.calendar.google.GoogleAccountRef import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.object_mothers.therapists.idOnlyUserDetails @@ -22,21 +22,20 @@ class GoogleCalendarTestApi( ).accounts } - fun addAccount(therapistRef: TherapistRef, email: String, refreshToken: String): GoogleAccount { - val googleAccount = GoogleAccount(therapistRef, email, refreshToken) + fun addAccount(googleAccount: GoogleAccount): GoogleAccount { googleCalendarsService.addGoogleAccount(googleAccount) return googleAccount } fun setShouldBeShown( therapistRef: TherapistRef, - account: GoogleAccount, + accountRef: GoogleAccountRef, calendarId: String, shouldBeShown: Boolean ) { googleCalendarsService.updateCalendarSettings( therapistRef, - account.ref(), + accountRef, calendarId, mapOf("shouldBeShown" to shouldBeShown) ) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index dc6df136..412e6887 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -4,6 +4,7 @@ import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.MappingBuilder import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component import org.springframework.web.util.UriUtils import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItem @@ -16,6 +17,7 @@ import kotlin.text.Charsets.UTF_8 /** * Mock implementation of Google Calendar API for testing purposes. */ +@Component class MockGoogleCalendar( private val wiremockServer: WireMockServer ) { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt index 153895ee..5a05e130 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleOAuthServer.kt @@ -3,9 +3,11 @@ package pro.qyoga.tests.fixture.wiremocks import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component import pro.qyoga.app.therapist.oauth2.GoogleOAuthController +@Component class MockGoogleOAuthServer( private val wiremockServer: WireMockServer ) { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockServersConf.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockServersConf.kt new file mode 100644 index 00000000..9c4fca90 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockServersConf.kt @@ -0,0 +1,9 @@ +package pro.qyoga.tests.fixture.wiremocks + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.ComponentScan + + +@TestConfiguration +@ComponentScan +class MockServersConf \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt index 69330080..f6665dfd 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt @@ -13,6 +13,7 @@ import pro.qyoga.tests.fixture.FailingController import pro.qyoga.tests.fixture.backgrounds.BackgroundsConfig import pro.qyoga.tests.fixture.presets.Presets import pro.qyoga.tests.fixture.test_apis.TestApisConf +import pro.qyoga.tests.fixture.wiremocks.MockServersConf import pro.qyoga.tests.infra.test_config.spring.auth.TestPasswordEncoderConfig import pro.qyoga.tests.infra.test_config.spring.db.TestDataSourceConfig import pro.qyoga.tests.infra.test_config.spring.minio.TestMinioConfig @@ -44,7 +45,9 @@ val sdjContext by lazy { TestPasswordEncoderConfig::class, TestDataSourceConfig::class, TestMinioConfig::class, - FailingController::class + FailingController::class, + WireMockConf::class, + MockServersConf::class, ) @Configuration class TestsConfig diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/WireMockConf.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/WireMockConf.kt new file mode 100644 index 00000000..eccb8e54 --- /dev/null +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/WireMockConf.kt @@ -0,0 +1,18 @@ +package pro.qyoga.tests.infra.test_config.spring + +import com.github.tomakehurst.wiremock.WireMockServer +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import pro.qyoga.tests.infra.wiremock.WireMock + + +@TestConfiguration +@Configuration +class WireMockConf { + + @Bean + fun wireMockServer(): WireMockServer = + WireMock.wiremock + +} \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/wiremock/WireMock.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/wiremock/WireMock.kt index d917fe94..9e77c7a9 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/wiremock/WireMock.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/wiremock/WireMock.kt @@ -2,11 +2,15 @@ package pro.qyoga.tests.infra.wiremock import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options +import org.slf4j.LoggerFactory +private val log = LoggerFactory.getLogger(WireMock::class.java) + object WireMock { private val wiremockDelegate = lazy { + log.warn("Starting WireMock server on port 8089") val wireMockServer = WireMockServer(options().port(8089)) wireMockServer.start() wireMockServer diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index 6d649b66..889b8b5e 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -14,6 +14,7 @@ import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.appointments.core.schedule.TimeMark +import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.Appointment import pro.qyoga.core.calendar.ical.model.ICalCalendarItem import pro.qyoga.l10n.russianTimeFormat @@ -29,6 +30,8 @@ object CalendarPage : HtmlPage { private val goToDayLink = Link("goToDayLink-", SchedulePageController.DATE_PATH, "") + const val SYNC_ERROR_ICON_SELECTOR = ".sync-error-icon" + object RevealAppointmentScript : Script("revealAppointment") { val appToFocus = Variable(CalendarPageModel.FOCUSED_APPOINTMENT) override val vars: List = listOf(appToFocus) @@ -76,6 +79,17 @@ infix fun Elements.shouldMatch(appointments: Iterable) { } } +infix fun Element.shouldMatch(app: EditAppointmentRequest) { + this shouldHaveComponent Link( + "editAppointmentLink", + EditAppointmentPage, + app.client.resolveOrThrow().fullName() + " " + + russianTimeFormat.format(app.dateTime) + " - " + russianTimeFormat.format(app.dateTime + app.duration) + " " + app.appointmentTypeTitle + ) + select("div.appointment-card") + .single() shouldHaveClass AppointmentCard.appointmentStatusClasses[app.appointmentStatus]!! +} + infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { this shouldHaveComponent Link( "editAppointmentLink", diff --git a/app/src/testFixtures/resources/application-test.yaml b/app/src/testFixtures/resources/application-test.yaml index e65ebdc7..ff8909a2 100644 --- a/app/src/testFixtures/resources/application-test.yaml +++ b/app/src/testFixtures/resources/application-test.yaml @@ -2,6 +2,9 @@ server: port: 10803 spring: + main: + lazy-initialization: true + mail: username: qyogapro@yandex.ru password: password diff --git a/app/src/testFixtures/resources/logback-test.xml b/app/src/testFixtures/resources/logback-test.xml index e650bf9f..5ef97f5f 100644 --- a/app/src/testFixtures/resources/logback-test.xml +++ b/app/src/testFixtures/resources/logback-test.xml @@ -6,11 +6,11 @@ - + - + From f07e106991ae8781f11700675e01cc765a01565b Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 19 Sep 2025 15:47:17 +0700 Subject: [PATCH 20/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20Google-=D1=8D=D0=B2=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pro/azhidkov/platform/kotlin/ResultExt.kt | 3 + .../platform/spring/jdbc/RowMapperExt.kt | 4 +- .../sdj/converters/CharArrayConverters.kt | 6 +- .../core/edit/forms/CreateAppointmentForm.kt | 5 +- .../edit/ops/GetAppointmentPrefillDataOp.kt | 21 ++++-- .../core/edit/view_model/SourceItem.kt | 13 ++-- .../core/schedule/GetCalendarAppointments.kt | 14 ++-- .../appointments/core/AppointmentsRepo.kt | 15 ++-- .../qyoga/core/calendar/api/CalendarItem.kt | 6 ++ .../core/calendar/api/CalendarsService.kt | 13 +++- .../core/calendar/google/GoogleAccountsDao.kt | 26 ++++++- .../core/calendar/google/GoogleCalendar.kt | 7 +- .../calendar/google/GoogleCalendarItem.kt | 12 +++- .../calendar/google/GoogleCalendarsClient.kt | 71 +++++++++++++------ .../calendar/google/GoogleCalendarsService.kt | 42 ++++++++--- .../core/calendar/ical/ICalCalendarsRepo.kt | 19 ++--- .../core/calendar/ical/model/ICalEventId.kt | 10 ++- .../vendor/htmx/js/json-enc-2.0.2.min.js | 1 + .../core/CreateAppointmentPageTest.kt | 25 ++++++- .../core/SchedulePageControllerTest.kt | 9 ++- .../GetGoogleCalendarsSettingsEndpointTest.kt | 2 +- .../google/SetCalendarShouldBeShownTest.kt | 2 +- .../google/GoogleCalendarsServiceTest.kt | 2 - .../google/GoogleCalendarObjectMother.kt | 16 ++++- .../presets/GoogleCalendarFixturePresets.kt | 13 +++- .../presets/{Presets.kt => PresetsConf.kt} | 2 +- .../fixture/wiremocks/MockGoogleCalendar.kt | 27 ++++++- .../infra/test_config/spring/TestsConfig.kt | 4 +- .../tests/infra/web/QYogaAppBaseKoTest.kt | 4 +- .../qyoga/tests/infra/web/QYogaAppBaseTest.kt | 4 +- 30 files changed, 306 insertions(+), 92 deletions(-) create mode 100644 app/src/main/resources/static/vendor/htmx/js/json-enc-2.0.2.min.js rename app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/{Presets.kt => PresetsConf.kt} (95%) diff --git a/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt b/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt index 160eb936..f5987f15 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/kotlin/ResultExt.kt @@ -39,6 +39,9 @@ inline fun Result.mapNull(transform: () -> R): Re inline fun Result.recoverFailure(block: (T) -> R): Result = if (this.exceptionOrNull() is T) success(block(this.exceptionOrNull() as T)) else this +inline fun Result.tryRecover(block: (T) -> Result): Result = + if (this.exceptionOrNull() is T) block(this.exceptionOrNull() as T) else this + inline fun Result.mapFailure(block: (T) -> Throwable): Result = if (this.exceptionOrNull() is T) failure(block(this.exceptionOrNull() as T)) else this diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt index d50eec8f..6be193ad 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt @@ -7,6 +7,7 @@ import org.springframework.data.jdbc.core.mapping.AggregateReference import org.springframework.jdbc.core.DataClassRowMapper import org.springframework.jdbc.core.RowMapper import pro.azhidkov.platform.spring.sdj.converters.PGIntervalToDurationConverter +import pro.azhidkov.platform.spring.sdj.converters.StringToSecretChars import java.util.* @@ -29,5 +30,6 @@ inline fun taDataClassRowMapper() = DataClassRowMapper.newInstance(T conversionService = DefaultConversionService().apply { addConverter(PGIntervalToDurationConverter()) addConverter(UuidToAggregateReferenceConverter) + addConverter(StringToSecretChars()) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt index d59e83e1..06bf550d 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt @@ -22,6 +22,10 @@ data class SecretChars(val value: CharArray) { return value.contentHashCode() } + override fun toString(): String { + return "" + } + } @WritingConverter @@ -32,4 +36,4 @@ class SecretCharsToString : Converter { @ReadingConverter class StringToSecretChars : Converter { override fun convert(source: String) = SecretChars(source.toCharArray()) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt index 47bf289e..03850a6a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt @@ -1,10 +1,9 @@ package pro.qyoga.app.therapist.appointments.core.edit.forms -import pro.qyoga.app.therapist.appointments.core.edit.view_model.toQueryParamStr import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.types.model.AppointmentTypeRef import pro.qyoga.core.calendar.api.CalendarItem -import pro.qyoga.core.calendar.ical.model.ICalEventId +import pro.qyoga.core.calendar.api.CalendarItemId import pro.qyoga.core.clients.cards.model.ClientRef import pro.qyoga.core.therapy.therapeutic_tasks.model.TherapeuticTaskRef import java.time.Duration @@ -33,7 +32,7 @@ data class CreateAppointmentForm( ) { constructor( - iCalEvent: CalendarItem?, + iCalEvent: CalendarItem?, dateTime: LocalDateTime?, timeZone: ZoneId, timeZoneTitle: String? diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index 0b5b7798..9f47f004 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -4,8 +4,12 @@ import org.springframework.stereotype.Component import pro.azhidkov.timezones.TimeZones import pro.qyoga.app.therapist.appointments.core.edit.forms.CreateAppointmentForm import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem +import pro.qyoga.app.therapist.appointments.core.edit.view_model.googleEventId import pro.qyoga.app.therapist.appointments.core.edit.view_model.icsEventId +import pro.qyoga.core.calendar.google.GoogleCalendar +import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.calendar.ical.ICalCalendarsRepo +import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef @@ -15,6 +19,7 @@ import java.time.LocalDateTime @Component class GetAppointmentPrefillDataOp( private val iCalCalendarsRepo: ICalCalendarsRepo, + private val googleCalendarsService: GoogleCalendarsService, private val userSettingsRepo: UserSettingsRepo, private val timeZones: TimeZones, ) : (TherapistRef, SourceItem?, LocalDateTime?) -> CreateAppointmentForm { @@ -26,14 +31,22 @@ class GetAppointmentPrefillDataOp( ): CreateAppointmentForm { val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapistRef)) - val iCalEvent = sourceItem?.icsEventId() - ?.let { iCalCalendarsRepo.findById(therapistRef, it) } + val sourceEvent = when (sourceItem?.type) { + ICalCalendar.TYPE -> + iCalCalendarsRepo.findById(therapistRef, sourceItem.icsEventId()) - val timeZone = iCalEvent?.dateTime?.zone + GoogleCalendar.TYPE -> + googleCalendarsService.findById(therapistRef, sourceItem.googleEventId()) + + else -> + null + } + + val timeZone = sourceEvent?.dateTime?.zone ?: currentUserTimeZone val timeZoneTitle = timeZones.findById(timeZone)?.displayName - return CreateAppointmentForm(iCalEvent, dateTime, timeZone, timeZoneTitle) + return CreateAppointmentForm(sourceEvent, dateTime, timeZone, timeZoneTitle) } } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index 006bed36..4c2f7947 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -1,5 +1,6 @@ package pro.qyoga.app.therapist.appointments.core.edit.view_model +import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.calendar.ical.model.ICalEventId @@ -14,15 +15,12 @@ data class SourceItem( SourceItem(ICalCalendar.TYPE, eventId.toQueryParamStr()) fun googleEvent(eventId: GoogleCalendarItemId): SourceItem = - SourceItem("Google", eventId.value) + SourceItem("Google", eventId.toQueryParamStr()) } } -fun ICalEventId.toQueryParamStr(): String = - "uid=${uid},rid=${recurrenceId ?: ""}" - fun SourceItem.icsEventId(): ICalEventId { check(type == ICalCalendar.TYPE) val matcher = "uid=(.+),rid=(.*)".toRegex().matchEntire(id) @@ -30,4 +28,11 @@ fun SourceItem.icsEventId(): ICalEventId { val uid = matcher.groups[1]!!.value val rid = matcher.groups[2]!!.value.takeIf { it.isNotBlank() } return ICalEventId(uid, rid) +} + +fun SourceItem.googleEventId(): GoogleCalendarItemId { + check(type == GoogleCalendar.TYPE) + val matcher = "(.+),(.+)".toRegex().matchEntire(id) + check(matcher != null) + return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index 7f84d1f5..34513e37 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -5,6 +5,7 @@ import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.kotlin.tryExecute import pro.qyoga.core.appointments.core.AppointmentsRepo import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.calendar.ical.ICalCalendarsRepo import pro.qyoga.core.users.auth.model.UserRef @@ -37,11 +38,10 @@ class GetCalendarAppointmentsOp( val googleCalendarEventsResult = tryExecute { googleCalendarsService.findCalendarItemsInInterval(therapist, interval) } - val drafts = - iCalEventsResult.getOrElse { emptyList() } + - googleCalendarEventsResult.getOrElse { emptyList() } + val drafts = iCalEventsResult.items() + googleCalendarEventsResult.items() + + val hasErrors = iCalEventsResult.hasErrors() || googleCalendarEventsResult.hasErrors() - val hasErrors = iCalEventsResult.isFailure || googleCalendarEventsResult.isFailure return GetCalendarAppointmentsRs(appointments + drafts, hasErrors) } @@ -54,3 +54,9 @@ private fun calendarIntervalAround( val from = date.minusDays((CalendarPageModel.DAYS_IN_CALENDAR / 2).toLong()).atStartOfDay(currentUserTimeZone) return Interval.of(from, Duration.ofDays(CalendarPageModel.DAYS_IN_CALENDAR.toLong())) } + +private fun Result>.items(): Iterable> = + this.getOrNull() ?: emptyList() + +private fun Result>.hasErrors() = + this.isFailure || this.getOrThrow().hasErrors diff --git a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt index 10ec6bde..b98caeab 100644 --- a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt @@ -15,11 +15,11 @@ import pro.qyoga.core.appointments.core.model.Appointment import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef import java.sql.Timestamp import java.time.Duration import java.time.Instant -import java.time.LocalDateTime import java.time.ZonedDateTime import java.util.* @@ -35,12 +35,12 @@ class AppointmentsRepo( Appointment::class, jdbcConverter, relationalMappingContext -), CalendarsService { +), CalendarsService { override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval, - ): Iterable> { + ): SearchResult { @Language("PostgreSQL") val query = """ WITH localized_appointment_summary AS (SELECT @@ -70,7 +70,14 @@ class AppointmentsRepo( "to" to interval.to.toLocalDateTime(), "localTimeZone" to interval.zoneId.id ) - return findAll(query, params, localizedAppointmentSummaryRowMapper) + return SearchResult(findAll(query, params, localizedAppointmentSummaryRowMapper)) + } + + override fun findById( + therapistRef: TherapistRef, + eventId: UUID + ): CalendarItem? { + TODO() } } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt index 10c8862c..7b9ccb61 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt @@ -3,6 +3,12 @@ package pro.qyoga.core.calendar.api import java.time.Duration import java.time.temporal.Temporal +interface CalendarItemId { + + fun toQueryParamStr(): String + +} + interface CalendarItem { val id: ID val title: String diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt index 00f477a1..525662f3 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt @@ -6,11 +6,18 @@ import java.time.LocalDateTime import java.time.ZonedDateTime -interface CalendarsService { +data class SearchResult( + val items: Iterable>, + val hasErrors: Boolean = false +) : Iterable> by items + +interface CalendarsService { fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval - ): Iterable> + ): SearchResult + + fun findById(therapistRef: TherapistRef, eventId: ID): CalendarItem? -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt index ff74615f..bec6a6ac 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt @@ -2,14 +2,19 @@ package pro.qyoga.core.calendar.google import org.springframework.data.jdbc.core.JdbcAggregateTemplate import org.springframework.data.jdbc.core.findAllById +import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.stereotype.Repository +import pro.azhidkov.platform.spring.jdbc.taDataClassRowMapper import pro.azhidkov.platform.spring.sdj.query.query import pro.qyoga.core.users.therapists.TherapistRef +private val googleAccountRowMapper = taDataClassRowMapper() + @Repository class GoogleAccountsDao( - private val jdbcAggregateTemplate: JdbcAggregateTemplate + private val jdbcAggregateTemplate: JdbcAggregateTemplate, + private val jdbcClient: JdbcClient ) { fun addGoogleAccount(googleAccount: GoogleAccount) { @@ -27,4 +32,21 @@ class GoogleAccountsDao( return jdbcAggregateTemplate.findAllById(accountIds.map { it.id }) } -} \ No newline at end of file + fun findGoogleAccount(therapistRef: TherapistRef, calendarId: String): List { + val query = """ + SELECT * + FROM therapist_google_accounts + JOIN therapist_google_calendar_settings + ON therapist_google_accounts.id = therapist_google_calendar_settings.google_account_ref + WHERE therapist_google_accounts.owner_ref = :therapistRef + AND calendar_id = :calendarId + """.trimIndent() + + return jdbcClient.sql(query) + .param("therapistRef", therapistRef.id) + .param("calendarId", calendarId) + .query(googleAccountRowMapper) + .list() + } + +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt index e2d8697f..71164d0a 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt @@ -10,6 +10,11 @@ data class GoogleCalendar( override val name: String, ) : Calendar { - override val type: String = "Google" + override val type: String = TYPE + + companion object { + + const val TYPE = "Google" + } } \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt index b7135a52..4e2892bd 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt @@ -2,12 +2,20 @@ package pro.qyoga.core.calendar.google import pro.azhidkov.platform.java.time.toLocalDateTime import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarItemId import java.time.Duration import java.time.ZoneId import java.time.temporal.Temporal -@JvmInline -value class GoogleCalendarItemId(val value: String) +data class GoogleCalendarItemId( + val calendarId: String, + val itemId: String +) : CalendarItemId { + + override fun toQueryParamStr(): String = + "$calendarId,$itemId" + +} data class GoogleCalendarItem( override val id: GoogleCalendarItemId, diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt index 45f43572..02f10695 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt @@ -1,5 +1,6 @@ package pro.qyoga.core.calendar.google +import com.google.api.client.googleapis.json.GoogleJsonResponseException import com.google.api.client.util.DateTime import com.google.api.services.calendar.Calendar import com.google.api.services.calendar.model.Event @@ -9,9 +10,12 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties import org.springframework.cache.annotation.Cacheable +import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.kotlin.tryExecute +import pro.azhidkov.platform.kotlin.tryRecover +import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.users.therapists.TherapistRef import java.net.URI import java.time.Duration @@ -54,29 +58,10 @@ class GoogleCalendarsClient( .setSingleEvents(true) .execute() .items - .map { - GoogleCalendarItem( - GoogleCalendarItemId(it.id), - it.summary, - it.description ?: "", - startDate(it), - duration(it), - it.location - ) - } + .map { mapToCalendarItem(calendarSettings.calendarId, it) } return events } - private fun startDate(event: Event): ZonedDateTime = - ZonedDateTime.ofInstant( - Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), - ZoneId.of(event.start.timeZone) - ) - - private fun duration(event: Event): Duration = - Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - - Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) - @Cacheable( cacheNames = [GoogleCalendarConf.CacheNames.GOOGLE_ACCOUNT_CALENDARS], key = "#therapist.id + ':' + #account.id" @@ -100,6 +85,31 @@ class GoogleCalendarsClient( return success(calendarsList) } + @Cacheable( + cacheNames = [GoogleCalendarConf.CacheNames.CALENDAR_EVENTS], + key = "#eventId" + ) + fun findById( + account: GoogleAccount, + eventId: GoogleCalendarItemId + ): CalendarItem? { + val service = servicesCache.getValue(account) + + val getEventRequest = service.events().get(eventId.calendarId, eventId.itemId) + + val event = tryExecute { getEventRequest.execute() } + .tryRecover { + if (it.statusCode == HttpStatus.NOT_FOUND.value()) { + success(null) + } else { + failure(it) + } + } + .getOrThrow() + + return event?.let { mapToCalendarItem(eventId.calendarId, it) } + } + private fun createCalendarService(account: GoogleAccount): Calendar { val credentials = UserCredentials.newBuilder() .setClientId(googleOAuthProps.registration["google"]!!.clientId) @@ -115,4 +125,23 @@ class GoogleCalendarsClient( return service } -} \ No newline at end of file +} + +private fun mapToCalendarItem(calendarId: String, event: Event): GoogleCalendarItem = GoogleCalendarItem( + GoogleCalendarItemId(calendarId, event.id), + event.summary, + event.description ?: "", + startDate(event), + duration(event), + event.location +) + +private fun startDate(event: Event): ZonedDateTime = + ZonedDateTime.ofInstant( + Instant.ofEpochMilli(event.start.dateTime?.value ?: event.start.date?.value ?: 0), + ZoneId.of(event.start.timeZone) + ) + +private fun duration(event: Event): Duration = + Duration.ofMillis(event.end.dateTime?.value ?: event.end.date?.value ?: 0) - + Duration.ofMillis(event.start.dateTime?.value ?: event.start.date?.value ?: 0) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt index 9f955ccc..2b97359d 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt @@ -3,16 +3,19 @@ package pro.qyoga.core.calendar.google import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory +import org.apache.tomcat.util.threads.VirtualThreadExecutor import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.java.time.zoneId +import pro.azhidkov.platform.kotlin.tryExecute import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef -import java.time.LocalDateTime import java.time.ZonedDateTime import java.util.* +import java.util.concurrent.CompletableFuture const val APPLICATION_NAME = "Trainer Advisor" @@ -76,7 +79,9 @@ class GoogleCalendarsService( private val googleAccountsDao: GoogleAccountsDao, private val googleCalendarsDao: GoogleCalendarsDao, private val googleCalendarsClient: GoogleCalendarsClient, -) : CalendarsService { +) : CalendarsService { + + private val executor = VirtualThreadExecutor("google-calendar-events-fetcher") fun addGoogleAccount(googleAccount: GoogleAccount) { googleAccountsDao.addGoogleAccount(googleAccount) @@ -98,17 +103,17 @@ class GoogleCalendarsService( override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval - ): Iterable> { + ): SearchResult { val googleCalendarSettings = googleCalendarsDao.findCalendarsSettings(therapist) if (googleCalendarSettings.isEmpty()) { - return emptyList() + return SearchResult(emptyList()) } val accountCalendars = googleCalendarSettings.values.groupBy { it.googleAccountRef.id } val accountIds = googleCalendarSettings.values.map { it.googleAccountRef } .distinct() val accounts = googleAccountsDao.findGoogleAccounts(accountIds) - val events = accounts + val fetchTasks = accounts .flatMap { account -> val settings = accountCalendars[account.ref().id] @@ -116,13 +121,32 @@ class GoogleCalendarsService( settings .filter { it.shouldBeShown } - .flatMap { calendarSettings -> - googleCalendarsClient.getEvents(account, calendarSettings, interval) + .map { calendarSettings -> + CompletableFuture.supplyAsync( + { + googleCalendarsClient.getEvents(account, calendarSettings, interval) + }, executor + ) } } + val calendarEventsResults = fetchTasks.map { + tryExecute { it.get() } + } + + val events = calendarEventsResults + .mapNotNull { it.getOrNull() } + .flatMap { it } .map { it.toLocalizedCalendarItem(interval.zoneId) } - return events + return SearchResult(events, hasErrors = calendarEventsResults.any { it.isFailure }) + } + + override fun findById( + therapistRef: TherapistRef, + eventId: GoogleCalendarItemId + ): CalendarItem? { + val account = googleAccountsDao.findGoogleAccount(therapistRef, eventId.calendarId).first() + return googleCalendarsClient.findById(account, eventId) } fun updateCalendarSettings( @@ -134,4 +158,4 @@ class GoogleCalendarsService( googleCalendarsDao.patchCalendarSettings(therapist, googleAccount, calendarId, settingsPatch) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt index fc19c94e..22db332c 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.calendar.ical.commands.CreateICalRq import pro.qyoga.core.calendar.ical.commands.createFrom import pro.qyoga.core.calendar.ical.model.* @@ -13,14 +14,13 @@ import pro.qyoga.core.calendar.ical.persistance.ICalCalendarsDao import pro.qyoga.core.calendar.ical.persistance.findAllByOwner import pro.qyoga.core.calendar.ical.platform.ical4j.toICalCalendarItem import pro.qyoga.core.users.therapists.TherapistRef -import java.time.LocalDateTime import java.time.ZonedDateTime @Component class ICalCalendarsRepo( private val iCalCalendarsDao: ICalCalendarsDao -) : CalendarsService { +) : CalendarsService { private val log = LoggerFactory.getLogger(javaClass) @@ -33,18 +33,21 @@ class ICalCalendarsRepo( override fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval - ): Iterable> { + ): SearchResult { val res = iCalCalendarsDao .findAllByOwner(therapist) .flatMap { ical -> ical.localizedICalCalendarItemsIn(interval) } - return res + return SearchResult(res) } - fun findById(therapist: TherapistRef, icsEventId: ICalEventId): CalendarItem? { - return iCalCalendarsDao.findAllByOwner(therapist) + override fun findById( + therapistRef: TherapistRef, + eventId: ICalEventId + ): CalendarItem? { + return iCalCalendarsDao.findAllByOwner(therapistRef) .asSequence() - .mapNotNull { it.findById(icsEventId) } + .mapNotNull { it.findById(eventId) } .firstOrNull() ?.toICalCalendarItem() } @@ -64,4 +67,4 @@ private fun ICalCalendar.localizedICalCalendarItemsIn( interval: Interval, ): List = (this.calendarItemsIn(interval) ?: emptyList()) - .map(ICalCalendarItem::toLocalizedICalCalendarItem) \ No newline at end of file + .map(ICalCalendarItem::toLocalizedICalCalendarItem) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt index 92909b84..33c8272e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt @@ -1,7 +1,15 @@ package pro.qyoga.core.calendar.ical.model +import pro.qyoga.core.calendar.api.CalendarItemId + data class ICalEventId( val uid: String, val recurrenceId: String? = null -) \ No newline at end of file +) : CalendarItemId { + + override fun toQueryParamStr(): String = + "uid=${uid},rid=${recurrenceId ?: ""}" + + +} \ No newline at end of file diff --git a/app/src/main/resources/static/vendor/htmx/js/json-enc-2.0.2.min.js b/app/src/main/resources/static/vendor/htmx/js/json-enc-2.0.2.min.js new file mode 100644 index 00000000..24816971 --- /dev/null +++ b/app/src/main/resources/static/vendor/htmx/js/json-enc-2.0.2.min.js @@ -0,0 +1 @@ +(function(){let s;htmx.defineExtension("json-enc",{init:function(n){s=n},onEvent:function(n,e){if(n==="htmx:configRequest"){e.detail.headers["Content-Type"]="application/json"}},encodeParameters:function(n,e,t){n.overrideMimeType("text/json");const i={};e.forEach(function(n,e){if(Object.hasOwn(i,e)){if(!Array.isArray(i[e])){i[e]=[i[e]]}i[e].push(n)}else{i[e]=n}});const o=s.getExpressionVars(t);Object.keys(i).forEach(function(n){i[n]=Object.hasOwn(o,n)?o[n]:i[n]});return JSON.stringify(i)}})})(); \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index f41bbce1..7650953d 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -7,7 +7,6 @@ import org.springframework.http.HttpStatus import pro.azhidkov.platform.java.time.toLocalTimeString import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem -import pro.qyoga.app.therapist.appointments.core.edit.view_model.toQueryParamStr import pro.qyoga.core.calendar.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.shouldBePage import pro.qyoga.tests.assertions.shouldHave @@ -21,6 +20,8 @@ import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMot import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother.randomFullEditAppointmentRequest import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDate import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem +import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother +import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseTest import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentForm @@ -43,7 +44,8 @@ private val aTime = LocalTime.now() @DisplayName("Страница создания приёма") class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { - private val ICalsCalendarsFixturePresets = getBean() + private val iCalsCalendarsFixturePresets = getBean() + private val googleCalendarsFixturePresets = getBean() @Test fun `должна рендерится корректно`() { @@ -159,7 +161,7 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { set(field(ICalCalendarItem::duration), Duration.ofMinutes(75)) set(field(ICalCalendarItem::description), randomSentence()) } - ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) + iCalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) // Действие val document = theTherapist.appointments.getCreateAppointmentPage( @@ -175,4 +177,21 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { CreateAppointmentForm.comment.value(document) shouldBe event.description } + @Test + @DisplayName("должна предзаполнять дату, время, длительность и идентификатор события источника данными из события goolge-календаря, если его ид был передан в запросе") // длина имени файла с лямбдой превышает ограничение Линукса + fun createAppointmentWithGoogleEventId() { + // Сетап + val event = GoogleCalendarObjectMother.aGoogleCalendarItem(date = { randomAppointmentDate() }) + googleCalendarsFixturePresets.setupCalendar(event) + + // Действие + val document = theTherapist.appointments.getCreateAppointmentPage( + dateTime = event.dateTime, + sourceItem = SourceItem.googleEvent(event.id) + ) + + // Проверка + CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toQueryParamStr() + } + } \ No newline at end of file diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt index deca408e..e4796ba5 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt @@ -10,7 +10,6 @@ import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomWorkingTime import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDuration import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aAppointmentEventTitle -import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.object_mothers.therapists.theTherapistUserDetails import pro.qyoga.tests.fixture.presets.AppointmentsFixturePresets import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets @@ -54,14 +53,14 @@ class SchedulePageControllerTest : QYogaAppIntegrationBaseKoTest({ val date = LocalDate.of(2025, 9, 14) val calendarId = "calendarId" val event = GoogleCalendarItem( - GoogleCalendarItemId(faker.internet().uuid()), + GoogleCalendarItemId(calendarId, faker.internet().uuid()), aAppointmentEventTitle(), "", date.atTime(randomWorkingTime()), randomAppointmentDuration(), null ) - googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId, event) + googleCalendarFixturePresets.setupCalendar(event, calendarId = calendarId) // Действие val calendarPageModel = @@ -76,14 +75,14 @@ class SchedulePageControllerTest : QYogaAppIntegrationBaseKoTest({ val date = LocalDate.of(2025, 9, 14) val calendarId = "calendarId" val event = GoogleCalendarItem( - GoogleCalendarItemId(faker.internet().uuid()), + GoogleCalendarItemId(calendarId, faker.internet().uuid()), aAppointmentEventTitle(), "", date.atTime(randomWorkingTime()), randomAppointmentDuration(), null ) - googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId, event, shouldBeShown = true) + googleCalendarFixturePresets.setupCalendar(event, calendarId = calendarId, shouldBeShown = true) // Действие val calendarPageModel = diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt index 70eb2955..7b149c17 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt @@ -35,7 +35,7 @@ class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ // Сетап val therapist = TherapistClient.loginAsTheTherapist() val accessToken = "accessToken" - val account = googleCalendarsFixturePresets.setupCalendar(THE_THERAPIST_REF) + val account = googleCalendarsFixturePresets.setupCalendar(therapistRef = THE_THERAPIST_REF) googleCalendarsFixturePresets.mockGoogleCalendar.OnGetCalendars(accessToken) .returnsForbidden() val accounts = listOf( diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt index cb553c0a..4d262146 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt @@ -27,7 +27,7 @@ class SetCalendarShouldBeShownTest : QYogaAppIntegrationBaseKoTest({ // Сетап val calendarId = "calendarId" val therapist = loginAsTheTherapist() - val googleAccount = googleCalendarFixturePresets.setupCalendar(THE_THERAPIST_REF, calendarId) + val googleAccount = googleCalendarFixturePresets.setupCalendar(calendarId = calendarId) // Действие therapist.googleCalendarIntegration.setShouldBeShown(googleAccount.ref(), calendarId, true) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt index 856b3f05..75debaec 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt @@ -6,7 +6,6 @@ import io.kotest.matchers.shouldBe import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone -import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendarItem import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.presets.googleCalendarFixturePresets @@ -29,7 +28,6 @@ class GoogleCalendarsServiceTest : QYogaAppIntegrationBaseKoTest({ "должен возвращать события приведённые к таймзоне запрошенного интервала" { // Сетап googleCalendarFixturePresets.setupCalendar( - THE_THERAPIST_REF, GoogleCalendarObjectMother.aCalendarName(), aGoogleCalendarItem( date = { ZonedDateTime.of(2025, 9, 16, 6, 0, 0, 0, ZoneId.of("Europe/Moscow")) }, duration = Duration.ofMinutes(60) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt index 260b65bf..68e2dfff 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt @@ -38,11 +38,12 @@ object GoogleCalendarObjectMother { ) fun aGoogleCalendarItem( + calendarId: String = faker.random().randomElementOf(googleCalendarIds), date: () -> DATE, duration: Duration = randomAppointmentDuration() ) = GoogleCalendarItem( - GoogleCalendarItemId(faker.internet().uuid()), + GoogleCalendarItemId(calendarId, faker.internet().uuid()), aAppointmentEventTitle(), "", date(), @@ -77,4 +78,15 @@ object GoogleCalendarObjectMother { shouldBeShown ) -} \ No newline at end of file +} + +val googleCalendarIds = listOf( + "primary", + "calendar1@gmail.com", + "calendar2@gmail.com", + "work.calendar@company.com", + "personal.events@gmail.com", + "yoga.sessions@studio.com", + "therapy.appointments@clinic.com", + "group.classes@fitness.com" +) \ No newline at end of file diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index ba4659ff..28275ffc 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -1,6 +1,7 @@ package pro.qyoga.tests.fixture.presets import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.google.GoogleAccount import pro.qyoga.core.calendar.google.GoogleCalendarItem @@ -8,6 +9,7 @@ import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aCalendarName import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar +import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.test_apis.GoogleCalendarTestApi import pro.qyoga.tests.fixture.wiremocks.MockGoogleCalendar import pro.qyoga.tests.fixture.wiremocks.MockGoogleOAuthServer @@ -15,6 +17,7 @@ import pro.qyoga.tests.infra.wiremock.WireMock import pro.qyoga.tests.platform.spring.context.getBean +@Component class GoogleCalendarFixturePresets( val mockGoogleOAuthServer: MockGoogleOAuthServer, val mockGoogleCalendar: MockGoogleCalendar, @@ -22,12 +25,13 @@ class GoogleCalendarFixturePresets( ) { fun setupCalendar( - therapistRef: TherapistRef, - calendarId: String = aCalendarName(), vararg events: GoogleCalendarItem<*>, + therapistRef: TherapistRef = THE_THERAPIST_REF, + calendarId: String = events.firstOrNull()?.id?.calendarId ?: aCalendarName(), shouldBeShown: Boolean = false, accessToken: String = "accessToken" ): GoogleAccount { + check(events.map { it.id.calendarId }.all { it == calendarId }) { "events should have the same calendarId" } val refreshToken = "refreshToken" mockGoogleOAuthServer.OnRefreshToken(refreshToken).returnsToken(accessToken) mockGoogleCalendar.OnGetCalendars(accessToken).returnsCalendars( @@ -41,6 +45,11 @@ class GoogleCalendarFixturePresets( refreshToken ) ) + + events.forEach { + mockGoogleCalendar.OnGetEventById(accessToken, it.id).returnsEvent(it) + } + googleCalendarsTestApi.setShouldBeShown(therapistRef, account.ref(), calendarId, shouldBeShown) return account } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/Presets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/PresetsConf.kt similarity index 95% rename from app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/Presets.kt rename to app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/PresetsConf.kt index 972e8e99..f4a92cc7 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/Presets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/PresetsConf.kt @@ -8,7 +8,7 @@ import org.springframework.context.annotation.Configuration @TestConfiguration @ComponentScan @Configuration -class Presets( +class PresetsConf( val surveysFixturePresets: SurveysFixturePresets, val therapistsFixturePreset: TherapistsFixturePreset, val clientsFixturePresets: ClientsFixturePresets, diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index 412e6887..f965bc48 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component import org.springframework.web.util.UriUtils import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItem +import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone import java.time.LocalDateTime import java.time.ZonedDateTime @@ -123,6 +124,30 @@ class MockGoogleCalendar( } + inner class OnGetEventById( + private val accessToken: String, + private val id: GoogleCalendarItemId + ) { + + fun returnsEvent(event: GoogleCalendarItem<*>) { + val encodedCalendarId = UriUtils.encodePathSegment(id.calendarId, UTF_8) + val encodedEventId = UriUtils.encodePathSegment(id.itemId, UTF_8) + wiremockServer.stubFor( + get( + urlPathEqualTo("/google/calendar/v3/calendars/$encodedCalendarId/events/$encodedEventId") + ) + .withHeader("Authorization", equalTo("Bearer $accessToken")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", "application/json") + .withBody(event.toJson()) + ) + ) + } + + } + } private fun GoogleCalendar.toJson(): String = @@ -140,7 +165,7 @@ private fun GoogleCalendarItem<*>.toJson(): String { .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) return """ { - "id": "$id", + "id": "${id.itemId}", "summary": "$title", "description": "$description", "start": { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt index f6665dfd..4a25b877 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/test_config/spring/TestsConfig.kt @@ -11,7 +11,7 @@ import pro.qyoga.app.QYogaApp import pro.qyoga.infra.db.SdjConfig import pro.qyoga.tests.fixture.FailingController import pro.qyoga.tests.fixture.backgrounds.BackgroundsConfig -import pro.qyoga.tests.fixture.presets.Presets +import pro.qyoga.tests.fixture.presets.PresetsConf import pro.qyoga.tests.fixture.test_apis.TestApisConf import pro.qyoga.tests.fixture.wiremocks.MockServersConf import pro.qyoga.tests.infra.test_config.spring.auth.TestPasswordEncoderConfig @@ -41,7 +41,7 @@ val sdjContext by lazy { QYogaApp::class, BackgroundsConfig::class, TestApisConf::class, - Presets::class, + PresetsConf::class, TestPasswordEncoderConfig::class, TestDataSourceConfig::class, TestMinioConfig::class, diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt index 3ef1ab22..c7c59570 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseKoTest.kt @@ -3,7 +3,7 @@ package pro.qyoga.tests.infra.web import io.kotest.core.spec.style.FreeSpec import pro.qyoga.tests.fixture.backgrounds.Backgrounds import pro.qyoga.tests.fixture.data.resetFaker -import pro.qyoga.tests.fixture.presets.Presets +import pro.qyoga.tests.fixture.presets.PresetsConf import pro.qyoga.tests.infra.db.setupDb import pro.qyoga.tests.infra.test_config.spring.context import pro.qyoga.tests.infra.wiremock.WireMock @@ -16,7 +16,7 @@ abstract class QYogaAppBaseKoTest(body: QYogaAppBaseKoTest.() -> Unit = {}) : Fr val backgrounds: Backgrounds = context.getBean(Backgrounds::class.java) - val presets: Presets = context.getBean(Presets::class.java) + val presets: PresetsConf = context.getBean(PresetsConf::class.java) inline fun getBean(): T = context.getBean(T::class.java) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseTest.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseTest.kt index 49b6c755..2fa36d03 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseTest.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/infra/web/QYogaAppBaseTest.kt @@ -4,7 +4,7 @@ import org.junit.jupiter.api.BeforeEach import org.springframework.boot.autoconfigure.web.ServerProperties import pro.qyoga.tests.fixture.backgrounds.Backgrounds import pro.qyoga.tests.fixture.data.resetFaker -import pro.qyoga.tests.fixture.presets.Presets +import pro.qyoga.tests.fixture.presets.PresetsConf import pro.qyoga.tests.infra.db.setupDb import pro.qyoga.tests.infra.test_config.spring.context import pro.qyoga.tests.infra.wiremock.WireMock @@ -19,7 +19,7 @@ open class QYogaAppBaseTest { protected val backgrounds: Backgrounds = context.getBean(Backgrounds::class.java) - protected val presets: Presets = context.getBean(Presets::class.java) + protected val presets: PresetsConf = context.getBean(PresetsConf::class.java) inline fun getBean(): T = context.getBean(T::class.java) From ab69bb1e172692e885b4a42b1938b29d12ef7b4e Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Sun, 21 Sep 2025 18:20:48 +0700 Subject: [PATCH 21/43] =?UTF-8?q?feat/qg-253:=20WIP:=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=91=D0=BC=D0=B0=20=D1=81=D0=BE=D0=B7=D0=B0=D0=B4=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D1=83=20=D0=B8=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D1=83=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=91=D0=BC=D1=8B=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B7=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edit/CreateAppointmentPageController.kt | 2 +- .../core/edit/forms/CreateAppointmentForm.kt | 19 +++++++++++++------ .../edit/ops/GetAppointmentPrefillDataOp.kt | 8 +++----- .../core/edit/view_model/SourceItem.kt | 5 ++++- .../core/schedule/CalendarPageModel.kt | 14 ++++---------- .../qyoga/core/calendar/api/CalendarItem.kt | 4 +++- .../calendar/google/GoogleCalendarItem.kt | 2 ++ .../core/calendar/ical/model/ICalEventId.kt | 4 +++- .../core/CreateAppointmentPageTest.kt | 3 +-- .../therapist/appointments/SchedulePage.kt | 2 +- 10 files changed, 35 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt index 740b6857..50134a4a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt @@ -90,4 +90,4 @@ class CreateAppointmentPageController( .replace("{$SOURCE_ITEM_ID}", sourceItem.id) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt index 03850a6a..83c17730 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt @@ -32,18 +32,18 @@ data class CreateAppointmentForm( ) { constructor( - iCalEvent: CalendarItem?, + externalEvent: CalendarItem?, dateTime: LocalDateTime?, timeZone: ZoneId, timeZoneTitle: String? ) : this( - externalId = iCalEvent?.id?.toQueryParamStr(), + externalId = externalEvent?.id?.toQueryParamStr(), dateTime = dateTime, timeZone = timeZone, timeZoneTitle = timeZoneTitle, - duration = iCalEvent?.duration, - comment = iCalEvent?.description, - place = iCalEvent?.location, + duration = externalEvent?.duration, + comment = formatCommentFor(externalEvent), + place = externalEvent?.location, client = null, clientTitle = null, appointmentType = null, @@ -55,4 +55,11 @@ data class CreateAppointmentForm( appointmentStatus = null, ) -} \ No newline at end of file +} + +private fun formatCommentFor(externalEvent: CalendarItem?): String = + listOfNotNull( + externalEvent?.title?.takeIf { !it.isBlank() }, + externalEvent?.description?.takeIf { !it.isBlank() } + ) + .joinToString("\n\n") diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index 9f47f004..fbca6f63 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -42,11 +42,9 @@ class GetAppointmentPrefillDataOp( null } - val timeZone = sourceEvent?.dateTime?.zone - ?: currentUserTimeZone - val timeZoneTitle = timeZones.findById(timeZone)?.displayName + val timeZoneTitle = timeZones.findById(currentUserTimeZone)?.displayName - return CreateAppointmentForm(sourceEvent, dateTime, timeZone, timeZoneTitle) + return CreateAppointmentForm(sourceEvent, dateTime, currentUserTimeZone, timeZoneTitle) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index 4c2f7947..229b2b2a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -1,5 +1,6 @@ package pro.qyoga.app.therapist.appointments.core.edit.view_model +import pro.qyoga.core.calendar.api.CalendarItemId import pro.qyoga.core.calendar.google.GoogleCalendar import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.calendar.ical.model.ICalCalendar @@ -10,6 +11,8 @@ data class SourceItem( val id: String ) { + constructor(eventId: CalendarItemId) : this(eventId.type, eventId.toQueryParamStr()) + companion object { fun icsEvent(eventId: ICalEventId): SourceItem = SourceItem(ICalCalendar.TYPE, eventId.toQueryParamStr()) @@ -35,4 +38,4 @@ fun SourceItem.googleEventId(): GoogleCalendarItemId { val matcher = "(.+),(.+)".toRegex().matchEntire(id) check(matcher != null) return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index 5da3f1ed..b8c6beff 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -17,8 +17,7 @@ import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Comp import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem -import pro.qyoga.core.calendar.google.GoogleCalendarItemId -import pro.qyoga.core.calendar.ical.model.ICalEventId +import pro.qyoga.core.calendar.api.CalendarItemId import pro.qyoga.l10n.russianDayOfMonthLongFormat import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.l10n.systemLocale @@ -266,14 +265,9 @@ data class CalendarDay( private fun CalendarItem<*, LocalDateTime>.editUri() = when (id) { is UUID -> EditAppointmentPageController.editUri(id as UUID) - is ICalEventId -> CreateAppointmentPageController.addFromSourceItemUri( + is CalendarItemId -> CreateAppointmentPageController.addFromSourceItemUri( dateTime, - SourceItem.icsEvent(id as ICalEventId) + SourceItem(id as CalendarItemId) ) - is GoogleCalendarItemId -> CreateAppointmentPageController.addFromSourceItemUri( - dateTime, - SourceItem.googleEvent(id as GoogleCalendarItemId) - ) - else -> error("Unsupported type: $id") - } \ No newline at end of file + } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt index 7b9ccb61..1f236fd1 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt @@ -5,6 +5,8 @@ import java.time.temporal.Temporal interface CalendarItemId { + val type: String + fun toQueryParamStr(): String } @@ -20,4 +22,4 @@ interface CalendarItem { val endDateTime: DATE get() = this.dateTime.plus(this.duration) as DATE val location: String? -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt index 4e2892bd..18a34a68 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt @@ -12,6 +12,8 @@ data class GoogleCalendarItemId( val itemId: String ) : CalendarItemId { + override val type: String = GoogleCalendar.TYPE + override fun toQueryParamStr(): String = "$calendarId,$itemId" diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt index 33c8272e..9d0214b0 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt @@ -8,8 +8,10 @@ data class ICalEventId( val recurrenceId: String? = null ) : CalendarItemId { + override val type: String = ICalCalendar.TYPE + override fun toQueryParamStr(): String = "uid=${uid},rid=${recurrenceId ?: ""}" -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 7650953d..7a9f9586 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -186,7 +186,6 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { // Действие val document = theTherapist.appointments.getCreateAppointmentPage( - dateTime = event.dateTime, sourceItem = SourceItem.googleEvent(event.id) ) @@ -194,4 +193,4 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toQueryParamStr() } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index 889b8b5e..95c1a280 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -104,4 +104,4 @@ infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { ) this.select("div.appointment-card") .single() shouldHaveClass AppointmentCard.CssClasses.DRAFT_CARD -} \ No newline at end of file +} From 50ef256652d7d8b12752c0a0883f502fa70806f4 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Mon, 22 Sep 2025 09:48:01 +0700 Subject: [PATCH 22/43] =?UTF-8?q?refactor/qg-253:=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20Google=20?= =?UTF-8?q?Calendars=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=20i9ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt | 2 +- .../core/edit/ops/GetAppointmentPrefillDataOp.kt | 4 ++-- .../appointments/core/edit/view_model/SourceItem.kt | 4 ++-- .../core/schedule/GetCalendarAppointments.kt | 2 +- .../core/schedule/GoogleCalendarSettingsController.kt | 8 ++++---- .../app/therapist/oauth2/GoogleCallbackController.kt | 6 +++--- .../calendars}/google/GoogleAccount.kt | 4 ++-- .../calendars}/google/GoogleAccountsDao.kt | 2 +- .../calendars}/google/GoogleCalendar.kt | 4 ++-- .../calendars}/google/GoogleCalendarConf.kt | 4 ++-- .../calendars}/google/GoogleCalendarItem.kt | 2 +- .../calendars}/google/GoogleCalendarsClient.kt | 2 +- .../calendars}/google/GoogleCalendarsDao.kt | 4 ++-- .../calendars}/google/GoogleCalendarsRepo.kt | 4 ++-- .../calendars}/google/GoogleCalendarsService.kt | 2 +- .../appointments/google-settings-component.html | 6 +++--- .../appointments/core/SchedulePageControllerTest.kt | 6 +++--- .../google/GetGoogleCalendarsSettingsEndpointTest.kt | 6 +++--- .../google/GoogleAuthorizationIntegrationTest.kt | 8 ++++---- .../calendars/google/SetCalendarShouldBeShownTest.kt | 4 ++-- .../i9ns/calendar/google/GoogleCalendarsServiceTest.kt | 4 ++-- .../api/TherapistGoogleCalendarIntegrationApi.kt | 4 ++-- .../calendars/google/GoogleCalendarObjectMother.kt | 4 ++-- .../fixture/presets/GoogleCalendarFixturePresets.kt | 6 +++--- .../tests/fixture/presets/ScheduleFixturePreset.kt | 10 +++++----- .../tests/fixture/test_apis/GoogleCalendarTestApi.kt | 10 +++++----- .../tests/fixture/wiremocks/MockGoogleCalendar.kt | 8 ++++---- .../appointments/GoogleCalendarSettingsComponent.kt | 4 ++-- 28 files changed, 67 insertions(+), 67 deletions(-) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleAccount.kt (95%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleAccountsDao.kt (97%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendar.kt (89%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarConf.kt (97%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarItem.kt (96%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarsClient.kt (99%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarsDao.kt (97%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarsRepo.kt (91%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/google/GoogleCalendarsService.kt (99%) diff --git a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt index cebc9eec..0d45ff2a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt @@ -8,12 +8,12 @@ import pro.azhidkov.platform.spring.sdj.ErgoSdjConfig import pro.qyoga.app.publc.PublicAppConfig import pro.qyoga.app.therapist.TherapistWebAppConfig import pro.qyoga.core.appointments.AppointmentsConfig -import pro.qyoga.core.calendar.google.GoogleCalendarConf import pro.qyoga.core.calendar.ical.ICalCalendarsConfig import pro.qyoga.core.clients.ClientsConfig import pro.qyoga.core.survey_forms.SurveyFormsSettingsConfig import pro.qyoga.core.therapy.TherapyConfig import pro.qyoga.core.users.UsersConfig +import pro.qyoga.i9ns.calendars.google.GoogleCalendarConf import pro.qyoga.i9ns.email.EmailsConfig import pro.qyoga.infra.auth.AuthConfig import pro.qyoga.infra.cache.CacheConf diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index fbca6f63..ee58cac3 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -6,13 +6,13 @@ import pro.qyoga.app.therapist.appointments.core.edit.forms.CreateAppointmentFor import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.edit.view_model.googleEventId import pro.qyoga.app.therapist.appointments.core.edit.view_model.icsEventId -import pro.qyoga.core.calendar.google.GoogleCalendar -import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.calendar.ical.ICalCalendarsRepo import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import java.time.LocalDateTime diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index 229b2b2a..a8febfa6 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -1,10 +1,10 @@ package pro.qyoga.app.therapist.appointments.core.edit.view_model import pro.qyoga.core.calendar.api.CalendarItemId -import pro.qyoga.core.calendar.google.GoogleCalendar -import pro.qyoga.core.calendar.google.GoogleCalendarItemId import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.calendar.ical.model.ICalEventId +import pro.qyoga.i9ns.calendars.google.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItemId data class SourceItem( val type: String, diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index 34513e37..ccb99af4 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -6,11 +6,11 @@ import pro.azhidkov.platform.kotlin.tryExecute import pro.qyoga.core.appointments.core.AppointmentsRepo import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.SearchResult -import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.calendar.ical.ICalCalendarsRepo import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import java.time.* diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt index 3064793d..29b106dc 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -5,11 +5,11 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.ModelAndView -import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView -import pro.qyoga.core.calendar.google.GoogleAccountRef -import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref +import pro.qyoga.i9ns.calendars.google.GoogleAccountCalendarsView +import pro.qyoga.i9ns.calendars.google.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService data class GoogleCalendarSettingsPageModel( val accounts: List @@ -60,4 +60,4 @@ class GoogleCalendarSettingsController( } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index bf78527e..b2da203f 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -7,11 +7,11 @@ import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2Aut import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.client.RestClient -import pro.qyoga.core.calendar.google.GoogleAccount -import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.Therapist import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleAccount +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import java.util.* /** @@ -60,4 +60,4 @@ class GoogleOAuthController( const val PATH = "/therapist/oauth2/google/callback" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt similarity index 95% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt index 6456a665..b73467a9 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import org.springframework.data.annotation.Id import org.springframework.data.jdbc.core.mapping.AggregateReference @@ -29,4 +29,4 @@ data class GoogleAccount( refreshToken: String ) : this(ownerRef, email, SecretChars(refreshToken.toCharArray())) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt similarity index 97% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt index bec6a6ac..302428fc 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleAccountsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import org.springframework.data.jdbc.core.JdbcAggregateTemplate import org.springframework.data.jdbc.core.findAllById diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt similarity index 89% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt index 71164d0a..c0e3cc2e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import pro.qyoga.core.calendar.api.Calendar import pro.qyoga.core.users.therapists.TherapistRef @@ -17,4 +17,4 @@ data class GoogleCalendar( const val TYPE = "Google" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarConf.kt similarity index 97% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarConf.kt index aee85e45..0a239bb9 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarConf.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarConf.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import com.github.benmanes.caffeine.cache.Caffeine import org.springframework.cache.CacheManager @@ -46,4 +46,4 @@ class GoogleCalendarConf { } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt similarity index 96% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt index 18a34a68..6cc14c72 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import pro.azhidkov.platform.java.time.toLocalDateTime import pro.qyoga.core.calendar.api.CalendarItem diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt similarity index 99% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt index 02f10695..d650303c 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import com.google.api.client.googleapis.json.GoogleJsonResponseException import com.google.api.client.util.DateTime diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt similarity index 97% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt index c6e4766c..040a402c 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.stereotype.Repository @@ -55,4 +55,4 @@ class GoogleCalendarsDao( .associateBy(GoogleCalendarSettings::calendarId) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt similarity index 91% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt index b8185c5b..972834ed 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import pro.qyoga.core.users.therapists.TherapistRef @@ -15,4 +15,4 @@ class GoogleCalendarsRepo { return repo[therapist] ?: emptyList() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt similarity index 99% rename from app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt index 2b97359d..b7814c0e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.google +package pro.qyoga.i9ns.calendars.google import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index a4941885..d5a66c38 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -10,7 +10,7 @@
Google Calendar
email@example.com
-
+
  • @@ -35,7 +35,7 @@
    Google Calendar
\ No newline at end of file +
diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt index e4796ba5..eadce83c 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageControllerTest.kt @@ -3,8 +3,8 @@ package pro.qyoga.tests.cases.app.therapist.appointments.core import io.kotest.core.annotation.DisplayName import io.kotest.matchers.collections.shouldHaveSize import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController -import pro.qyoga.core.calendar.google.GoogleCalendarItem -import pro.qyoga.core.calendar.google.GoogleCalendarItemId +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItem +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItemId import pro.qyoga.tests.assertions.shouldMatch import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomWorkingTime @@ -92,4 +92,4 @@ class SchedulePageControllerTest : QYogaAppIntegrationBaseKoTest({ calendarPageModel.appointmentCards() shouldHaveSize 1 } -}) \ No newline at end of file +}) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt index 7b149c17..af342479 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GetGoogleCalendarsSettingsEndpointTest.kt @@ -1,8 +1,8 @@ package pro.qyoga.tests.cases.app.therapist.calendars.google import io.kotest.core.annotation.DisplayName -import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView -import pro.qyoga.core.calendar.google.GoogleAccountContentView +import pro.qyoga.i9ns.calendars.google.GoogleAccountCalendarsView +import pro.qyoga.i9ns.calendars.google.GoogleAccountContentView import pro.qyoga.tests.assertions.shouldHaveComponent import pro.qyoga.tests.clients.TherapistClient import pro.qyoga.tests.fixture.data.faker @@ -57,4 +57,4 @@ class GetGoogleCalendarsSettingsEndpointTest : QYogaAppIntegrationBaseKoTest({ } } -}) \ No newline at end of file +}) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt index b0d1c1d8..a2b38201 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/GoogleAuthorizationIntegrationTest.kt @@ -5,9 +5,9 @@ import io.kotest.matchers.shouldBe import org.springframework.core.env.get import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.oauth2.GoogleOAuthController -import pro.qyoga.core.calendar.google.GoogleAccountContentView -import pro.qyoga.core.calendar.google.GoogleCalendar -import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.i9ns.calendars.google.GoogleAccountContentView +import pro.qyoga.i9ns.calendars.google.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import pro.qyoga.tests.assertions.shouldBeRedirectToGoogleOAuth import pro.qyoga.tests.clients.TherapistClient import pro.qyoga.tests.fixture.data.faker @@ -100,4 +100,4 @@ class GoogleAuthorizationIntegrationTest : QYogaAppIntegrationBaseKoTest({ } } -}) \ No newline at end of file +}) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt index 4d262146..6b7748f2 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/calendars/google/SetCalendarShouldBeShownTest.kt @@ -3,7 +3,7 @@ package pro.qyoga.tests.cases.app.therapist.calendars.google import io.kotest.core.annotation.DisplayName import io.kotest.matchers.shouldBe import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref -import pro.qyoga.core.calendar.google.GoogleAccountContentView +import pro.qyoga.i9ns.calendars.google.GoogleAccountContentView import pro.qyoga.tests.clients.TherapistClient.Companion.loginAsTheTherapist import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.presets.GoogleCalendarFixturePresets @@ -37,4 +37,4 @@ class SetCalendarShouldBeShownTest : QYogaAppIntegrationBaseKoTest({ (settings.single().content as GoogleAccountContentView.Calendars).calendars.single { it.id == calendarId }.shouldBeShown shouldBe true } -}) \ No newline at end of file +}) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt index 75debaec..10819c95 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/i9ns/calendar/google/GoogleCalendarsServiceTest.kt @@ -4,7 +4,7 @@ import io.kotest.core.annotation.DisplayName import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import pro.azhidkov.platform.java.time.Interval -import pro.qyoga.core.calendar.google.GoogleCalendarsService +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendarItem import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF @@ -48,4 +48,4 @@ class GoogleCalendarsServiceTest : QYogaAppIntegrationBaseKoTest({ } } -}) \ No newline at end of file +}) diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt index 2e3e36c6..5579f617 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistGoogleCalendarIntegrationApi.kt @@ -7,7 +7,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.function.BodyInserters.fromValue import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController -import pro.qyoga.core.calendar.google.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.GoogleAccountRef import pro.qyoga.tests.platform.spring.web_test_client.getBodyAsString import pro.qyoga.tests.platform.spring.web_test_client.redirectLocation import java.net.URI @@ -75,4 +75,4 @@ class TherapistGoogleCalendarIntegrationApi( .expectStatus().isNoContent } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt index 68e2dfff..5cc0cfac 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/google/GoogleCalendarObjectMother.kt @@ -1,7 +1,7 @@ package pro.qyoga.tests.fixture.object_mothers.calendars.google -import pro.qyoga.core.calendar.google.* import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.* import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomElementOf import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDuration @@ -89,4 +89,4 @@ val googleCalendarIds = listOf( "yoga.sessions@studio.com", "therapy.appointments@clinic.com", "group.classes@fitness.com" -) \ No newline at end of file +) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt index 28275ffc..3a417927 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/GoogleCalendarFixturePresets.kt @@ -3,9 +3,9 @@ package pro.qyoga.tests.fixture.presets import org.springframework.context.ApplicationContext import org.springframework.stereotype.Component import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref -import pro.qyoga.core.calendar.google.GoogleAccount -import pro.qyoga.core.calendar.google.GoogleCalendarItem import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleAccount +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItem import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aCalendarName import pro.qyoga.tests.fixture.object_mothers.calendars.google.GoogleCalendarObjectMother.aGoogleCalendar @@ -60,4 +60,4 @@ fun ApplicationContext.googleCalendarFixturePresets() = GoogleCalendarFixturePre MockGoogleOAuthServer(WireMock.wiremock), MockGoogleCalendar(WireMock.wiremock), getBean() -) \ No newline at end of file +) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt index d49616d9..52abcef5 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ScheduleFixturePreset.kt @@ -3,14 +3,14 @@ package pro.qyoga.tests.fixture.presets import org.springframework.stereotype.Component import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest -import pro.qyoga.core.calendar.google.GoogleAccount -import pro.qyoga.core.calendar.google.GoogleAccountId -import pro.qyoga.core.calendar.google.GoogleAccountRef -import pro.qyoga.core.calendar.google.GoogleCalendarSettings import pro.qyoga.core.clients.cards.Client import pro.qyoga.core.clients.cards.dtos.ClientCardDto import pro.qyoga.core.clients.cards.model.ClientId import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleAccount +import pro.qyoga.i9ns.calendars.google.GoogleAccountId +import pro.qyoga.i9ns.calendars.google.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendarSettings import pro.qyoga.tests.fixture.backgrounds.AppointmentsBackgrounds import pro.qyoga.tests.fixture.backgrounds.ClientsBackgrounds import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMother @@ -103,4 +103,4 @@ class ScheduleFixturePreset( } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt index 14b4bb23..031795c6 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/GoogleCalendarTestApi.kt @@ -2,11 +2,11 @@ package pro.qyoga.tests.fixture.test_apis import org.springframework.stereotype.Component import pro.qyoga.app.therapist.appointments.core.schedule.GoogleCalendarSettingsController -import pro.qyoga.core.calendar.google.GoogleAccount -import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView -import pro.qyoga.core.calendar.google.GoogleAccountRef -import pro.qyoga.core.calendar.google.GoogleCalendarsService import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleAccount +import pro.qyoga.i9ns.calendars.google.GoogleAccountCalendarsView +import pro.qyoga.i9ns.calendars.google.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService import pro.qyoga.tests.fixture.object_mothers.therapists.idOnlyUserDetails @@ -41,4 +41,4 @@ class GoogleCalendarTestApi( ) } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt index f965bc48..2f96e8f8 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/wiremocks/MockGoogleCalendar.kt @@ -6,9 +6,9 @@ import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.util.UriUtils -import pro.qyoga.core.calendar.google.GoogleCalendar -import pro.qyoga.core.calendar.google.GoogleCalendarItem -import pro.qyoga.core.calendar.google.GoogleCalendarItemId +import pro.qyoga.i9ns.calendars.google.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItem +import pro.qyoga.i9ns.calendars.google.GoogleCalendarItemId import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone import java.time.LocalDateTime import java.time.ZonedDateTime @@ -178,4 +178,4 @@ private fun GoogleCalendarItem<*>.toJson(): String { } } """.trimIndent() -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt index c369200a..bdb668d3 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/GoogleCalendarSettingsComponent.kt @@ -2,7 +2,7 @@ package pro.qyoga.tests.pages.therapist.appointments import io.kotest.matchers.Matcher import org.jsoup.nodes.Element -import pro.qyoga.core.calendar.google.GoogleAccountCalendarsView +import pro.qyoga.i9ns.calendars.google.GoogleAccountCalendarsView import pro.qyoga.tests.assertions.haveComponent import pro.qyoga.tests.platform.html.Component import pro.qyoga.tests.platform.html.Link @@ -39,4 +39,4 @@ class GoogleCalendarSettingsComponent( } -} \ No newline at end of file +} From 9a0844e891cbb1ce6bdafd9a667cf0861a916c1c Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Mon, 22 Sep 2025 09:51:36 +0700 Subject: [PATCH 23/43] =?UTF-8?q?refactor/qg-253:=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20ical-?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=20i9ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt | 2 +- .../core/edit/ops/GetAppointmentPrefillDataOp.kt | 4 ++-- .../core/edit/view_model/SourceItem.kt | 4 ++-- .../core/schedule/GetCalendarAppointments.kt | 2 +- .../ical/platform/ical4j/ICalIntegration.kt | 4 ---- .../calendars}/ical/ICalCalendarsConfig.kt | 4 ++-- .../calendars}/ical/ICalCalendarsRepo.kt | 14 +++++++------- .../{core/calendar => i9ns/calendars}/ical/Sync.kt | 8 ++++---- .../calendars}/ical/commands/CreateICalRq.kt | 6 +++--- .../calendars/ical}/ical4j/CalendarExt.kt | 4 ++-- .../i9ns/calendars/ical/ical4j/ICalIntegration.kt | 4 ++++ .../calendars/ical}/ical4j/PeriodExt.kt | 4 ++-- .../calendars/ical}/ical4j/VEventExt.kt | 8 ++++---- .../calendars}/ical/model/ICalCalendar.kt | 12 ++++++------ .../calendars}/ical/model/ICalCalendarItem.kt | 4 ++-- .../calendars}/ical/model/ICalEventId.kt | 2 +- .../ical/model/LocalizedICalCalendarItem.kt | 4 ++-- .../ical/persistance/ICalCalendarsDao.kt | 6 +++--- .../appointments/core/CreateAppointmentPageTest.kt | 2 +- .../appointments/core/SchedulePageTest.kt | 4 ++-- .../cases/core/calendar/ical/ICalCalendarTest.kt | 10 +++++----- .../core/calendar/ical/ICalCalendarsRepoTest.kt | 6 +++--- .../backgrounds/ICalCalendarsBackgrounds.kt | 8 ++++---- .../calendars/CalendarsObjectMother.kt | 4 ++-- .../calendars/ical/ICalCalendarsObjectMother.kt | 6 +++--- .../presets/ICalsCalendarsFixturePresets.kt | 4 ++-- .../pages/therapist/appointments/SchedulePage.kt | 2 +- 27 files changed, 71 insertions(+), 71 deletions(-) delete mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/ICalCalendarsConfig.kt (78%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/ICalCalendarsRepo.kt (83%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/Sync.kt (81%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/commands/CreateICalRq.kt (80%) rename app/src/main/kotlin/pro/qyoga/{core/calendar/ical/platform => i9ns/calendars/ical}/ical4j/CalendarExt.kt (92%) create mode 100644 app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/ICalIntegration.kt rename app/src/main/kotlin/pro/qyoga/{core/calendar/ical/platform => i9ns/calendars/ical}/ical4j/PeriodExt.kt (70%) rename app/src/main/kotlin/pro/qyoga/{core/calendar/ical/platform => i9ns/calendars/ical}/ical4j/VEventExt.kt (92%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/model/ICalCalendar.kt (83%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/model/ICalCalendarItem.kt (92%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/model/ICalEventId.kt (87%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/model/LocalizedICalCalendarItem.kt (80%) rename app/src/main/kotlin/pro/qyoga/{core/calendar => i9ns/calendars}/ical/persistance/ICalCalendarsDao.kt (91%) diff --git a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt index 0d45ff2a..cc53590a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt @@ -8,12 +8,12 @@ import pro.azhidkov.platform.spring.sdj.ErgoSdjConfig import pro.qyoga.app.publc.PublicAppConfig import pro.qyoga.app.therapist.TherapistWebAppConfig import pro.qyoga.core.appointments.AppointmentsConfig -import pro.qyoga.core.calendar.ical.ICalCalendarsConfig import pro.qyoga.core.clients.ClientsConfig import pro.qyoga.core.survey_forms.SurveyFormsSettingsConfig import pro.qyoga.core.therapy.TherapyConfig import pro.qyoga.core.users.UsersConfig import pro.qyoga.i9ns.calendars.google.GoogleCalendarConf +import pro.qyoga.i9ns.calendars.ical.ICalCalendarsConfig import pro.qyoga.i9ns.email.EmailsConfig import pro.qyoga.infra.auth.AuthConfig import pro.qyoga.infra.cache.CacheConf diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index ee58cac3..7c9c839d 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -6,13 +6,13 @@ import pro.qyoga.app.therapist.appointments.core.edit.forms.CreateAppointmentFor import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.edit.view_model.googleEventId import pro.qyoga.app.therapist.appointments.core.edit.view_model.icsEventId -import pro.qyoga.core.calendar.ical.ICalCalendarsRepo -import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.i9ns.calendars.google.GoogleCalendar import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService +import pro.qyoga.i9ns.calendars.ical.ICalCalendarsRepo +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar import java.time.LocalDateTime diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index a8febfa6..334b4140 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -1,10 +1,10 @@ package pro.qyoga.app.therapist.appointments.core.edit.view_model import pro.qyoga.core.calendar.api.CalendarItemId -import pro.qyoga.core.calendar.ical.model.ICalCalendar -import pro.qyoga.core.calendar.ical.model.ICalEventId import pro.qyoga.i9ns.calendars.google.GoogleCalendar import pro.qyoga.i9ns.calendars.google.GoogleCalendarItemId +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.model.ICalEventId data class SourceItem( val type: String, diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index ccb99af4..e328c520 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -6,11 +6,11 @@ import pro.azhidkov.platform.kotlin.tryExecute import pro.qyoga.core.appointments.core.AppointmentsRepo import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.SearchResult -import pro.qyoga.core.calendar.ical.ICalCalendarsRepo import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService +import pro.qyoga.i9ns.calendars.ical.ICalCalendarsRepo import java.time.* diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt deleted file mode 100644 index 7d363e06..00000000 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/ICalIntegration.kt +++ /dev/null @@ -1,4 +0,0 @@ -package pro.qyoga.core.calendar.ical.platform.ical4j - - -object ICalIntegration \ No newline at end of file diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsConfig.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsConfig.kt similarity index 78% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsConfig.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsConfig.kt index 76e25de8..a9da21af 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsConfig.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical +package pro.qyoga.i9ns.calendars.ical import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration @@ -8,4 +8,4 @@ import org.springframework.scheduling.annotation.EnableScheduling @ComponentScan @Configuration @EnableScheduling -class ICalCalendarsConfig \ No newline at end of file +class ICalCalendarsConfig diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt similarity index 83% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt index 22db332c..a29d7126 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical +package pro.qyoga.i9ns.calendars.ical import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled @@ -7,13 +7,13 @@ import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.calendar.api.SearchResult -import pro.qyoga.core.calendar.ical.commands.CreateICalRq -import pro.qyoga.core.calendar.ical.commands.createFrom -import pro.qyoga.core.calendar.ical.model.* -import pro.qyoga.core.calendar.ical.persistance.ICalCalendarsDao -import pro.qyoga.core.calendar.ical.persistance.findAllByOwner -import pro.qyoga.core.calendar.ical.platform.ical4j.toICalCalendarItem import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.ical.commands.CreateICalRq +import pro.qyoga.i9ns.calendars.ical.commands.createFrom +import pro.qyoga.i9ns.calendars.ical.ical4j.toICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.* +import pro.qyoga.i9ns.calendars.ical.persistance.ICalCalendarsDao +import pro.qyoga.i9ns.calendars.ical.persistance.findAllByOwner import java.time.ZonedDateTime diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/Sync.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/Sync.kt similarity index 81% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/Sync.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/Sync.kt index a29862e1..ecf40093 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/Sync.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/Sync.kt @@ -1,8 +1,8 @@ -package pro.qyoga.core.calendar.ical +package pro.qyoga.i9ns.calendars.ical import org.slf4j.LoggerFactory -import pro.qyoga.core.calendar.ical.model.ICalCalendar -import pro.qyoga.core.calendar.ical.persistance.ICalCalendarsDao +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.persistance.ICalCalendarsDao import java.io.IOException @@ -29,4 +29,4 @@ object Sync { } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/commands/CreateICalRq.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/commands/CreateICalRq.kt similarity index 80% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/commands/CreateICalRq.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/commands/CreateICalRq.kt index de36acf5..790a184b 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/commands/CreateICalRq.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/commands/CreateICalRq.kt @@ -1,7 +1,7 @@ -package pro.qyoga.core.calendar.ical.commands +package pro.qyoga.i9ns.calendars.ical.commands -import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar import java.net.URL data class CreateICalRq( @@ -21,4 +21,4 @@ fun ICalCalendar.Companion.createFrom( icsData ) return ical -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/CalendarExt.kt similarity index 92% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/CalendarExt.kt index e13a7665..20349d47 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/CalendarExt.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/CalendarExt.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.platform.ical4j +package pro.qyoga.i9ns.calendars.ical.ical4j import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.ParserException @@ -19,4 +19,4 @@ fun tryParseIcs(icsData: String): Calendar? { log.error("ics-file parsing failed", e) null } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/ICalIntegration.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/ICalIntegration.kt new file mode 100644 index 00000000..0fc4796e --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/ICalIntegration.kt @@ -0,0 +1,4 @@ +package pro.qyoga.i9ns.calendars.ical.ical4j + + +object ICalIntegration diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/PeriodExt.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/PeriodExt.kt similarity index 70% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/PeriodExt.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/PeriodExt.kt index bf829098..b9737d3e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/PeriodExt.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/PeriodExt.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.platform.ical4j +package pro.qyoga.i9ns.calendars.ical.ical4j import net.fortuna.ical4j.model.Period import pro.azhidkov.platform.java.time.Interval @@ -6,4 +6,4 @@ import java.time.temporal.Temporal fun Interval.toICalPeriod() = - Period(from, to) \ No newline at end of file + Period(from, to) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/VEventExt.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt similarity index 92% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/VEventExt.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt index 2b880d01..980a9b76 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/platform/ical4j/VEventExt.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.platform.ical4j +package pro.qyoga.i9ns.calendars.ical.ical4j import net.fortuna.ical4j.model.Period import net.fortuna.ical4j.model.component.VEvent @@ -6,8 +6,8 @@ import net.fortuna.ical4j.model.parameter.TzId import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RecurrenceId import pro.azhidkov.platform.java.time.toLocalDateTime -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem -import pro.qyoga.core.calendar.ical.model.ICalEventId +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalEventId import java.time.Duration import java.time.ZoneId import java.time.ZonedDateTime @@ -65,4 +65,4 @@ fun VEvent.toICalCalendarItem(period: Period): ICalCalendarItem = period.start, period.duration as Duration, listOfNotNull(geographicsPosOrNull, locationOrNull).joinToString(", ") -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt similarity index 83% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt index 71c09d47..7e83b0fe 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.model +package pro.qyoga.i9ns.calendars.ical.model import net.fortuna.ical4j.model.component.VEvent import org.springframework.data.annotation.* @@ -6,11 +6,11 @@ import org.springframework.data.relational.core.mapping.Table import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.calendar.api.Calendar -import pro.qyoga.core.calendar.ical.platform.ical4j.recurrenceId -import pro.qyoga.core.calendar.ical.platform.ical4j.toICalCalendarItem -import pro.qyoga.core.calendar.ical.platform.ical4j.toICalPeriod -import pro.qyoga.core.calendar.ical.platform.ical4j.tryParseIcs import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.ical.ical4j.recurrenceId +import pro.qyoga.i9ns.calendars.ical.ical4j.toICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.ical4j.toICalPeriod +import pro.qyoga.i9ns.calendars.ical.ical4j.tryParseIcs import java.net.URL import java.time.Instant import java.time.ZonedDateTime @@ -64,4 +64,4 @@ fun ICalCalendar.calendarItemsIn( ?.flatMap { ve: VEvent -> ve.calculateRecurrenceSet(interval.toICalPeriod()) .map { ve.toICalCalendarItem(it) } - } \ No newline at end of file + } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt similarity index 92% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendarItem.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt index 346529dd..186d3e87 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.model +package pro.qyoga.i9ns.calendars.ical.model import pro.qyoga.core.calendar.api.CalendarItem import java.time.Duration @@ -22,4 +22,4 @@ fun ICalCalendarItem.toLocalizedICalCalendarItem(): LocalizedICalCalendarItem = this.dateTime.toLocalDateTime(), this.duration, this.location - ) \ No newline at end of file + ) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt similarity index 87% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt index 9d0214b0..7f39790a 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/ICalEventId.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.model +package pro.qyoga.i9ns.calendars.ical.model import pro.qyoga.core.calendar.api.CalendarItemId diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/LocalizedICalCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt similarity index 80% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/LocalizedICalCalendarItem.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt index 29a6159d..7b95d5b4 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/model/LocalizedICalCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.model +package pro.qyoga.i9ns.calendars.ical.model import pro.qyoga.core.calendar.api.CalendarItem import java.time.Duration @@ -12,4 +12,4 @@ data class LocalizedICalCalendarItem( override val dateTime: LocalDateTime, override val duration: Duration, override val location: String? -) : CalendarItem \ No newline at end of file +) : CalendarItem diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/persistance/ICalCalendarsDao.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/persistance/ICalCalendarsDao.kt similarity index 91% rename from app/src/main/kotlin/pro/qyoga/core/calendar/ical/persistance/ICalCalendarsDao.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/persistance/ICalCalendarsDao.kt index 4cb1e485..a17bbc28 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/ical/persistance/ICalCalendarsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/persistance/ICalCalendarsDao.kt @@ -1,4 +1,4 @@ -package pro.qyoga.core.calendar.ical.persistance +package pro.qyoga.i9ns.calendars.ical.persistance import org.slf4j.LoggerFactory import org.springframework.data.domain.PageRequest @@ -8,8 +8,8 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations import org.springframework.stereotype.Repository import pro.azhidkov.platform.spring.sdj.ergo.ErgoRepository -import pro.qyoga.core.calendar.ical.model.ICalCalendar import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar private val log = LoggerFactory.getLogger(ICalCalendarsDao::class.java) @@ -33,4 +33,4 @@ fun ICalCalendarsDao.findAllByOwner(ownerRef: TherapistRef): List ICalCalendar::ownerRef isEqual ownerRef } .also { if (it.hasNext()) log.warn("Therapist $ownerRef has more that 100 calendars") } - .content \ No newline at end of file + .content diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 7a9f9586..87cf2e5a 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -7,7 +7,7 @@ import org.springframework.http.HttpStatus import pro.azhidkov.platform.java.time.toLocalTimeString import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.shouldBePage import pro.qyoga.tests.assertions.shouldHave import pro.qyoga.tests.assertions.shouldHaveElement diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt index 574c3e38..939d9cd1 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt @@ -6,7 +6,7 @@ import io.kotest.matchers.shouldBe import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.SelectorOnlyComponent import pro.qyoga.tests.assertions.shouldBePage import pro.qyoga.tests.assertions.shouldHaveComponent @@ -149,4 +149,4 @@ class SchedulePageTest : QYogaAppIntegrationBaseTest() { document shouldHaveComponent SelectorOnlyComponent(CalendarPage.SYNC_ERROR_ICON_SELECTOR) } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt index 03d46dff..a536b634 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarTest.kt @@ -7,11 +7,11 @@ import io.kotest.matchers.date.shouldBeBefore import io.kotest.matchers.shouldBe import net.fortuna.ical4j.model.component.VEvent import pro.azhidkov.platform.java.time.Interval -import pro.qyoga.core.calendar.ical.model.ICalCalendar -import pro.qyoga.core.calendar.ical.model.calendarItemsIn -import pro.qyoga.core.calendar.ical.model.findById -import pro.qyoga.core.calendar.ical.model.vEvents -import pro.qyoga.core.calendar.ical.platform.ical4j.id +import pro.qyoga.i9ns.calendars.ical.ical4j.id +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.model.calendarItemsIn +import pro.qyoga.i9ns.calendars.ical.model.findById +import pro.qyoga.i9ns.calendars.ical.model.vEvents import pro.qyoga.tests.fixture.object_mothers.calendars.ical.ICalCalendarsObjectMother.aICalCalendar import java.time.Duration import java.time.ZonedDateTime diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt index fc14d020..29d38660 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt @@ -2,8 +2,8 @@ package pro.qyoga.tests.cases.core.calendar.ical import io.kotest.core.annotation.DisplayName import io.kotest.matchers.shouldBe -import pro.qyoga.core.calendar.ical.ICalCalendarsRepo -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.ICalCalendarsRepo +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.fixture.backgrounds.ICalCalendarsBackgrounds import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother.aCalendarItem import pro.qyoga.tests.fixture.object_mothers.calendars.ical.ICalCalendarsObjectMother @@ -45,4 +45,4 @@ class ICalCalendarsRepoTest : QYogaAppBaseKoTest({ } } -}) \ No newline at end of file +}) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt index aca6bc2e..13a5e464 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/backgrounds/ICalCalendarsBackgrounds.kt @@ -3,9 +3,9 @@ package pro.qyoga.tests.fixture.backgrounds import com.github.tomakehurst.wiremock.client.WireMock.* import org.springframework.stereotype.Component import pro.azhidkov.platform.uuid.UUIDv7 -import pro.qyoga.core.calendar.ical.ICalCalendarsRepo -import pro.qyoga.core.calendar.ical.commands.CreateICalRq -import pro.qyoga.core.calendar.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.ICalCalendarsRepo +import pro.qyoga.i9ns.calendars.ical.commands.CreateICalRq +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar import pro.qyoga.tests.infra.wiremock.WireMock import java.net.URI import java.net.URL @@ -37,4 +37,4 @@ class ICalCalendarsBackgrounds( ) } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt index 3d75aabe..01a17b3c 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt @@ -2,8 +2,8 @@ package pro.qyoga.tests.fixture.object_mothers.calendars import org.instancio.Instancio import org.instancio.InstancioApi -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem -import pro.qyoga.core.calendar.ical.model.ICalEventId +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalEventId import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.data.randomElementOf diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt index c3d8e82d..f8120aa8 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt @@ -9,8 +9,8 @@ import net.fortuna.ical4j.model.property.Location import net.fortuna.ical4j.model.property.RecurrenceId import net.fortuna.ical4j.model.property.Uid import pro.azhidkov.platform.kotlin.ifNotNull -import pro.qyoga.core.calendar.ical.model.ICalCalendar -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.fixture.data.faker import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF @@ -55,4 +55,4 @@ object ICalCalendarsObjectMother { return calendar } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt index a9b398c4..027689ad 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt @@ -1,8 +1,8 @@ package pro.qyoga.tests.fixture.presets import org.springframework.stereotype.Component -import pro.qyoga.core.calendar.ical.model.ICalCalendar -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.fixture.backgrounds.ICalCalendarsBackgrounds import pro.qyoga.tests.fixture.object_mothers.calendars.ical.ICalCalendarsObjectMother.aIcsFile import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index 95c1a280..43d0f6e4 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -16,7 +16,7 @@ import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.appointments.core.schedule.TimeMark import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.Appointment -import pro.qyoga.core.calendar.ical.model.ICalCalendarItem +import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.tests.assertions.* import pro.qyoga.tests.pages.therapist.appointments.CalendarPage.APPOINTMENT_CARD_SELECTOR From a404de7d98dedde2ac396efe9e5668755a5de150 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 25 Sep 2025 10:44:59 +0700 Subject: [PATCH 24/43] =?UTF-8?q?refactor/qg-253:=20=D0=BF=D0=B0=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +-- .../azhidkov/platform/secrets/SecretChars.kt | 25 +++++++++++++++++ .../platform/spring/jdbc/RowMapperExt.kt | 10 +------ ...Converters.kt => SecretCharsConverters.kt} | 25 +---------------- .../UuidToAggregateReferenceConverter.kt | 11 ++++++++ .../pro/qyoga/app/infra/WebSecurityConfig.kt | 9 ++----- .../core/edit/forms/CreateAppointmentForm.kt | 2 +- .../i9ns/calendars/google/GoogleAccount.kt | 2 +- .../core/CreateAppointmentPageTest.kt | 12 +++++++-- .../tests/fixture/test_apis/TestApisConf.kt | 4 +-- settings.gradle.kts | 27 +++++++++++-------- 11 files changed, 72 insertions(+), 59 deletions(-) create mode 100644 app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt rename app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/{CharArrayConverters.kt => SecretCharsConverters.kt} (53%) create mode 100644 app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/UuidToAggregateReferenceConverter.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8cfb19e0..03dc7b0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-cache") - implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + implementation(libs.caffeine) implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-database-postgresql") @@ -40,7 +40,7 @@ dependencies { implementation(libs.google.api.client) implementation(libs.google.calendar.api) implementation(libs.google.oauth.client) - implementation(platform("com.google.auth:google-auth-library-bom:1.30.1")) + implementation(platform(libs.google.auth.bom)) implementation("com.google.auth:google-auth-library-oauth2-http") developmentOnly("org.springframework.boot:spring-boot-docker-compose") diff --git a/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt b/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt new file mode 100644 index 00000000..c13f4b75 --- /dev/null +++ b/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt @@ -0,0 +1,25 @@ +package pro.azhidkov.platform.secrets + +data class SecretChars(val value: CharArray) { + + fun show() = + String(value) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SecretChars + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return value.contentHashCode() + } + + override fun toString(): String { + return "" + } + +} diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt index 6be193ad..8bf5831a 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/jdbc/RowMapperExt.kt @@ -1,14 +1,12 @@ package pro.azhidkov.platform.spring.jdbc import com.fasterxml.jackson.databind.ObjectMapper -import org.springframework.core.convert.converter.Converter import org.springframework.core.convert.support.DefaultConversionService -import org.springframework.data.jdbc.core.mapping.AggregateReference import org.springframework.jdbc.core.DataClassRowMapper import org.springframework.jdbc.core.RowMapper import pro.azhidkov.platform.spring.sdj.converters.PGIntervalToDurationConverter import pro.azhidkov.platform.spring.sdj.converters.StringToSecretChars -import java.util.* +import pro.azhidkov.platform.spring.sdj.converters.UuidToAggregateReferenceConverter inline fun rowMapperFor(objectMapper: ObjectMapper, columnName: String? = null) = RowMapper { rs, _ -> @@ -20,12 +18,6 @@ inline fun rowMapperFor(objectMapper: ObjectMapper, columnName: Stri json?.let { objectMapper.readValue(it, T::class.java) } } -object UuidToAggregateReferenceConverter : Converter> { - override fun convert(source: UUID): AggregateReference<*, UUID> { - return AggregateReference.to(source) - } -} - inline fun taDataClassRowMapper() = DataClassRowMapper.newInstance(T::class.java).apply { conversionService = DefaultConversionService().apply { addConverter(PGIntervalToDurationConverter()) diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt similarity index 53% rename from app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt rename to app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt index 06bf550d..0e6c2991 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/CharArrayConverters.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt @@ -3,30 +3,7 @@ package pro.azhidkov.platform.spring.sdj.converters import org.springframework.core.convert.converter.Converter import org.springframework.data.convert.ReadingConverter import org.springframework.data.convert.WritingConverter - -data class SecretChars(val value: CharArray) { - - fun show() = - String(value) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as SecretChars - - return value.contentEquals(other.value) - } - - override fun hashCode(): Int { - return value.contentHashCode() - } - - override fun toString(): String { - return "" - } - -} +import pro.azhidkov.platform.secrets.SecretChars @WritingConverter class SecretCharsToString : Converter { diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/UuidToAggregateReferenceConverter.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/UuidToAggregateReferenceConverter.kt new file mode 100644 index 00000000..2a443b69 --- /dev/null +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/UuidToAggregateReferenceConverter.kt @@ -0,0 +1,11 @@ +package pro.azhidkov.platform.spring.sdj.converters + +import org.springframework.core.convert.converter.Converter +import org.springframework.data.jdbc.core.mapping.AggregateReference +import java.util.* + +object UuidToAggregateReferenceConverter : Converter> { + override fun convert(source: UUID): AggregateReference<*, UUID> { + return AggregateReference.to(source) + } +} diff --git a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt index 6ecb1051..f26cf796 100644 --- a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt @@ -51,11 +51,6 @@ class WebSecurityConfig( // Therapist .requestMatchers("/therapist/**").hasAnyAuthority(Role.ROLE_THERAPIST.toString()) - - // OAuth2 - .requestMatchers("/oauth2/**", "/therapist/oauth").permitAll() - - // Public .requestMatchers( HttpMethod.GET, @@ -87,7 +82,7 @@ class WebSecurityConfig( .failureForwardUrl("/error-p") .permitAll() } - .oauth2Client(withDefaults()) // Добавьте эту строку! + .oauth2Client(withDefaults()) .logout { logout: LogoutConfigurer -> logout.permitAll() } .rememberMe { rememberMeConfigurer -> rememberMeConfigurer @@ -110,4 +105,4 @@ class WebSecurityConfig( return jdbcTokenRepositoryImpl } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt index 83c17730..653ccf7a 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt @@ -57,7 +57,7 @@ data class CreateAppointmentForm( } -private fun formatCommentFor(externalEvent: CalendarItem?): String = +fun formatCommentFor(externalEvent: CalendarItem?): String = listOfNotNull( externalEvent?.title?.takeIf { !it.isBlank() }, externalEvent?.description?.takeIf { !it.isBlank() } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt index b73467a9..1315db81 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt @@ -3,7 +3,7 @@ package pro.qyoga.i9ns.calendars.google import org.springframework.data.annotation.Id import org.springframework.data.jdbc.core.mapping.AggregateReference import org.springframework.data.relational.core.mapping.Table -import pro.azhidkov.platform.spring.sdj.converters.SecretChars +import pro.azhidkov.platform.secrets.SecretChars import pro.azhidkov.platform.spring.sdj.ergo.hydration.Identifiable import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.users.therapists.TherapistRef diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 87cf2e5a..cc171900 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test import org.springframework.http.HttpStatus import pro.azhidkov.platform.java.time.toLocalTimeString import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref +import pro.qyoga.app.therapist.appointments.core.edit.forms.formatCommentFor import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.shouldBePage @@ -174,23 +175,30 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { CreateAppointmentForm.dateTime.value(document) shouldBe event.dateTime.toLocalDateTime().toString() CreateAppointmentForm.timeZone.value(document) shouldBe event.dateTime.zone.id CreateAppointmentForm.duration.value(document) shouldBe event.duration.toLocalTimeString() - CreateAppointmentForm.comment.value(document) shouldBe event.description + CreateAppointmentForm.comment.value(document) shouldBe formatCommentFor(event) } @Test @DisplayName("должна предзаполнять дату, время, длительность и идентификатор события источника данными из события goolge-календаря, если его ид был передан в запросе") // длина имени файла с лямбдой превышает ограничение Линукса fun createAppointmentWithGoogleEventId() { // Сетап - val event = GoogleCalendarObjectMother.aGoogleCalendarItem(date = { randomAppointmentDate() }) + val event = GoogleCalendarObjectMother.aGoogleCalendarItem(date = { + randomAppointmentDate().atZone(asiaNovosibirskTimeZone) + }) googleCalendarsFixturePresets.setupCalendar(event) // Действие val document = theTherapist.appointments.getCreateAppointmentPage( + dateTime = event.dateTime.toLocalDateTime(), sourceItem = SourceItem.googleEvent(event.id) ) // Проверка CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toQueryParamStr() + CreateAppointmentForm.dateTime.value(document) shouldBe event.dateTime.toLocalDateTime().toString() + CreateAppointmentForm.timeZone.value(document) shouldBe asiaNovosibirskTimeZone.id + CreateAppointmentForm.duration.value(document) shouldBe event.duration.toLocalTimeString() + CreateAppointmentForm.comment.value(document) shouldBe formatCommentFor(event) } } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt index 00289ebd..780c840e 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/test_apis/TestApisConf.kt @@ -1,9 +1,9 @@ package pro.qyoga.tests.fixture.test_apis +import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.ComponentScan -import org.springframework.context.annotation.Configuration -@Configuration +@TestConfiguration @ComponentScan class TestApisConf \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b976fef..5b948f55 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,9 +15,8 @@ dependencyResolutionManagement { // lib versions val poiVersion = version("poi", "5.4.1") - val googleApiClient = version("google-api-client", "2.0.0") - val googleCalendarApi = version("google-calendar-api", "v3-rev20220715-2.0.0") - val googleOAuthClientJetty = version("google-oauth-client", "1.34.1") + val caffeineVersion = version("caffeine", "3.1.8") + val googleAuthBomVersion = version("google-auth-bom", "1.30.1") // plugins plugin("kotlin", "org.jetbrains.kotlin.jvm").versionRef(kotlinVersion) @@ -42,6 +41,7 @@ dependencyResolutionManagement { library("postgres", "org.postgresql", "postgresql").version("42.7.6") library("minio", "io.minio", "minio").version("8.5.17") + library("caffeine", "com.github.ben-manes.caffeine", "caffeine").versionRef(caffeineVersion) library("poi-ooxml", "org.apache.poi", "poi-ooxml").versionRef(poiVersion) library("poi-ooxml-lite", "org.apache.poi", "poi-ooxml-lite").versionRef(poiVersion) @@ -51,13 +51,18 @@ dependencyResolutionManagement { library("nanocaptcha", "net.logicsquad", "nanocaptcha").version("2.1") library("ical4j", "org.mnode.ical4j", "ical4j").version("4.1.1") - library("google-api-client", "com.google.api-client", "google-api-client").versionRef(googleApiClient) - library("google-oauth-client", "com.google.oauth-client", "google-oauth-client-jetty").versionRef( - googleOAuthClientJetty - ) - library("google-calendar-api", "com.google.apis", "google-api-services-calendar").versionRef( - googleCalendarApi - ) + library("google-api-client", "com.google.api-client", "google-api-client").version("2.0.0") + library( + "google-oauth-client", + "com.google.oauth-client", + "google-oauth-client-jetty" + ).version("1.34.1") + library( + "google-calendar-api", + "com.google.apis", + "google-api-services-calendar" + ).version("v3-rev20220715-2.0.0") + library("google.auth.bom", "com.google.auth", "google-auth-library-bom").versionRef(googleAuthBomVersion) } create("testLibs") { @@ -97,4 +102,4 @@ dependencyResolutionManagement { library("wiremock-jetty12", "org.wiremock", "wiremock-jetty12").versionRef(wiremockVersion) } } -} \ No newline at end of file +} From 18e3e73b258f9eb186a293f251c180a43ae6a568 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 25 Sep 2025 11:11:32 +0700 Subject: [PATCH 25/43] =?UTF-8?q?refactor/qg-253:=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20AppointmentsRepo-=D0=BE=D0=BC=20Cale?= =?UTF-8?q?ndarsService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Триггер - метод findById( therapistRef: TherapistRef, eventId: UUID ), который реализовать, конечно, не сложно, но он не используется. Это навело меня на мысль, что приём это не is a CalendarItem, а у него есть компонент Событие. На самом деле у меня изначально была идея вытащить календарные даты в отдельную сущность. В том числе потому что это позволило бы связывать приёмы с событиями разных календарей (а не костылить как сейчас, что в расписании приём просто перекрывает события из других календарей с такими же датами). Но тогда я решил, что это слишком трудоёмко. --- .../core/appointments/core/AppointmentsRepo.kt | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt index b98caeab..892ccb0b 100644 --- a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt @@ -13,8 +13,6 @@ import pro.azhidkov.platform.spring.jdbc.taDataClassRowMapper import pro.azhidkov.platform.spring.sdj.ergo.ErgoRepository import pro.qyoga.core.appointments.core.model.Appointment import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary -import pro.qyoga.core.calendar.api.CalendarItem -import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef import java.sql.Timestamp @@ -35,9 +33,9 @@ class AppointmentsRepo( Appointment::class, jdbcConverter, relationalMappingContext -), CalendarsService { +) { - override fun findCalendarItemsInInterval( + fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval, ): SearchResult { @@ -73,13 +71,6 @@ class AppointmentsRepo( return SearchResult(findAll(query, params, localizedAppointmentSummaryRowMapper)) } - override fun findById( - therapistRef: TherapistRef, - eventId: UUID - ): CalendarItem? { - TODO() - } - } fun AppointmentsRepo.findIntersectingAppointment( From f469cc1ed6585374a41c289f6cf532574cb431f5 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 25 Sep 2025 11:14:17 +0700 Subject: [PATCH 26/43] =?UTF-8?q?test/qg-253:=20=D1=81=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/kotlin/pro/qyoga/tests/cases/arch/ArchTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/arch/ArchTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/arch/ArchTest.kt index 1c2b014c..21490a23 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/arch/ArchTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/arch/ArchTest.kt @@ -24,15 +24,17 @@ class ArchTest { .consideringAllDependencies() .layer(testsAbstractionLayer).definedBy("pro.qyoga.tests.(assertions|clients|fixture|infra|pages|platform)..") .layer("App").definedBy("pro.qyoga.app..") + .layer("I9ns").definedBy("pro.qyoga.i9ns..") .layer("Core").definedBy("pro.qyoga.core..") .layer("Infra").definedBy("pro.qyoga.infra..") .layer("Platform").definedBy("pro.azhidkov.platform..") .whereLayer("App").mayOnlyBeAccessedByLayers(testsAbstractionLayer) - .whereLayer("Core").mayOnlyBeAccessedByLayers("App", testsAbstractionLayer) + .whereLayer("I9ns").mayOnlyBeAccessedByLayers("App", testsAbstractionLayer) + .whereLayer("Core").mayOnlyBeAccessedByLayers("App", "I9ns", testsAbstractionLayer) .whereLayer("Infra").mayOnlyBeAccessedByLayers("Core", "App", testsAbstractionLayer) - .whereLayer("Platform").mayOnlyBeAccessedByLayers("App", "Core", "Infra", testsAbstractionLayer) + .whereLayer("Platform").mayOnlyBeAccessedByLayers("App", "I9ns", "Core", "Infra", testsAbstractionLayer) .check(qyogaClasses) } -} \ No newline at end of file +} From 15eed39048b40281489b5ec1c2f44cd16e6f34d1 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 25 Sep 2025 11:24:53 +0700 Subject: [PATCH 27/43] =?UTF-8?q?refactor/qg-253:=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D1=8B=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B8=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/edit/ops/GetAppointmentPrefillDataOp.kt | 4 ++-- .../appointments/core/edit/view_model/SourceItem.kt | 8 ++++---- .../main/kotlin/pro/qyoga/core/calendar/api/Calendar.kt | 4 ++-- .../kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt | 2 +- .../kotlin/pro/qyoga/core/calendar/api/CalendarType.kt | 8 ++++++++ .../pro/qyoga/core/calendar/api/CalendarsService.kt | 2 ++ .../pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt | 8 ++++---- .../qyoga/i9ns/calendars/google/GoogleCalendarItem.kt | 4 +++- .../i9ns/calendars/google/GoogleCalendarsService.kt | 3 +++ .../pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt | 3 +++ .../pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt | 9 ++++++--- .../pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt | 4 ++-- 12 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarType.kt diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index 7c9c839d..c8a12366 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -32,10 +32,10 @@ class GetAppointmentPrefillDataOp( val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapistRef)) val sourceEvent = when (sourceItem?.type) { - ICalCalendar.TYPE -> + ICalCalendar.Type.name -> iCalCalendarsRepo.findById(therapistRef, sourceItem.icsEventId()) - GoogleCalendar.TYPE -> + GoogleCalendar.Type.name -> googleCalendarsService.findById(therapistRef, sourceItem.googleEventId()) else -> diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt index 334b4140..665e3468 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt @@ -11,11 +11,11 @@ data class SourceItem( val id: String ) { - constructor(eventId: CalendarItemId) : this(eventId.type, eventId.toQueryParamStr()) + constructor(eventId: CalendarItemId) : this(eventId.type.name, eventId.toQueryParamStr()) companion object { fun icsEvent(eventId: ICalEventId): SourceItem = - SourceItem(ICalCalendar.TYPE, eventId.toQueryParamStr()) + SourceItem(ICalCalendar.Type.name, eventId.toQueryParamStr()) fun googleEvent(eventId: GoogleCalendarItemId): SourceItem = SourceItem("Google", eventId.toQueryParamStr()) @@ -25,7 +25,7 @@ data class SourceItem( } fun SourceItem.icsEventId(): ICalEventId { - check(type == ICalCalendar.TYPE) + check(type == ICalCalendar.Type.name) val matcher = "uid=(.+),rid=(.*)".toRegex().matchEntire(id) check(matcher != null) val uid = matcher.groups[1]!!.value @@ -34,7 +34,7 @@ fun SourceItem.icsEventId(): ICalEventId { } fun SourceItem.googleEventId(): GoogleCalendarItemId { - check(type == GoogleCalendar.TYPE) + check(type == GoogleCalendar.Type.name) val matcher = "(.+),(.+)".toRegex().matchEntire(id) check(matcher != null) return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/Calendar.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/Calendar.kt index bca230b6..5627b684 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/Calendar.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/Calendar.kt @@ -6,5 +6,5 @@ import pro.qyoga.core.users.therapists.TherapistRef interface Calendar { val ownerRef: TherapistRef val name: String - val type: String -} \ No newline at end of file + val type: CalendarType +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt index 1f236fd1..9fde079d 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt @@ -5,7 +5,7 @@ import java.time.temporal.Temporal interface CalendarItemId { - val type: String + val type: CalendarType fun toQueryParamStr(): String diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarType.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarType.kt new file mode 100644 index 00000000..68a3f192 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarType.kt @@ -0,0 +1,8 @@ +package pro.qyoga.core.calendar.api + + +interface CalendarType { + + val name: String + +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt index 525662f3..e88dd963 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt @@ -13,6 +13,8 @@ data class SearchResult( interface CalendarsService { + val type: CalendarType + fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt index c0e3cc2e..b4f351bf 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt @@ -1,6 +1,7 @@ package pro.qyoga.i9ns.calendars.google import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.users.therapists.TherapistRef @@ -10,11 +11,10 @@ data class GoogleCalendar( override val name: String, ) : Calendar { - override val type: String = TYPE + override val type: CalendarType = Type - companion object { - - const val TYPE = "Google" + object Type : CalendarType { + override val name = "Google" } } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt index 6cc14c72..18ba9afd 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt @@ -3,16 +3,18 @@ package pro.qyoga.i9ns.calendars.google import pro.azhidkov.platform.java.time.toLocalDateTime import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarItemId +import pro.qyoga.core.calendar.api.CalendarType import java.time.Duration import java.time.ZoneId import java.time.temporal.Temporal + data class GoogleCalendarItemId( val calendarId: String, val itemId: String ) : CalendarItemId { - override val type: String = GoogleCalendar.TYPE + override val type: CalendarType = GoogleCalendar.Type override fun toQueryParamStr(): String = "$calendarId,$itemId" diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt index b7814c0e..4fef2fd5 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt @@ -10,6 +10,7 @@ import pro.azhidkov.platform.java.time.zoneId import pro.azhidkov.platform.kotlin.tryExecute import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef @@ -81,6 +82,8 @@ class GoogleCalendarsService( private val googleCalendarsClient: GoogleCalendarsClient, ) : CalendarsService { + override val type: CalendarType = GoogleCalendar.Type + private val executor = VirtualThreadExecutor("google-calendar-events-fetcher") fun addGoogleAccount(googleAccount: GoogleAccount) { diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt index a29d7126..dac161bb 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt @@ -5,6 +5,7 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef @@ -24,6 +25,8 @@ class ICalCalendarsRepo( private val log = LoggerFactory.getLogger(javaClass) + override val type: CalendarType = ICalCalendar.Type + fun addICal(createICalRq: CreateICalRq): ICalCalendar { val icsData = createICalRq.icsUrl.readText() val ical = ICalCalendar.createFrom(createICalRq, icsData) diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt index 7e83b0fe..0b2d0466 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt @@ -6,6 +6,7 @@ import org.springframework.data.relational.core.mapping.Table import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.calendar.api.Calendar +import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.i9ns.calendars.ical.ical4j.recurrenceId import pro.qyoga.i9ns.calendars.ical.ical4j.toICalCalendarItem @@ -35,7 +36,7 @@ data class ICalCalendar( ) : Calendar { @Transient - override val type: String = TYPE + override val type: CalendarType = Type val calendar: net.fortuna.ical4j.model.Calendar? by lazy { tryParseIcs(icsFile) @@ -44,10 +45,12 @@ data class ICalCalendar( fun withIcsFile(icsFile: String) = copy(icsFile = icsFile) - companion object { - const val TYPE = "ICAL" + object Type : CalendarType { + override val name = "ICAL" } + companion object + } fun ICalCalendar.vEvents(): List? = diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt index 7f39790a..dd9f594d 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt @@ -1,6 +1,7 @@ package pro.qyoga.i9ns.calendars.ical.model import pro.qyoga.core.calendar.api.CalendarItemId +import pro.qyoga.core.calendar.api.CalendarType data class ICalEventId( @@ -8,10 +9,9 @@ data class ICalEventId( val recurrenceId: String? = null ) : CalendarItemId { - override val type: String = ICalCalendar.TYPE + override val type: CalendarType = ICalCalendar.Type override fun toQueryParamStr(): String = "uid=${uid},rid=${recurrenceId ?: ""}" - } From 386e6f28b9458a61d733a0a5e87b3f6a23acbc6f Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Thu, 25 Sep 2025 11:58:31 +0700 Subject: [PATCH 28/43] =?UTF-8?q?refactor/qg-253:=20=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=20CalendarItem=20=D0=BF=D0=BE=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=BC=D1=83=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=83=20=D0=B8=D0=BD=D0=BA=D0=B0=D0=BF=D1=81=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=B2=20CalendarItemsResolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt | 6 ++- .../edit/CreateAppointmentPageController.kt | 2 +- .../edit/ops/GetAppointmentPrefillDataOp.kt | 23 ++--------- .../core/edit/view_model/SourceItem.kt | 41 ------------------- .../core/schedule/CalendarPageModel.kt | 2 +- .../core/calendar/api/CalendarsService.kt | 2 + .../pro/qyoga/core/calendar/api/SourceItem.kt | 10 +++++ .../calendar/gateways/CalendarGatewaysConf.kt | 9 ++++ .../gateways/CalendarItemsResolver.kt | 28 +++++++++++++ .../google/GoogleCalendarsService.kt | 15 +++++-- .../i9ns/calendars/ical/ICalCalendarsRepo.kt | 17 ++++++-- .../core/CreateAppointmentPageTest.kt | 6 +-- .../qyoga/tests/clients/TherapistClient.kt | 4 +- .../clients/api/TherapistAppointmentsApi.kt | 6 +-- .../therapist/appointments/SchedulePage.kt | 4 +- 15 files changed, 92 insertions(+), 83 deletions(-) delete mode 100644 app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarGatewaysConf.kt create mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt diff --git a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt index cc53590a..27c173de 100644 --- a/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt @@ -8,6 +8,7 @@ import pro.azhidkov.platform.spring.sdj.ErgoSdjConfig import pro.qyoga.app.publc.PublicAppConfig import pro.qyoga.app.therapist.TherapistWebAppConfig import pro.qyoga.core.appointments.AppointmentsConfig +import pro.qyoga.core.calendar.gateways.CalendarGatewaysConf import pro.qyoga.core.clients.ClientsConfig import pro.qyoga.core.survey_forms.SurveyFormsSettingsConfig import pro.qyoga.core.therapy.TherapyConfig @@ -35,11 +36,12 @@ import pro.qyoga.tech.captcha.CaptchaConf TherapyConfig::class, UsersConfig::class, SurveyFormsSettingsConfig::class, - ICalCalendarsConfig::class, - GoogleCalendarConf::class, + CalendarGatewaysConf::class, // I9ns EmailsConfig::class, + ICalCalendarsConfig::class, + GoogleCalendarConf::class, // Tech CaptchaConf::class, diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt index 50134a4a..4cd39bcb 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt @@ -16,10 +16,10 @@ import pro.qyoga.app.publc.components.toComboBoxItem import pro.qyoga.app.therapist.appointments.core.edit.errors.AppointmentsIntersectionException import pro.qyoga.app.therapist.appointments.core.edit.ops.CreateAppointmentOp import pro.qyoga.app.therapist.appointments.core.edit.ops.GetAppointmentPrefillDataOp -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.edit.view_model.appointmentPageModelAndView import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController.Companion.calendarForDayWithFocus import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest +import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref import java.time.LocalDateTime diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index c8a12366..0e6fa32f 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -3,23 +3,17 @@ package pro.qyoga.app.therapist.appointments.core.edit.ops import org.springframework.stereotype.Component import pro.azhidkov.timezones.TimeZones import pro.qyoga.app.therapist.appointments.core.edit.forms.CreateAppointmentForm -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem -import pro.qyoga.app.therapist.appointments.core.edit.view_model.googleEventId -import pro.qyoga.app.therapist.appointments.core.edit.view_model.icsEventId +import pro.qyoga.core.calendar.api.SourceItem +import pro.qyoga.core.calendar.gateways.CalendarItemsResolver import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef -import pro.qyoga.i9ns.calendars.google.GoogleCalendar -import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService -import pro.qyoga.i9ns.calendars.ical.ICalCalendarsRepo -import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar import java.time.LocalDateTime @Component class GetAppointmentPrefillDataOp( - private val iCalCalendarsRepo: ICalCalendarsRepo, - private val googleCalendarsService: GoogleCalendarsService, + private val calendarItemsResolver: CalendarItemsResolver, private val userSettingsRepo: UserSettingsRepo, private val timeZones: TimeZones, ) : (TherapistRef, SourceItem?, LocalDateTime?) -> CreateAppointmentForm { @@ -31,16 +25,7 @@ class GetAppointmentPrefillDataOp( ): CreateAppointmentForm { val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapistRef)) - val sourceEvent = when (sourceItem?.type) { - ICalCalendar.Type.name -> - iCalCalendarsRepo.findById(therapistRef, sourceItem.icsEventId()) - - GoogleCalendar.Type.name -> - googleCalendarsService.findById(therapistRef, sourceItem.googleEventId()) - - else -> - null - } + val sourceEvent = calendarItemsResolver.findCalendarItem(therapistRef, sourceItem!!) val timeZoneTitle = timeZones.findById(currentUserTimeZone)?.displayName diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt deleted file mode 100644 index 665e3468..00000000 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/view_model/SourceItem.kt +++ /dev/null @@ -1,41 +0,0 @@ -package pro.qyoga.app.therapist.appointments.core.edit.view_model - -import pro.qyoga.core.calendar.api.CalendarItemId -import pro.qyoga.i9ns.calendars.google.GoogleCalendar -import pro.qyoga.i9ns.calendars.google.GoogleCalendarItemId -import pro.qyoga.i9ns.calendars.ical.model.ICalCalendar -import pro.qyoga.i9ns.calendars.ical.model.ICalEventId - -data class SourceItem( - val type: String, - val id: String -) { - - constructor(eventId: CalendarItemId) : this(eventId.type.name, eventId.toQueryParamStr()) - - companion object { - fun icsEvent(eventId: ICalEventId): SourceItem = - SourceItem(ICalCalendar.Type.name, eventId.toQueryParamStr()) - - fun googleEvent(eventId: GoogleCalendarItemId): SourceItem = - SourceItem("Google", eventId.toQueryParamStr()) - - } - -} - -fun SourceItem.icsEventId(): ICalEventId { - check(type == ICalCalendar.Type.name) - val matcher = "uid=(.+),rid=(.*)".toRegex().matchEntire(id) - check(matcher != null) - val uid = matcher.groups[1]!!.value - val rid = matcher.groups[2]!!.value.takeIf { it.isNotBlank() } - return ICalEventId(uid, rid) -} - -fun SourceItem.googleEventId(): GoogleCalendarItemId { - check(type == GoogleCalendar.Type.name) - val matcher = "(.+),(.+)".toRegex().matchEntire(id) - check(matcher != null) - return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) -} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index b8c6beff..dacf44ae 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -5,7 +5,6 @@ package pro.qyoga.app.therapist.appointments.core.schedule import org.springframework.web.servlet.ModelAndView import pro.qyoga.app.therapist.appointments.core.edit.CreateAppointmentPageController import pro.qyoga.app.therapist.appointments.core.edit.EditAppointmentPageController -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.CLIENT_CAME_CARD import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.CLIENT_DO_NOT_CAME_CARD import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.DRAFT_CARD @@ -18,6 +17,7 @@ import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarItemId +import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.l10n.russianDayOfMonthLongFormat import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.l10n.systemLocale diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt index e88dd963..6d48cb2f 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt @@ -22,4 +22,6 @@ interface CalendarsService { fun findById(therapistRef: TherapistRef, eventId: ID): CalendarItem? + fun parseStringId(sourceItem: SourceItem): ID + } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt new file mode 100644 index 00000000..f8826238 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt @@ -0,0 +1,10 @@ +package pro.qyoga.core.calendar.api + +data class SourceItem( + val type: String, + val id: String +) { + + constructor(eventId: CalendarItemId) : this(eventId.type.name, eventId.toQueryParamStr()) + +} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarGatewaysConf.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarGatewaysConf.kt new file mode 100644 index 00000000..ae77a7f6 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarGatewaysConf.kt @@ -0,0 +1,9 @@ +package pro.qyoga.core.calendar.gateways + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + + +@ComponentScan +@Configuration +class CalendarGatewaysConf diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt new file mode 100644 index 00000000..d3a785db --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt @@ -0,0 +1,28 @@ +package pro.qyoga.core.calendar.gateways + +import org.springframework.stereotype.Service +import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarItemId +import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SourceItem +import pro.qyoga.core.users.therapists.TherapistRef +import java.time.ZonedDateTime + +@Service +class CalendarItemsResolver( + calendarsServices: List> +) { + + private val services = calendarsServices.associateBy { it.type.name } + + fun findCalendarItem( + therapistRef: TherapistRef, + sourceItem: SourceItem + ): CalendarItem? { + @Suppress("UNCHECKED_CAST") + val service = services[sourceItem.type] as CalendarsService + val id = service.parseStringId(sourceItem) + return service.findById(therapistRef, id) + } + +} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt index 4fef2fd5..c4c91c07 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt @@ -9,10 +9,7 @@ import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.java.time.zoneId import pro.azhidkov.platform.kotlin.tryExecute import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref -import pro.qyoga.core.calendar.api.CalendarItem -import pro.qyoga.core.calendar.api.CalendarType -import pro.qyoga.core.calendar.api.CalendarsService -import pro.qyoga.core.calendar.api.SearchResult +import pro.qyoga.core.calendar.api.* import pro.qyoga.core.users.therapists.TherapistRef import java.time.ZonedDateTime import java.util.* @@ -152,6 +149,9 @@ class GoogleCalendarsService( return googleCalendarsClient.findById(account, eventId) } + override fun parseStringId(sourceItem: SourceItem): GoogleCalendarItemId = + sourceItem.googleEventId() + fun updateCalendarSettings( therapist: TherapistRef, googleAccount: GoogleAccountRef, @@ -162,3 +162,10 @@ class GoogleCalendarsService( } } + +fun SourceItem.googleEventId(): GoogleCalendarItemId { + check(type == GoogleCalendar.Type.name) + val matcher = "(.+),(.+)".toRegex().matchEntire(id) + check(matcher != null) + return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) +} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt index dac161bb..a686b5df 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt @@ -4,10 +4,7 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval -import pro.qyoga.core.calendar.api.CalendarItem -import pro.qyoga.core.calendar.api.CalendarType -import pro.qyoga.core.calendar.api.CalendarsService -import pro.qyoga.core.calendar.api.SearchResult +import pro.qyoga.core.calendar.api.* import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.i9ns.calendars.ical.commands.CreateICalRq import pro.qyoga.i9ns.calendars.ical.commands.createFrom @@ -55,6 +52,9 @@ class ICalCalendarsRepo( ?.toICalCalendarItem() } + override fun parseStringId(sourceItem: SourceItem): ICalEventId = + sourceItem.icsEventId() + @Scheduled(cron = "0 */10 * * * *") fun sync() { @@ -71,3 +71,12 @@ private fun ICalCalendar.localizedICalCalendarItemsIn( ): List = (this.calendarItemsIn(interval) ?: emptyList()) .map(ICalCalendarItem::toLocalizedICalCalendarItem) + +fun SourceItem.icsEventId(): ICalEventId { + check(type == ICalCalendar.Type.name) + val matcher = "uid=(.+),rid=(.*)".toRegex().matchEntire(id) + check(matcher != null) + val uid = matcher.groups[1]!!.value + val rid = matcher.groups[2]!!.value.takeIf { it.isNotBlank() } + return ICalEventId(uid, rid) +} diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index cc171900..8a1fbfb2 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -7,7 +7,7 @@ import org.springframework.http.HttpStatus import pro.azhidkov.platform.java.time.toLocalTimeString import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.edit.forms.formatCommentFor -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem +import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.shouldBePage import pro.qyoga.tests.assertions.shouldHave @@ -167,7 +167,7 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { // Действие val document = theTherapist.appointments.getCreateAppointmentPage( dateTime = event.dateTime.toLocalDateTime(), - sourceItem = SourceItem.icsEvent(event.id) + sourceItem = SourceItem(event.id) ) // Проверка @@ -190,7 +190,7 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { // Действие val document = theTherapist.appointments.getCreateAppointmentPage( dateTime = event.dateTime.toLocalDateTime(), - sourceItem = SourceItem.googleEvent(event.id) + sourceItem = SourceItem(event.id) ) // Проверка diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt index 8da48227..dfb6a1f2 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/TherapistClient.kt @@ -21,7 +21,7 @@ class TherapistClient( ) { // Work - val appointments = TherapistAppointmentsApi(authCookie, webTestClient) + val appointments = TherapistAppointmentsApi(authCookie) val googleCalendarIntegration = TherapistGoogleCalendarIntegrationApi(authCookie, webTestClient) val clients = TherapistClientsApi(authCookie) val clientJournal = TherapistClientJournalApi(authCookie) @@ -61,4 +61,4 @@ class TherapistClient( } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt index 04d8cd93..d97dcb99 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt @@ -11,15 +11,14 @@ import org.hamcrest.CoreMatchers.nullValue import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.springframework.http.HttpStatus -import org.springframework.test.web.reactive.server.WebTestClient import pro.azhidkov.platform.java.time.toLocalTimeString import pro.qyoga.app.therapist.appointments.core.edit.CreateAppointmentPageController import pro.qyoga.app.therapist.appointments.core.edit.EditAppointmentPageController -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.AppointmentRef +import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage import pro.qyoga.tests.pages.therapist.appointments.EditAppointmentPage import java.time.LocalDate @@ -27,8 +26,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter class TherapistAppointmentsApi( - override val authCookie: Cookie, - private val webTestClient: WebTestClient + override val authCookie: Cookie ) : AuthorizedApi { fun getScheduleForDay(date: LocalDate? = null, appointmentToFocus: AppointmentRef? = null): Document { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index 43d0f6e4..d8eb4ba8 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -9,13 +9,13 @@ import org.jsoup.nodes.Element import org.jsoup.select.Elements import pro.azhidkov.platform.spring.sdj.ergo.hydration.resolveOrThrow import pro.qyoga.app.therapist.appointments.core.edit.CreateAppointmentPageController -import pro.qyoga.app.therapist.appointments.core.edit.view_model.SourceItem import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.appointments.core.schedule.TimeMark import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.Appointment +import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.tests.assertions.* @@ -95,7 +95,7 @@ infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { "editAppointmentLink", CreateAppointmentPageController.addFromSourceItemUri( localizedICalCalendarItem.dateTime.toLocalDateTime(), - SourceItem.icsEvent(localizedICalCalendarItem.id) + SourceItem(localizedICalCalendarItem.id) ), localizedICalCalendarItem.title + " " + russianTimeFormat.format(localizedICalCalendarItem.dateTime) + " - " + russianTimeFormat.format( From 4c58c0e5963c448a4ebe8a00600035b546532469 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 26 Sep 2025 10:10:55 +0700 Subject: [PATCH 29/43] =?UTF-8?q?tests/qg-253:=20Link=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=20=D0=BD=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D1=87=D0=B8=D0=BD=D0=B3=20=D1=83=D1=80=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20UriTemplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для повышения надёжности. В частности для матчинга урлов с вложенными uri с query-параметрами в качестве значений query-параметров url --- .../pro/qyoga/tests/assertions/ElementMatchers.kt | 11 ++++++++++- .../kotlin/pro/qyoga/tests/platform/html/Link.kt | 14 +++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/ElementMatchers.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/ElementMatchers.kt index 272fa14d..d2da2f12 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/ElementMatchers.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/assertions/ElementMatchers.kt @@ -6,6 +6,7 @@ import io.kotest.matchers.compose.all import io.kotest.matchers.should import io.kotest.matchers.shouldNot import org.jsoup.nodes.Element +import org.springframework.web.util.UriTemplate import pro.qyoga.tests.platform.html.Component import pro.qyoga.tests.platform.html.HtmlPage import pro.qyoga.tests.platform.html.Input @@ -104,6 +105,14 @@ fun haveAttributeValueMatching(attr: String, valueRegex: Regex) = Matcher { elem ) } +fun haveAttributeValueMatching(attr: String, uriTemplate: UriTemplate) = Matcher { element: Element -> + MatcherResult.invoke( + uriTemplate.matches(element.attr(attr)), + { "Element ${element.descr} has $attr=\"${element.attr(attr)}\" but `$attr` value matching \"${uriTemplate}}\" is expected" }, + { "Element ${element.descr} should not have attribute `$attr` value matching \"${uriTemplate}}\"" }, + ) +} + fun haveAttributeValue(attr: String, value: String, ignoreCase: Boolean = false) = Matcher { element: Element -> MatcherResult.invoke( element.attr(attr).equals(value, ignoreCase = ignoreCase), @@ -277,4 +286,4 @@ data class SelectorOnlyComponent(val selector: String) : Component { override fun matcher(): Matcher = alwaysSuccess() -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/html/Link.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/html/Link.kt index cc4f315d..a4439232 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/html/Link.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/html/Link.kt @@ -2,12 +2,12 @@ package pro.qyoga.tests.platform.html import io.kotest.matchers.Matcher import org.jsoup.nodes.Element +import org.springframework.web.util.UriTemplate import pro.qyoga.tests.assertions.haveAttribute import pro.qyoga.tests.assertions.haveAttributeValueMatching import pro.qyoga.tests.assertions.haveText import pro.qyoga.tests.assertions.isTag import pro.qyoga.tests.platform.kotest.all -import pro.qyoga.tests.platform.pathToRegex class Link( val id: String, @@ -16,7 +16,7 @@ class Link( val targetAttr: String = "href" ) : Component { - val urlRegex = urlPattern.pathToRegex().toRegex() + val urlRegex = UriTemplate(urlPattern) constructor(id: String, page: HtmlPageCompat, text: String) : this(id, page.path, text) @@ -36,12 +36,8 @@ class Link( fun pathParam(element: Element, paramName: String): String? { val actualUrl = element.select(selector()).attr(targetAttr) - val paramIdx = "\\{.*?}".toRegex().findAll(urlPattern) - .indexOfFirst { it.value == "{$paramName}" } - .takeIf { it >= 0 } - ?: return null - - return urlRegex.matchEntire(actualUrl)!!.groupValues[paramIdx + 1] + val vars = urlRegex.match(actualUrl) + return vars[paramName] } companion object { @@ -52,4 +48,4 @@ class Link( } -} \ No newline at end of file +} From 85ee3b947f8480cacfffd85b987708f1375f5f67 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 26 Sep 2025 11:48:59 +0700 Subject: [PATCH 30/43] =?UTF-8?q?refactor/qg-253:=20SourceItem=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=20=D0=BD=D0=B0=20URI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы не переизобретать велосипед для строкового представления универсальных идентификаторов ресурсов, чем SourceItem собственно и был --- app/build.gradle.kts | 5 ++ .../edit/CreateAppointmentPageController.kt | 28 +++++------ .../core/edit/forms/CreateAppointmentForm.kt | 5 +- .../edit/ops/GetAppointmentPrefillDataOp.kt | 8 +-- .../core/schedule/CalendarPageModel.kt | 3 +- .../qyoga/core/calendar/api/CalendarItem.kt | 50 ++++++++++++++++++- .../core/calendar/api/CalendarsService.kt | 2 +- .../pro/qyoga/core/calendar/api/SourceItem.kt | 10 ---- .../gateways/CalendarItemsResolver.kt | 12 +++-- .../calendars/google/GoogleCalendarItem.kt | 6 ++- .../calendars/google/GoogleCalendarsClient.kt | 5 +- .../google/GoogleCalendarsService.kt | 29 ++++++----- .../i9ns/calendars/ical/ICalCalendarsRepo.kt | 20 +++----- .../i9ns/calendars/ical/model/ICalEventId.kt | 6 ++- .../main/resources/application-local-dev.yaml | 2 +- .../core/CreateAppointmentPageTest.kt | 9 ++-- .../clients/api/TherapistAppointmentsApi.kt | 7 ++- .../therapist/appointments/SchedulePage.kt | 3 +- .../pro/qyoga/tests/platform/StringExt.kt | 2 +- 19 files changed, 130 insertions(+), 82 deletions(-) delete mode 100644 app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03dc7b0b..337ff969 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import kotlinx.kover.gradle.plugin.dsl.AggregationType import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.kotlin.spring) @@ -230,3 +231,7 @@ configurations.matching { it.name == "detekt" }.all { } } } +val compileKotlin: KotlinCompile by tasks +compileKotlin.compilerOptions { + freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property")) +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt index 4cd39bcb..a3f1b0f0 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/CreateAppointmentPageController.kt @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.servlet.ModelAndView +import org.springframework.web.util.UriComponentsBuilder import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.azhidkov.timezones.LocalizedTimeZone import pro.azhidkov.timezones.TimeZones @@ -19,9 +20,9 @@ import pro.qyoga.app.therapist.appointments.core.edit.ops.GetAppointmentPrefillD import pro.qyoga.app.therapist.appointments.core.edit.view_model.appointmentPageModelAndView import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController.Companion.calendarForDayWithFocus import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref +import java.net.URI import java.time.LocalDateTime @Controller @@ -35,14 +36,9 @@ class CreateAppointmentPageController( @GetMapping fun getAppointmentPage( @RequestParam(DATE_TIME) dateTime: LocalDateTime?, - @RequestParam(SOURCE_ITEM_TYPE) sourceItemType: String?, - @RequestParam(SOURCE_ITEM_ID) sourceItemId: String?, + @RequestParam(SOURCE_ITEM) sourceItem: URI?, @AuthenticationPrincipal therapist: QyogaUserDetails ): ModelAndView { - val sourceItem = when { - sourceItemType != null && sourceItemId != null -> SourceItem(sourceItemType, sourceItemId) - else -> null - } val prefillData = getAppointmentPrefillData(therapist.ref, sourceItem, dateTime) return appointmentPageModelAndView( pageMode = EntityPageMode.CREATE, @@ -77,17 +73,19 @@ class CreateAppointmentPageController( companion object { const val PATH = "/therapist/appointments/new" const val DATE_TIME = "dateTime" - const val SOURCE_ITEM_TYPE = "sourceItemType" - const val SOURCE_ITEM_ID = "sourceItemId" + const val SOURCE_ITEM = "sourceItem" const val CREATE_AT_DATE_TIME_URI = "/therapist/appointments/new?$DATE_TIME={$DATE_TIME}" private const val CREATE_FROM_SOURCE_ITEM_URI = - "/therapist/appointments/new?$DATE_TIME={$DATE_TIME}&$SOURCE_ITEM_TYPE={$SOURCE_ITEM_TYPE}&$SOURCE_ITEM_ID={$SOURCE_ITEM_ID}" + "/therapist/appointments/new" - fun addFromSourceItemUri(dateTime: LocalDateTime, sourceItem: SourceItem): String = - CREATE_FROM_SOURCE_ITEM_URI - .replace("{$DATE_TIME}", dateTime.toString()) - .replace("{$SOURCE_ITEM_TYPE}", sourceItem.type) - .replace("{$SOURCE_ITEM_ID}", sourceItem.id) + fun addFromSourceItemUri(dateTime: LocalDateTime, sourceItem: URI): String = + UriComponentsBuilder + .fromPath(CREATE_FROM_SOURCE_ITEM_URI) + .queryParam("dateTime", dateTime) + .queryParam("sourceItem", sourceItem.toString()) + .encode() + .build() + .toUriString() } } diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt index 653ccf7a..2288b147 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/forms/CreateAppointmentForm.kt @@ -6,6 +6,7 @@ import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarItemId import pro.qyoga.core.clients.cards.model.ClientRef import pro.qyoga.core.therapy.therapeutic_tasks.model.TherapeuticTaskRef +import java.net.URI import java.time.Duration import java.time.LocalDateTime import java.time.ZoneId @@ -28,7 +29,7 @@ data class CreateAppointmentForm( val payed: Boolean?, val appointmentStatus: AppointmentStatus?, val comment: String?, - val externalId: String? + val externalId: URI? ) { constructor( @@ -37,7 +38,7 @@ data class CreateAppointmentForm( timeZone: ZoneId, timeZoneTitle: String? ) : this( - externalId = externalEvent?.id?.toQueryParamStr(), + externalId = externalEvent?.id?.toUri(), dateTime = dateTime, timeZone = timeZone, timeZoneTitle = timeZoneTitle, diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt index 0e6fa32f..1ca7c632 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/edit/ops/GetAppointmentPrefillDataOp.kt @@ -3,11 +3,11 @@ package pro.qyoga.app.therapist.appointments.core.edit.ops import org.springframework.stereotype.Component import pro.azhidkov.timezones.TimeZones import pro.qyoga.app.therapist.appointments.core.edit.forms.CreateAppointmentForm -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.core.calendar.gateways.CalendarItemsResolver import pro.qyoga.core.users.auth.model.UserRef import pro.qyoga.core.users.settings.UserSettingsRepo import pro.qyoga.core.users.therapists.TherapistRef +import java.net.URI import java.time.LocalDateTime @@ -16,16 +16,16 @@ class GetAppointmentPrefillDataOp( private val calendarItemsResolver: CalendarItemsResolver, private val userSettingsRepo: UserSettingsRepo, private val timeZones: TimeZones, -) : (TherapistRef, SourceItem?, LocalDateTime?) -> CreateAppointmentForm { +) : (TherapistRef, URI?, LocalDateTime?) -> CreateAppointmentForm { override fun invoke( therapistRef: TherapistRef, - sourceItem: SourceItem?, + sourceItem: URI?, dateTime: LocalDateTime? ): CreateAppointmentForm { val currentUserTimeZone = userSettingsRepo.getUserTimeZone(UserRef(therapistRef)) - val sourceEvent = calendarItemsResolver.findCalendarItem(therapistRef, sourceItem!!) + val sourceEvent = sourceItem?.let { calendarItemsResolver.findCalendarItem(therapistRef, it) } val timeZoneTitle = timeZones.findById(currentUserTimeZone)?.displayName diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt index dacf44ae..435a432c 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt @@ -17,7 +17,6 @@ import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarItemId -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.l10n.russianDayOfMonthLongFormat import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.l10n.systemLocale @@ -267,7 +266,7 @@ private fun CalendarItem<*, LocalDateTime>.editUri() = is UUID -> EditAppointmentPageController.editUri(id as UUID) is CalendarItemId -> CreateAppointmentPageController.addFromSourceItemUri( dateTime, - SourceItem(id as CalendarItemId) + (id as CalendarItemId).toUri() ) else -> error("Unsupported type: $id") } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt index 9fde079d..ee0cf0d1 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarItem.kt @@ -1,13 +1,61 @@ package pro.qyoga.core.calendar.api +import org.springframework.util.MultiValueMap +import org.springframework.web.util.UriComponentsBuilder +import org.springframework.web.util.UriTemplate +import java.net.URI import java.time.Duration import java.time.temporal.Temporal +data class CalendarItemUri( + val type: String, + val params: Map +) { + + companion object { + + private const val SCHEMA = "qyoga" + private const val HOST = "calendars" + private const val PATH = "{type}/events" + const val TEMPLATE = "$SCHEMA://$HOST/$PATH" + + private val pathTemplate = UriTemplate("/$PATH") + + fun parseOrNull(uri: URI): CalendarItemUri? { + if (uri.scheme != SCHEMA) { + return null + } + if (uri.host != HOST) { + return null + } + + val comps = UriComponentsBuilder.fromUri(uri).build() + if (!pathTemplate.matches(comps.path)) { + return null + } + + val type = comps.pathSegments.getOrNull(0) + ?: return null + + val params: Map = comps.queryParams.toSingleValueMap() + + return CalendarItemUri(type, params) + } + + } + +} + interface CalendarItemId { val type: CalendarType - fun toQueryParamStr(): String + fun toUri(): URI = UriComponentsBuilder.fromUriString(CalendarItemUri.TEMPLATE) + .queryParams(MultiValueMap.fromSingleValue(toMap())) + .buildAndExpand(type.name) + .toUri() + + fun toMap(): Map } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt index 6d48cb2f..25f307aa 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/api/CalendarsService.kt @@ -22,6 +22,6 @@ interface CalendarsService { fun findById(therapistRef: TherapistRef, eventId: ID): CalendarItem? - fun parseStringId(sourceItem: SourceItem): ID + fun createItemId(itemId: Map): ID } diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt deleted file mode 100644 index f8826238..00000000 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/api/SourceItem.kt +++ /dev/null @@ -1,10 +0,0 @@ -package pro.qyoga.core.calendar.api - -data class SourceItem( - val type: String, - val id: String -) { - - constructor(eventId: CalendarItemId) : this(eventId.type.name, eventId.toQueryParamStr()) - -} diff --git a/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt index d3a785db..fb95b03e 100644 --- a/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt +++ b/app/src/main/kotlin/pro/qyoga/core/calendar/gateways/CalendarItemsResolver.kt @@ -3,9 +3,10 @@ package pro.qyoga.core.calendar.gateways import org.springframework.stereotype.Service import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.calendar.api.CalendarItemId +import pro.qyoga.core.calendar.api.CalendarItemUri import pro.qyoga.core.calendar.api.CalendarsService -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.core.users.therapists.TherapistRef +import java.net.URI import java.time.ZonedDateTime @Service @@ -17,11 +18,14 @@ class CalendarItemsResolver( fun findCalendarItem( therapistRef: TherapistRef, - sourceItem: SourceItem + sourceItem: URI ): CalendarItem? { + val calendarItemUri = CalendarItemUri.parseOrNull(sourceItem) + ?: throw IllegalArgumentException("Invalid calendar item URI: $sourceItem") + @Suppress("UNCHECKED_CAST") - val service = services[sourceItem.type] as CalendarsService - val id = service.parseStringId(sourceItem) + val service = services[calendarItemUri.type] as CalendarsService + val id = service.createItemId(calendarItemUri.params) return service.findById(therapistRef, id) } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt index 18ba9afd..3462804f 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt @@ -16,8 +16,10 @@ data class GoogleCalendarItemId( override val type: CalendarType = GoogleCalendar.Type - override fun toQueryParamStr(): String = - "$calendarId,$itemId" + override fun toMap(): Map = mapOf( + "cid" to calendarId, + "eid" to itemId + ) } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt index d650303c..fe730e7d 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt @@ -76,7 +76,10 @@ class GoogleCalendarsClient( val getCalendarsListRequest = service.CalendarList().list() val calendarListDto = tryExecute { getCalendarsListRequest.execute() } - .getOrElse { return failure(it) } + .getOrElse { + log.warn("Failed to fetch calendars for therapist {} using {}", therapist, account, it) + return failure(it) + } val calendarsList = calendarListDto.items.map { GoogleCalendar(therapist, it.id, it.summary) diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt index c4c91c07..ad43d790 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt @@ -4,12 +4,16 @@ import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import org.apache.tomcat.util.threads.VirtualThreadExecutor +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import pro.azhidkov.platform.java.time.Interval import pro.azhidkov.platform.java.time.zoneId import pro.azhidkov.platform.kotlin.tryExecute import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref -import pro.qyoga.core.calendar.api.* +import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarType +import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef import java.time.ZonedDateTime import java.util.* @@ -79,10 +83,12 @@ class GoogleCalendarsService( private val googleCalendarsClient: GoogleCalendarsClient, ) : CalendarsService { - override val type: CalendarType = GoogleCalendar.Type + private val log = LoggerFactory.getLogger(javaClass) private val executor = VirtualThreadExecutor("google-calendar-events-fetcher") + override val type: CalendarType = GoogleCalendar.Type + fun addGoogleAccount(googleAccount: GoogleAccount) { googleAccountsDao.addGoogleAccount(googleAccount) } @@ -134,7 +140,12 @@ class GoogleCalendarsService( } val events = calendarEventsResults - .mapNotNull { it.getOrNull() } + .mapNotNull { + if (it.isFailure) { + log.warn("Failed to fetch events for account", it.exceptionOrNull()) + } + it.getOrNull() + } .flatMap { it } .map { it.toLocalizedCalendarItem(interval.zoneId) } @@ -149,8 +160,9 @@ class GoogleCalendarsService( return googleCalendarsClient.findById(account, eventId) } - override fun parseStringId(sourceItem: SourceItem): GoogleCalendarItemId = - sourceItem.googleEventId() + override fun createItemId(itemId: Map): GoogleCalendarItemId { + return GoogleCalendarItemId(itemId["cid"]!!, itemId["eid"]!!) + } fun updateCalendarSettings( therapist: TherapistRef, @@ -162,10 +174,3 @@ class GoogleCalendarsService( } } - -fun SourceItem.googleEventId(): GoogleCalendarItemId { - check(type == GoogleCalendar.Type.name) - val matcher = "(.+),(.+)".toRegex().matchEntire(id) - check(matcher != null) - return GoogleCalendarItemId(matcher.groups[1]!!.value, matcher.groups[2]!!.value) -} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt index a686b5df..a346f2a1 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt @@ -4,7 +4,10 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import pro.azhidkov.platform.java.time.Interval -import pro.qyoga.core.calendar.api.* +import pro.qyoga.core.calendar.api.CalendarItem +import pro.qyoga.core.calendar.api.CalendarType +import pro.qyoga.core.calendar.api.CalendarsService +import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef import pro.qyoga.i9ns.calendars.ical.commands.CreateICalRq import pro.qyoga.i9ns.calendars.ical.commands.createFrom @@ -52,9 +55,9 @@ class ICalCalendarsRepo( ?.toICalCalendarItem() } - override fun parseStringId(sourceItem: SourceItem): ICalEventId = - sourceItem.icsEventId() - + override fun createItemId(itemId: Map): ICalEventId { + return ICalEventId(itemId["uid"]!!, itemId["rid"]) + } @Scheduled(cron = "0 */10 * * * *") fun sync() { @@ -71,12 +74,3 @@ private fun ICalCalendar.localizedICalCalendarItemsIn( ): List = (this.calendarItemsIn(interval) ?: emptyList()) .map(ICalCalendarItem::toLocalizedICalCalendarItem) - -fun SourceItem.icsEventId(): ICalEventId { - check(type == ICalCalendar.Type.name) - val matcher = "uid=(.+),rid=(.*)".toRegex().matchEntire(id) - check(matcher != null) - val uid = matcher.groups[1]!!.value - val rid = matcher.groups[2]!!.value.takeIf { it.isNotBlank() } - return ICalEventId(uid, rid) -} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt index dd9f594d..c844724a 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalEventId.kt @@ -11,7 +11,9 @@ data class ICalEventId( override val type: CalendarType = ICalCalendar.Type - override fun toQueryParamStr(): String = - "uid=${uid},rid=${recurrenceId ?: ""}" + override fun toMap(): Map = mapOf( + "uid" to uid, + "rid" to recurrenceId + ) } diff --git a/app/src/main/resources/application-local-dev.yaml b/app/src/main/resources/application-local-dev.yaml index 4808cc98..815f7c2c 100644 --- a/app/src/main/resources/application-local-dev.yaml +++ b/app/src/main/resources/application-local-dev.yaml @@ -41,4 +41,4 @@ management: logging: level: - org.springframework.jdbc.core.JdbcTemplate: TRACE \ No newline at end of file + org.springframework.jdbc.core.JdbcTemplate: INFO diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 8a1fbfb2..18cc7147 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -7,7 +7,6 @@ import org.springframework.http.HttpStatus import pro.azhidkov.platform.java.time.toLocalTimeString import pro.azhidkov.platform.spring.sdj.ergo.hydration.ref import pro.qyoga.app.therapist.appointments.core.edit.forms.formatCommentFor -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.assertions.shouldBePage import pro.qyoga.tests.assertions.shouldHave @@ -167,11 +166,11 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { // Действие val document = theTherapist.appointments.getCreateAppointmentPage( dateTime = event.dateTime.toLocalDateTime(), - sourceItem = SourceItem(event.id) + sourceItem = event.id.toUri() ) // Проверка - CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toQueryParamStr() + CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toUri().toString() CreateAppointmentForm.dateTime.value(document) shouldBe event.dateTime.toLocalDateTime().toString() CreateAppointmentForm.timeZone.value(document) shouldBe event.dateTime.zone.id CreateAppointmentForm.duration.value(document) shouldBe event.duration.toLocalTimeString() @@ -190,11 +189,11 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { // Действие val document = theTherapist.appointments.getCreateAppointmentPage( dateTime = event.dateTime.toLocalDateTime(), - sourceItem = SourceItem(event.id) + sourceItem = event.id.toUri() ) // Проверка - CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toQueryParamStr() + CreateAppointmentForm.externalIdInput.value(document) shouldBe event.id.toUri().toString() CreateAppointmentForm.dateTime.value(document) shouldBe event.dateTime.toLocalDateTime().toString() CreateAppointmentForm.timeZone.value(document) shouldBe asiaNovosibirskTimeZone.id CreateAppointmentForm.duration.value(document) shouldBe event.duration.toLocalTimeString() diff --git a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt index d97dcb99..9a656bbb 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/clients/api/TherapistAppointmentsApi.kt @@ -18,9 +18,9 @@ import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.AppointmentRef -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage import pro.qyoga.tests.pages.therapist.appointments.EditAppointmentPage +import java.net.URI import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -50,7 +50,7 @@ class TherapistAppointmentsApi( fun getCreateAppointmentPage( dateTime: LocalDateTime? = null, - sourceItem: SourceItem? = null + sourceItem: URI? = null ): Document { return Given { authorized() @@ -58,8 +58,7 @@ class TherapistAppointmentsApi( queryParam(CreateAppointmentPageController.DATE_TIME, dateTime.toString()) } if (sourceItem != null) { - queryParam(CreateAppointmentPageController.SOURCE_ITEM_TYPE, sourceItem.type) - queryParam(CreateAppointmentPageController.SOURCE_ITEM_ID, sourceItem.id) + queryParam(CreateAppointmentPageController.SOURCE_ITEM, sourceItem.toASCIIString()) } this } When { diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index d8eb4ba8..ca4a38dd 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -15,7 +15,6 @@ import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageController import pro.qyoga.app.therapist.appointments.core.schedule.TimeMark import pro.qyoga.core.appointments.core.commands.EditAppointmentRequest import pro.qyoga.core.appointments.core.model.Appointment -import pro.qyoga.core.calendar.api.SourceItem import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.l10n.russianTimeFormat import pro.qyoga.tests.assertions.* @@ -95,7 +94,7 @@ infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { "editAppointmentLink", CreateAppointmentPageController.addFromSourceItemUri( localizedICalCalendarItem.dateTime.toLocalDateTime(), - SourceItem(localizedICalCalendarItem.id) + localizedICalCalendarItem.id.toUri() ), localizedICalCalendarItem.title + " " + russianTimeFormat.format(localizedICalCalendarItem.dateTime) + " - " + russianTimeFormat.format( diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/StringExt.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/StringExt.kt index e113734f..77b0517e 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/StringExt.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/platform/StringExt.kt @@ -9,4 +9,4 @@ fun String.pathToRegex(): String { val queryRegEx = pathRegEx + (pathAndQuery.getOrNull(1)?.replace("\\{.*?}".toRegex(), "(.*)")?.let { "\\?$it" } ?: "") return queryRegEx -} \ No newline at end of file +} From 16af6e50fda6ffda5ab58b0430641dda89b96f94 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 26 Sep 2025 11:59:05 +0700 Subject: [PATCH 31/43] =?UTF-8?q?refactor/qg-253:=20LocalizedICalCalendarI?= =?UTF-8?q?tem=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20ICalCalendar?= =?UTF-8?q?Item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i9ns/calendars/ical/ICalCalendarsRepo.kt | 5 +++-- .../i9ns/calendars/ical/ical4j/VEventExt.kt | 4 ++-- .../i9ns/calendars/ical/model/ICalCalendar.kt | 2 +- .../calendars/ical/model/ICalCalendarItem.kt | 12 +++++----- .../ical/model/LocalizedICalCalendarItem.kt | 15 ------------- .../core/CreateAppointmentPageTest.kt | 14 ++++++------ .../appointments/core/SchedulePageTest.kt | 8 +++++-- .../calendar/ical/ICalCalendarsRepoTest.kt | 3 ++- .../calendars/CalendarsObjectMother.kt | 22 +++++++++++++------ .../ical/ICalCalendarsObjectMother.kt | 5 +++-- .../presets/AppointmentsFixturePresets.kt | 6 ++--- .../presets/ICalsCalendarsFixturePresets.kt | 3 ++- .../therapist/appointments/SchedulePage.kt | 3 ++- 13 files changed, 53 insertions(+), 49 deletions(-) delete mode 100644 app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt index a346f2a1..02242cb5 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ICalCalendarsRepo.kt @@ -15,6 +15,7 @@ import pro.qyoga.i9ns.calendars.ical.ical4j.toICalCalendarItem import pro.qyoga.i9ns.calendars.ical.model.* import pro.qyoga.i9ns.calendars.ical.persistance.ICalCalendarsDao import pro.qyoga.i9ns.calendars.ical.persistance.findAllByOwner +import java.time.LocalDateTime import java.time.ZonedDateTime @@ -71,6 +72,6 @@ class ICalCalendarsRepo( private fun ICalCalendar.localizedICalCalendarItemsIn( interval: Interval, -): List = +): List> = (this.calendarItemsIn(interval) ?: emptyList()) - .map(ICalCalendarItem::toLocalizedICalCalendarItem) + .map { it.toLocalizedICalCalendarItem() } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt index 980a9b76..15ce627e 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/ical4j/VEventExt.kt @@ -55,10 +55,10 @@ val VEvent.geographicsPosOrNull: String? val VEvent.locationOrNull: String? get() = this.location?.value -fun VEvent.toICalCalendarItem(): ICalCalendarItem = +fun VEvent.toICalCalendarItem(): ICalCalendarItem = toICalCalendarItem(Period(startDateTime, startDateTime + javaDuration)) -fun VEvent.toICalCalendarItem(period: Period): ICalCalendarItem = ICalCalendarItem( +fun VEvent.toICalCalendarItem(period: Period): ICalCalendarItem = ICalCalendarItem( id, title, descriptionOrNull ?: "", diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt index 0b2d0466..72cbadd2 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendar.kt @@ -62,7 +62,7 @@ fun ICalCalendar.findById(eventId: ICalEventId) = fun ICalCalendar.calendarItemsIn( interval: Interval -): List? = +): List>? = vEvents() ?.flatMap { ve: VEvent -> ve.calculateRecurrenceSet(interval.toICalPeriod()) diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt index 186d3e87..288fffc2 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/ICalCalendarItem.kt @@ -2,20 +2,22 @@ package pro.qyoga.i9ns.calendars.ical.model import pro.qyoga.core.calendar.api.CalendarItem import java.time.Duration +import java.time.LocalDateTime import java.time.ZonedDateTime +import java.time.temporal.Temporal -data class ICalCalendarItem( +data class ICalCalendarItem( override val id: ICalEventId, override val title: String, override val description: String, - override val dateTime: ZonedDateTime, + override val dateTime: DATE, override val duration: Duration, override val location: String? -) : CalendarItem +) : CalendarItem -fun ICalCalendarItem.toLocalizedICalCalendarItem(): LocalizedICalCalendarItem = - LocalizedICalCalendarItem( +fun ICalCalendarItem.toLocalizedICalCalendarItem(): ICalCalendarItem = + ICalCalendarItem( this.id, this.title, this.description, diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt deleted file mode 100644 index 7b95d5b4..00000000 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/ical/model/LocalizedICalCalendarItem.kt +++ /dev/null @@ -1,15 +0,0 @@ -package pro.qyoga.i9ns.calendars.ical.model - -import pro.qyoga.core.calendar.api.CalendarItem -import java.time.Duration -import java.time.LocalDateTime - - -data class LocalizedICalCalendarItem( - override val id: ICalEventId, - override val title: String, - override val description: String, - override val dateTime: LocalDateTime, - override val duration: Duration, - override val location: String? -) : CalendarItem diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt index 18cc7147..52613552 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/CreateAppointmentPageTest.kt @@ -28,10 +28,7 @@ import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentForm import pro.qyoga.tests.pages.therapist.appointments.CreateAppointmentPage import pro.qyoga.tests.pages.therapist.appointments.EditAppointmentForm import pro.qyoga.tests.platform.instancio.KSelect.Companion.field -import java.time.Duration -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId +import java.time.* import java.time.temporal.ChronoUnit import java.util.* @@ -157,9 +154,12 @@ class CreateAppointmentPageTest : QYogaAppIntegrationBaseTest() { fun createAppointmentWithIcsEventId() { // Сетап val event = aCalendarItem { - set(field(ICalCalendarItem::dateTime), randomAppointmentDate().atZone(asiaNovosibirskTimeZone)) - set(field(ICalCalendarItem::duration), Duration.ofMinutes(75)) - set(field(ICalCalendarItem::description), randomSentence()) + set( + field(ICalCalendarItem::dateTime), + randomAppointmentDate().atZone(asiaNovosibirskTimeZone) + ) + set(field(ICalCalendarItem::duration), Duration.ofMinutes(75)) + set(field(ICalCalendarItem::description), randomSentence()) } iCalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt index 939d9cd1..7b74f246 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/app/therapist/appointments/core/SchedulePageTest.kt @@ -24,6 +24,7 @@ import pro.qyoga.tests.pages.therapist.appointments.appointmentCards import pro.qyoga.tests.pages.therapist.appointments.shouldMatch import pro.qyoga.tests.platform.instancio.KSelect.Companion.field import java.time.LocalDate +import java.time.ZonedDateTime @DisplayName("Страница календаря") @@ -115,8 +116,11 @@ class SchedulePageTest : QYogaAppIntegrationBaseTest() { // Сетап val today = LocalDate.now() val event = aCalendarItem { - set(field(ICalCalendarItem::dateTime), today.atTime(randomWorkingTime()).atZone(asiaNovosibirskTimeZone)) - set(field(ICalCalendarItem::duration), AppointmentsObjectMother.fullCardDuration) + set( + field(ICalCalendarItem::dateTime), + today.atTime(randomWorkingTime()).atZone(asiaNovosibirskTimeZone) + ) + set(field(ICalCalendarItem::duration), AppointmentsObjectMother.fullCardDuration) } ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(event) diff --git a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt index 29d38660..0b7887fb 100644 --- a/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt +++ b/app/src/test/kotlin/pro/qyoga/tests/cases/core/calendar/ical/ICalCalendarsRepoTest.kt @@ -11,6 +11,7 @@ import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import pro.qyoga.tests.fixture.presets.ICalsCalendarsFixturePresets import pro.qyoga.tests.infra.web.QYogaAppBaseKoTest import pro.qyoga.tests.platform.instancio.KSelect.Companion.field +import java.time.ZonedDateTime @DisplayName("Репозиторий ical-календарей") @@ -25,7 +26,7 @@ class ICalCalendarsRepoTest : QYogaAppBaseKoTest({ val iCalEvent = aCalendarItem() val iCal = getBean().createICalCalendarWithSingleEvent(iCalEvent) val updatedEvent = aCalendarItem { - set(field(ICalCalendarItem::id), iCalEvent.id) + set(field(ICalCalendarItem::id), iCalEvent.id) } getBean().updateICalSource( iCal.icsUrl, diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt index 01a17b3c..ce16b826 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/CalendarsObjectMother.kt @@ -2,6 +2,7 @@ package pro.qyoga.tests.fixture.object_mothers.calendars import org.instancio.Instancio import org.instancio.InstancioApi +import org.instancio.Model import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.i9ns.calendars.ical.model.ICalEventId import pro.qyoga.tests.fixture.data.asiaNovosibirskTimeZone @@ -11,23 +12,30 @@ import pro.qyoga.tests.fixture.object_mothers.appointments.AppointmentsObjectMot import pro.qyoga.tests.fixture.object_mothers.appointments.randomAppointmentDate import pro.qyoga.tests.platform.instancio.KSelect.Companion.field import pro.qyoga.tests.platform.instancio.generateBy +import java.time.ZonedDateTime object CalendarsObjectMother { - private val aCalendarItemModel = Instancio.of(ICalCalendarItem::class.java) + @Suppress("UNCHECKED_CAST") + private val aCalendarItemModel: Model> = Instancio.of(ICalCalendarItem::class.java) + .withTypeParameters(ZonedDateTime::class.java) .run { - generateBy(field(ICalCalendarItem::id)) { aICalEventId() } - generateBy(field(ICalCalendarItem::title)) { aAppointmentEventTitle() } - generateBy(field(ICalCalendarItem::dateTime)) { randomAppointmentDate().atZone(asiaNovosibirskTimeZone) } - generateBy(field(ICalCalendarItem::duration)) { AppointmentsObjectMother.fullCardDuration } + generateBy(field(ICalCalendarItem::id)) { aICalEventId() } + generateBy(field(ICalCalendarItem::title)) { aAppointmentEventTitle() } + generateBy(field(ICalCalendarItem::dateTime)) { + randomAppointmentDate().atZone( + asiaNovosibirskTimeZone + ) + } + generateBy(field(ICalCalendarItem::duration)) { AppointmentsObjectMother.fullCardDuration } } .withSeed(faker.random().nextLong()) - .toModel() + .toModel() as Model> fun aICalEventId() = ICalEventId(faker.internet().uuid().toString()) - fun aCalendarItem(configureInstance: InstancioApi.() -> InstancioApi = { this }): ICalCalendarItem { + fun aCalendarItem(configureInstance: InstancioApi>.() -> InstancioApi> = { this }): ICalCalendarItem { return Instancio.of(aCalendarItemModel).run { this.configureInstance() } diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt index f8120aa8..4ee3d01f 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/object_mothers/calendars/ical/ICalCalendarsObjectMother.kt @@ -16,6 +16,7 @@ import pro.qyoga.tests.fixture.object_mothers.calendars.CalendarsObjectMother import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF import java.io.StringWriter import java.net.URI +import java.time.ZonedDateTime import java.time.temporal.Temporal @@ -30,14 +31,14 @@ object ICalCalendarsObjectMother { ) } - fun aIcsFile(event: ICalCalendarItem): String { + fun aIcsFile(event: ICalCalendarItem): String { val calendar = aICalCalendar(event) val icsFile = StringWriter() CalendarOutputter().output(calendar, icsFile) return icsFile.toString() } - fun aICalCalendar(event: ICalCalendarItem): Calendar { + fun aICalCalendar(event: ICalCalendarItem): Calendar { val calendar = Calendar() .withProdId("-//azhidkov.pro//Trainer Advisor Tests//RU") .withDefaults() diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt index 71689ad5..757accb5 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/AppointmentsFixturePresets.kt @@ -14,13 +14,13 @@ import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF class AppointmentsFixturePresets( private val appointmentsBackgrounds: AppointmentsBackgrounds, private val clientBackgrounds: ClientsBackgrounds, - private val ICalsCalendarsFixturePresets: ICalsCalendarsFixturePresets + private val iCalsCalendarsFixturePresets: ICalsCalendarsFixturePresets ) { fun createAppointmentFromIcsEvent(): Appointment { val client = clientBackgrounds.aClient() val icsEvent = aCalendarItem() - ICalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(icsEvent) + iCalsCalendarsFixturePresets.createICalCalendarWithSingleEvent(icsEvent) val app = appointmentsBackgrounds.create( randomEditAppointmentRequest( client = client.ref(), @@ -33,4 +33,4 @@ class AppointmentsFixturePresets( return app } -} \ No newline at end of file +} diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt index 027689ad..9087368b 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/fixture/presets/ICalsCalendarsFixturePresets.kt @@ -6,6 +6,7 @@ import pro.qyoga.i9ns.calendars.ical.model.ICalCalendarItem import pro.qyoga.tests.fixture.backgrounds.ICalCalendarsBackgrounds import pro.qyoga.tests.fixture.object_mothers.calendars.ical.ICalCalendarsObjectMother.aIcsFile import pro.qyoga.tests.fixture.object_mothers.therapists.THE_THERAPIST_REF +import java.time.ZonedDateTime @Component @@ -14,7 +15,7 @@ class ICalsCalendarsFixturePresets( ) { fun createICalCalendarWithSingleEvent( - event: ICalCalendarItem, + event: ICalCalendarItem, ): ICalCalendar { val icsFile = aIcsFile(event) diff --git a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt index ca4a38dd..cba7018b 100644 --- a/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt +++ b/app/src/testFixtures/kotlin/pro/qyoga/tests/pages/therapist/appointments/SchedulePage.kt @@ -21,6 +21,7 @@ import pro.qyoga.tests.assertions.* import pro.qyoga.tests.pages.therapist.appointments.CalendarPage.APPOINTMENT_CARD_SELECTOR import pro.qyoga.tests.platform.html.* import java.time.LocalTime +import java.time.ZonedDateTime object CalendarPage : HtmlPage { @@ -89,7 +90,7 @@ infix fun Element.shouldMatch(app: EditAppointmentRequest) { .single() shouldHaveClass AppointmentCard.appointmentStatusClasses[app.appointmentStatus]!! } -infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { +infix fun Element.shouldMatch(localizedICalCalendarItem: ICalCalendarItem) { this shouldHaveComponent Link( "editAppointmentLink", CreateAppointmentPageController.addFromSourceItemUri( From 0b9aca44682eecadcbbaf94a963efdcbb269cfd0 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 26 Sep 2025 16:56:21 +0700 Subject: [PATCH 32/43] =?UTF-8?q?feat/qg-253:=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы пройти верификацию в гугле, чтобы токены не протухали за неделю --- .../pro/qyoga/app/infra/WebSecurityConfig.kt | 3 +- .../main/resources/static/privacy-policy.html | 158 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/src/main/resources/static/privacy-policy.html diff --git a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt index f26cf796..8d723273 100644 --- a/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt +++ b/app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt @@ -56,6 +56,7 @@ class WebSecurityConfig( HttpMethod.GET, "/", "/offline.html", + "/privacy-policy.html", "/manifest.json", "/register", "/oauth2/**", @@ -101,7 +102,7 @@ class WebSecurityConfig( @Bean fun tokenRepository(): PersistentTokenRepository { val jdbcTokenRepositoryImpl = JdbcTokenRepositoryImpl() - jdbcTokenRepositoryImpl.setDataSource(dataSource) + jdbcTokenRepositoryImpl.dataSource = dataSource return jdbcTokenRepositoryImpl } diff --git a/app/src/main/resources/static/privacy-policy.html b/app/src/main/resources/static/privacy-policy.html new file mode 100644 index 00000000..a6427ca8 --- /dev/null +++ b/app/src/main/resources/static/privacy-policy.html @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + Trainer Advisor - Политика конфиденциальности + + + +
+
+
+

Политика конфиденциальности

+

Дата вступления в силу: 26 сентября 2025

+ +

+ Настоящая Политика конфиденциальности описывает, как сервис Trainer Advisor + (далее — «Сервис», «мы», «нас», «наш») собирает, использует, хранит и передаёт информацию о + пользователях. +

+ +

1. Кто мы

+

+ Оператор данных: Trainer Advisor. + Контактный адрес для вопросов по обработке персональных данных: ta@azhidkov.pro. +

+ +

2. Какие данные мы собираем

+

В зависимости от того, как вы используете Сервис, мы можем обрабатывать следующие категории данных:

+
    +
  • Данные учётной записи: имя, адрес электронной почты, идентификатор аккаунта.
  • +
  • Данные аутентификации: токены доступа/обновления, выданные провайдерами (например, + Google), метаданные сессий. +
  • +
  • Данные использования: технические лог‑данные, события в приложении, тип устройства, + версия браузера, IP-адрес, дата и время запросов. +
  • +
  • Контент, который вы предоставляете в рамках использования функциональности Сервиса. +
  • +
+ +

3. Источники данных

+
    +
  • Непосредственно от вас при регистрации и использовании Сервиса.
  • +
  • От сторонних провайдеров идентификации (например, Google) при вашем явном согласии на авторизацию + через их аккаунт. +
  • +
  • Автоматически при взаимодействии с нашим веб‑сайтом и приложениями (cookies, журналы событий).
  • +
+ +

4. Цели и правовые основания обработки

+
    +
  • Предоставление функциональности Сервиса (исполнение договора с пользователем).
  • +
  • Аутентификация и безопасность (наш законный интерес в обеспечении безопасности + учётных записей и инфраструктуры). +
  • +
+ +

5. Использование данных Google и «Limited Use»

+

+ Если вы входите через Google или предоставляете доступ к данным Google, мы соблюдаем Политику использования данных пользователей API Google, включая требования + «Limited Use». +

+
    +
  • Мы используем данные Google строго для предоставления заявленной функциональности пользователю.
  • +
  • Мы не передаём данные Google третьим лицам и не используем их для рекламы без вашего явного + согласия. +
  • +
  • Доступ к данным ограничен минимально необходимым и предоставляется только системам/сотрудникам при + необходимости. +
  • +
  • Мы не используем данные Google для создания профилей вне заявленных целей.
  • +
+ +

6. Файлы cookie и аналогичные технологии

+

+ Мы используем строго необходимые cookies для обеспечения работы Сервиса. + Вы можете управлять cookies через настройки браузера. +

+ +

7. Передача и совместное использование данных

+

+ Мы не продаём ваши персональные данные. Мы можем передавать данные следующим категориям получателей при + наличии оснований: +

+
    +
  • Провайдерам облачной инфраструктуры, мониторинга, аналитики и уведомлений (как операторам по + поручению) — на основании договоров обработки данных. +
  • +
  • Компетентным государственным органам — если это требуется законом.
  • +
  • Правопреемникам в случае реорганизации/слияния при соблюдении применимых требований.
  • +
+ +

8. Сроки хранения

+

+ Мы храним персональные данные только столько, сколько необходимо для целей, для которых они были + собраны, и/или в сроки, требуемые законом. + По удалению учётной записи ваши персональные данные будут удалены или анонимизированы в разумный срок, + за исключением данных, которые мы обязаны хранить дольше в силу закона. +

+ +

9. Безопасность

+

+ Мы применяем организационные и технические меры защиты, включая шифрование при передаче, контроль + доступа, аудит и журналирование. + Однако ни один метод передачи данных через Интернет или хранения не может быть абсолютно безопасным. +

+ +

10. Ваши права

+

+ В зависимости от юрисдикции вам могут быть доступны следующие права: доступ к данным, исправление, + удаление, ограничение обработки, возражение против обработки, переносимость данных, отзыв согласия. + Чтобы реализовать права, свяжитесь с нами по адресу ta@azhidkov.pro. +

+ +

11. Детская аудитория

+

+ Сервис не предназначен для лиц младше 18 лет. + Мы не собираем умышленно персональные данные детей. + Если вы считаете, что ребёнок предоставил нам данные, свяжитесь с нами для удаления таких данных. +

+ +

12. Изменения настоящей политики

+

+ Мы можем периодически обновлять Политику. + Актуальная версия всегда доступна на этой странице. + При существенных изменениях мы уведомим пользователей через интерфейс Сервиса или иными доступными + способами. +

+ +

13. Контакты

+

+ По вопросам конфиденциальности свяжитесь с нами: +

+ + +
+
+
+ + + From 427f4067b82361d08f8aed66526c2545f9fbf51d Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Fri, 26 Sep 2025 18:13:05 +0700 Subject: [PATCH 33/43] =?UTF-8?q?feat/qg-253:=20=D0=BD=D0=B0=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D1=83=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы пройти верификацию в гугле, чтобы токены не протухали за неделю --- app/src/main/resources/templates/public/landing.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/resources/templates/public/landing.html b/app/src/main/resources/templates/public/landing.html index f28ab3f0..5271ec05 100644 --- a/app/src/main/resources/templates/public/landing.html +++ b/app/src/main/resources/templates/public/landing.html @@ -94,8 +94,17 @@

Расписание занятий

+ + +
- \ No newline at end of file + From 9efe983fbceb0b2d937efdd06b8e338dbc4b76c3 Mon Sep 17 00:00:00 2001 From: Aleksey Zhidkov Date: Sun, 28 Sep 2025 14:55:35 +0700 Subject: [PATCH 34/43] =?UTF-8?q?refactor/qg-253:=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8=D1=85=20=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D1=85,=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=BD=D0=B5=D0=B9=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=D1=8B=D1=85=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../azhidkov/platform/secrets/SecretChars.kt | 4 +- .../sdj/converters/SecretCharsConverters.kt | 2 +- .../core/schedule/GetCalendarAppointments.kt | 4 +- .../GoogleCalendarSettingsController.kt | 16 +--- .../GoogleCalendarSettingsPageModel.kt | 15 ++++ .../core/schedule/SchedulePageController.kt | 10 +-- ...endarPageModel.kt => SchedulePageModel.kt} | 24 +++--- .../oauth2/GoogleCallbackController.kt | 4 +- .../appointments/core/AppointmentsRepo.kt | 7 +- .../calendars/google/GoogleCalendarsRepo.kt | 18 ---- .../google/GoogleCalendarsService.kt | 86 +++++-------------- .../{ => client}/GoogleCalendarsClient.kt | 21 +++-- .../google/{ => model}/GoogleAccount.kt | 2 +- .../google/{ => model}/GoogleCalendar.kt | 6 +- .../google/{ => model}/GoogleCalendarItem.kt | 2 +- .../google/model/GoogleCalendarSettings.kt | 10 +++ .../{ => persistance}/GoogleAccountsDao.kt | 6 +- .../{ => persistance}/GoogleCalendarsDao.kt | 14 ++- .../GoogleAccountCalendarsSettingsView.kt | 73 ++++++++++++++++ app/src/main/resources/application.yaml | 6 +- .../V25091101__add_google_calendars.sql | 2 +- .../google-settings-component.html | 9 +- .../therapist/appointments/schedule.html | 27 +++--- .../core/CalendarPageModelTest.kt | 36 ++++---- .../core/SchedulePageControllerTest.kt | 4 +- .../GetGoogleCalendarsSettingsEndpointTest.kt | 10 +-- .../GoogleAuthorizationIntegrationTest.kt | 6 +- .../google/SetCalendarShouldBeShownTest.kt | 4 +- .../clients/api/TherapistAppointmentsApi.kt | 4 +- .../TherapistGoogleCalendarIntegrationApi.kt | 2 +- .../google/GoogleCalendarObjectMother.kt | 2 +- .../clients/ClientsObjectMother.kt | 5 +- .../presets/GoogleCalendarFixturePresets.kt | 4 +- .../fixture/presets/ScheduleFixturePreset.kt | 11 +-- .../test_apis/GoogleCalendarTestApi.kt | 8 +- .../fixture/wiremocks/MockGoogleCalendar.kt | 11 +-- .../GoogleCalendarSettingsComponent.kt | 10 +-- .../therapist/appointments/SchedulePage.kt | 8 +- .../pro/qyoga/tests/platform/html/Link.kt | 6 +- 39 files changed, 262 insertions(+), 237 deletions(-) create mode 100644 app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsPageModel.kt rename app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/{CalendarPageModel.kt => SchedulePageModel.kt} (91%) delete mode 100644 app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => client}/GoogleCalendarsClient.kt (86%) rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => model}/GoogleAccount.kt (95%) rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => model}/GoogleCalendar.kt (76%) rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => model}/GoogleCalendarItem.kt (96%) create mode 100644 app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarSettings.kt rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => persistance}/GoogleAccountsDao.kt (86%) rename app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/{ => persistance}/GoogleCalendarsDao.kt (83%) create mode 100644 app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/views/GoogleAccountCalendarsSettingsView.kt diff --git a/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt b/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt index c13f4b75..5b392bb6 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/secrets/SecretChars.kt @@ -1,6 +1,8 @@ package pro.azhidkov.platform.secrets -data class SecretChars(val value: CharArray) { +data class SecretChars( + private val value: CharArray +) { fun show() = String(value) diff --git a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt index 0e6c2991..8b2a1539 100644 --- a/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt +++ b/app/src/main/kotlin/pro/azhidkov/platform/spring/sdj/converters/SecretCharsConverters.kt @@ -7,7 +7,7 @@ import pro.azhidkov.platform.secrets.SecretChars @WritingConverter class SecretCharsToString : Converter { - override fun convert(source: SecretChars) = String(source.value) + override fun convert(source: SecretChars) = source.show() } @ReadingConverter diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt index e328c520..7ac7a9f3 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GetCalendarAppointments.kt @@ -51,8 +51,8 @@ private fun calendarIntervalAround( date: LocalDate, currentUserTimeZone: ZoneId ): Interval { - val from = date.minusDays((CalendarPageModel.DAYS_IN_CALENDAR / 2).toLong()).atStartOfDay(currentUserTimeZone) - return Interval.of(from, Duration.ofDays(CalendarPageModel.DAYS_IN_CALENDAR.toLong())) + val from = date.minusDays((SchedulePageModel.DAYS_IN_CALENDAR / 2).toLong()).atStartOfDay(currentUserTimeZone) + return Interval.of(from, Duration.ofDays(SchedulePageModel.DAYS_IN_CALENDAR.toLong())) } private fun Result>.items(): Iterable> = diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt index 29b106dc..038acccf 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsController.kt @@ -7,20 +7,8 @@ import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.ModelAndView import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.ref -import pro.qyoga.i9ns.calendars.google.GoogleAccountCalendarsView -import pro.qyoga.i9ns.calendars.google.GoogleAccountRef import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService - -data class GoogleCalendarSettingsPageModel( - val accounts: List -) : ModelAndView("therapist/appointments/google-settings-component.html") { - - init { - addObject("accounts", accounts) - addObject("hasAccounts", accounts.isNotEmpty()) - } - -} +import pro.qyoga.i9ns.calendars.google.model.GoogleAccountRef @Controller class GoogleCalendarSettingsController( @@ -55,7 +43,7 @@ class GoogleCalendarSettingsController( fun updateCalendarSettingsPath(googleAccount: GoogleAccountRef, calendarId: String): String = UPDATE_CALENDAR_SETTINGS_PATH - .replace("{googleAccount}", googleAccount.id.toString()) + .replace("{googleAccount}", googleAccount.id?.toString() ?: "") .replace("{calendarId}", calendarId) } diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsPageModel.kt new file mode 100644 index 00000000..f2ad5370 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/GoogleCalendarSettingsPageModel.kt @@ -0,0 +1,15 @@ +package pro.qyoga.app.therapist.appointments.core.schedule + +import org.springframework.web.servlet.ModelAndView +import pro.qyoga.i9ns.calendars.google.views.GoogleAccountCalendarsSettingsView + +data class GoogleCalendarSettingsPageModel( + val accounts: List +) : ModelAndView("therapist/appointments/google-settings-component.html") { + + init { + addObject("accounts", accounts) + addObject("hasAccounts", accounts.isNotEmpty()) + } + +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageController.kt index 6c54cef1..beed0f01 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageController.kt @@ -23,21 +23,21 @@ class SchedulePageController( @RequestParam(DATE) date: LocalDate = LocalDate.now(), @RequestParam(FOCUSED_APPOINTMENT) focusedAppointment: UUID? = null, @AuthenticationPrincipal therapist: QyogaUserDetails - ): CalendarPageModel { + ): SchedulePageModel { val appointments = getCalendarAppointments(therapist.ref, date) - return CalendarPageModel.of(date, appointments, focusedAppointment) + return SchedulePageModel.of(date, appointments, focusedAppointment) } companion object { const val PATH = "/therapist/schedule" const val DATE = "date" - const val FOCUSED_APPOINTMENT = CalendarPageModel.FOCUSED_APPOINTMENT + const val FOCUSED_APPOINTMENT = SchedulePageModel.FOCUSED_APPOINTMENT const val DATE_PATH = "$PATH?$DATE={$DATE}" const val DATE_APPOINTMENT_PATH = "$PATH?$DATE={$DATE}&$FOCUSED_APPOINTMENT={$FOCUSED_APPOINTMENT}" fun calendarForDateUrl(date: LocalDate) = DATE_PATH.replace("{$DATE}", date.toString()) fun calendarForDayWithFocus(date: LocalDate, appointmentRef: AppointmentRef) = DATE_APPOINTMENT_PATH .replace("{$DATE}", date.toString()) - .replace("{$FOCUSED_APPOINTMENT}", appointmentRef.id.toString()) + .replace("{$FOCUSED_APPOINTMENT}", appointmentRef.id?.toString() ?: "") } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageModel.kt similarity index 91% rename from app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt rename to app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageModel.kt index 435a432c..538f4481 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/CalendarPageModel.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/appointments/core/schedule/SchedulePageModel.kt @@ -9,10 +9,10 @@ import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssCla import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.CLIENT_DO_NOT_CAME_CARD import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.DRAFT_CARD import pro.qyoga.app.therapist.appointments.core.schedule.AppointmentCard.CssClasses.PENDING_CARD -import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Companion.DAYS_IN_CALENDAR -import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Companion.DAYS_IN_WEEK -import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Companion.DEFAULT_END_HOUR -import pro.qyoga.app.therapist.appointments.core.schedule.CalendarPageModel.Companion.DEFAULT_START_HOUR +import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageModel.Companion.DAYS_IN_CALENDAR +import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageModel.Companion.DAYS_IN_WEEK +import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageModel.Companion.DEFAULT_END_HOUR +import pro.qyoga.app.therapist.appointments.core.schedule.SchedulePageModel.Companion.DEFAULT_START_HOUR import pro.qyoga.core.appointments.core.model.AppointmentStatus import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary import pro.qyoga.core.calendar.api.CalendarItem @@ -39,12 +39,12 @@ import java.util.* * Контрол быстрого выбора дня состоит из строки с 7 днями - выбранный день +/- 3 дня * */ -data class CalendarPageModel( +data class SchedulePageModel( val date: LocalDate, val timeMarks: List, - val calendarDays: Collection, - val appointmentToFocus: UUID?, - val hasSyncErrors: Boolean + private val calendarDays: Collection, + private val appointmentToFocus: UUID?, + private val hasSyncErrors: Boolean ) : ModelAndView("therapist/appointments/schedule.html") { init { @@ -60,12 +60,12 @@ data class CalendarPageModel( fun of( date: LocalDate, - appointments: GetCalendarAppointmentsRs, + getAppointments: GetCalendarAppointmentsRs, appointmentToFocus: UUID? = null - ): CalendarPageModel { - val timeMarks = generateTimeMarks(appointments.appointments, date) + ): SchedulePageModel { + val timeMarks = generateTimeMarks(getAppointments.appointments, date) val weekCalendar = generateDaysAround(date) - return CalendarPageModel(date, timeMarks, weekCalendar, appointmentToFocus, appointments.hasErrors) + return SchedulePageModel(date, timeMarks, weekCalendar, appointmentToFocus, getAppointments.hasErrors) } const val FOCUSED_APPOINTMENT = "focusedAppointment" diff --git a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt index b2da203f..69b348dc 100644 --- a/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt +++ b/app/src/main/kotlin/pro/qyoga/app/therapist/oauth2/GoogleCallbackController.kt @@ -10,8 +10,8 @@ import org.springframework.web.client.RestClient import pro.qyoga.core.users.auth.dtos.QyogaUserDetails import pro.qyoga.core.users.therapists.Therapist import pro.qyoga.core.users.therapists.TherapistRef -import pro.qyoga.i9ns.calendars.google.GoogleAccount import pro.qyoga.i9ns.calendars.google.GoogleCalendarsService +import pro.qyoga.i9ns.calendars.google.model.GoogleAccount import java.util.* /** @@ -28,7 +28,6 @@ class GoogleOAuthController( private val googleCalendarsService: GoogleCalendarsService ) { - // Этот endpoint теперь будет работать с oauth2Client @GetMapping(PATH) fun handleOAuthCallback( @RegisteredOAuth2AuthorizedClient("google") authorizedClient: OAuth2AuthorizedClient, @@ -44,7 +43,6 @@ class GoogleOAuthController( .body(Map::class.java) val email = response ?.get("email") as String - val picture = response["picture"] as? String? googleCalendarsService.addGoogleAccount( GoogleAccount(therapistId, email, authorizedClient.refreshToken!!.tokenValue) diff --git a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt index 892ccb0b..cf8e9729 100644 --- a/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt +++ b/app/src/main/kotlin/pro/qyoga/core/appointments/core/AppointmentsRepo.kt @@ -13,11 +13,12 @@ import pro.azhidkov.platform.spring.jdbc.taDataClassRowMapper import pro.azhidkov.platform.spring.sdj.ergo.ErgoRepository import pro.qyoga.core.appointments.core.model.Appointment import pro.qyoga.core.appointments.core.views.LocalizedAppointmentSummary -import pro.qyoga.core.calendar.api.SearchResult +import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.users.therapists.TherapistRef import java.sql.Timestamp import java.time.Duration import java.time.Instant +import java.time.LocalDateTime import java.time.ZonedDateTime import java.util.* @@ -38,7 +39,7 @@ class AppointmentsRepo( fun findCalendarItemsInInterval( therapist: TherapistRef, interval: Interval, - ): SearchResult { + ): Iterable> { @Language("PostgreSQL") val query = """ WITH localized_appointment_summary AS (SELECT @@ -68,7 +69,7 @@ class AppointmentsRepo( "to" to interval.to.toLocalDateTime(), "localTimeZone" to interval.zoneId.id ) - return SearchResult(findAll(query, params, localizedAppointmentSummaryRowMapper)) + return findAll(query, params, localizedAppointmentSummaryRowMapper) } } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt deleted file mode 100644 index 972834ed..00000000 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsRepo.kt +++ /dev/null @@ -1,18 +0,0 @@ -package pro.qyoga.i9ns.calendars.google - -import pro.qyoga.core.users.therapists.TherapistRef - - -class GoogleCalendarsRepo { - - private val repo = HashMap>() - - fun addCalendarsToTherapist(therapist: TherapistRef, calendars: List) { - repo[therapist] = (repo[therapist] ?: emptyList()) + calendars - } - - fun getCalendars(therapist: TherapistRef): List { - return repo[therapist] ?: emptyList() - } - -} diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt index ad43d790..48ef6ba9 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsService.kt @@ -1,8 +1,5 @@ package pro.qyoga.i9ns.calendars.google -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport -import com.google.api.client.http.javanet.NetHttpTransport -import com.google.api.client.json.gson.GsonFactory import org.apache.tomcat.util.threads.VirtualThreadExecutor import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -15,67 +12,19 @@ import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.calendar.api.CalendarsService import pro.qyoga.core.calendar.api.SearchResult import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.client.GoogleCalendarsClient +import pro.qyoga.i9ns.calendars.google.model.GoogleAccount +import pro.qyoga.i9ns.calendars.google.model.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendarItemId +import pro.qyoga.i9ns.calendars.google.persistance.GoogleAccountsDao +import pro.qyoga.i9ns.calendars.google.persistance.GoogleCalendarSettingsPatch +import pro.qyoga.i9ns.calendars.google.persistance.GoogleCalendarsDao +import pro.qyoga.i9ns.calendars.google.views.GoogleAccountCalendarsSettingsView import java.time.ZonedDateTime -import java.util.* import java.util.concurrent.CompletableFuture -const val APPLICATION_NAME = "Trainer Advisor" -val gsonFactory: GsonFactory = GsonFactory.getDefaultInstance() -val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() - -data class GoogleCalendarView( - val id: String, - val title: String, - val shouldBeShown: Boolean -) - -private const val DEFAULT_CALENDAR_VISIBILITY = false - -sealed interface GoogleAccountContentView { - data class Calendars(val calendars: List) : GoogleAccountContentView - data object Error : GoogleAccountContentView - - companion object { - operator fun invoke( - calendars: Result>, - calendarSettings: Map - ): GoogleAccountContentView = - if (calendars.isSuccess) { - Calendars(calendars.getOrThrow().map { - GoogleCalendarView( - it.externalId, - it.name, - calendarSettings[it.externalId]?.shouldBeShown ?: DEFAULT_CALENDAR_VISIBILITY - ) - }) - } else { - Error - } - } -} - -data class GoogleAccountCalendarsView( - val id: UUID, - val email: String, - val content: GoogleAccountContentView -) { - - companion object { - - fun of( - account: GoogleAccount, - calendars: Result>, - calendarSettings: Map - ): GoogleAccountCalendarsView = GoogleAccountCalendarsView( - account.id, - account.email, - GoogleAccountContentView(calendars, calendarSettings) - ) - } - -} - @Service class GoogleCalendarsService( private val googleAccountsDao: GoogleAccountsDao, @@ -95,14 +44,17 @@ class GoogleCalendarsService( fun findGoogleAccountCalendars( therapist: TherapistRef - ): List { + ): List { val accounts = googleAccountsDao.findGoogleAccounts(therapist) - val accountCalendars = accounts.map { - googleCalendarsClient.getAccountCalendars(therapist, it) + + val accountCalendars = accounts.map { acc -> + acc to googleCalendarsClient.getAccountCalendars(therapist, acc) } + val calendarSettings = googleCalendarsDao.findCalendarsSettings(therapist) - return accounts.zip(accountCalendars).map { (account, calendars) -> - GoogleAccountCalendarsView.of(account, calendars, calendarSettings) + + return accountCalendars.map { (account, calendars) -> + GoogleAccountCalendarsSettingsView.of(account, calendars, calendarSettings) } } @@ -156,7 +108,9 @@ class GoogleCalendarsService( therapistRef: TherapistRef, eventId: GoogleCalendarItemId ): CalendarItem? { - val account = googleAccountsDao.findGoogleAccount(therapistRef, eventId.calendarId).first() + // Выбирается список, потому что один терапевт может подключить несколько аккаунтов, между которыми + // подключен один календарь. И для чтения события можно воспользоватья любым из этих аккаунтов + val account = googleAccountsDao.findGoogleAccounts(therapistRef, eventId.calendarId).first() return googleCalendarsClient.findById(account, eventId) } diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/client/GoogleCalendarsClient.kt similarity index 86% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/client/GoogleCalendarsClient.kt index fe730e7d..7bcee029 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsClient.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/client/GoogleCalendarsClient.kt @@ -1,6 +1,9 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.client +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory import com.google.api.client.util.DateTime import com.google.api.services.calendar.Calendar import com.google.api.services.calendar.model.Event @@ -17,6 +20,8 @@ import pro.azhidkov.platform.kotlin.tryExecute import pro.azhidkov.platform.kotlin.tryRecover import pro.qyoga.core.calendar.api.CalendarItem import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.GoogleCalendarConf +import pro.qyoga.i9ns.calendars.google.model.* import java.net.URI import java.time.Duration import java.time.Instant @@ -26,6 +31,10 @@ import kotlin.Result.Companion.failure import kotlin.Result.Companion.success +private const val APPLICATION_NAME = "Trainer Advisor" +private val gsonFactory: GsonFactory = GsonFactory.getDefaultInstance() +private val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() + @Component class GoogleCalendarsClient( private val googleOAuthProps: OAuth2ClientProperties, @@ -35,7 +44,7 @@ class GoogleCalendarsClient( private val log = LoggerFactory.getLogger(javaClass) - private val servicesCache = mutableMapOf() + private val calendarServicesCache = mutableMapOf() .withDefault { createCalendarService(it) } @Cacheable( @@ -49,7 +58,7 @@ class GoogleCalendarsClient( ): List> { log.info("Fetching events in {} for calendar {} using {}", interval, calendarSettings.calendarId, account) - val service = servicesCache.getValue(account) + val service = calendarServicesCache.getValue(account) val events = service.events().list(calendarSettings.calendarId) .setTimeMin(DateTime(interval.from.toInstant().toEpochMilli())) @@ -71,7 +80,7 @@ class GoogleCalendarsClient( account: GoogleAccount ): Result> { log.info("Fetching calendars for therapist {} using {}", therapist, account) - val service = servicesCache.getValue(account) + val service = calendarServicesCache.getValue(account) val getCalendarsListRequest = service.CalendarList().list() @@ -96,7 +105,7 @@ class GoogleCalendarsClient( account: GoogleAccount, eventId: GoogleCalendarItemId ): CalendarItem? { - val service = servicesCache.getValue(account) + val service = calendarServicesCache.getValue(account) val getEventRequest = service.events().get(eventId.calendarId, eventId.itemId) @@ -117,7 +126,7 @@ class GoogleCalendarsClient( val credentials = UserCredentials.newBuilder() .setClientId(googleOAuthProps.registration["google"]!!.clientId) .setClientSecret(googleOAuthProps.registration["google"]!!.clientSecret) - .setRefreshToken(String(account.refreshToken.value)) + .setRefreshToken(account.refreshToken.show()) .setTokenServerUri(tokenUri) .build() diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleAccount.kt similarity index 95% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleAccount.kt index 1315db81..3d9f93af 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccount.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleAccount.kt @@ -1,4 +1,4 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.model import org.springframework.data.annotation.Id import org.springframework.data.jdbc.core.mapping.AggregateReference diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendar.kt similarity index 76% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendar.kt index b4f351bf..74239321 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendar.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendar.kt @@ -1,13 +1,15 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.model import pro.qyoga.core.calendar.api.Calendar import pro.qyoga.core.calendar.api.CalendarType import pro.qyoga.core.users.therapists.TherapistRef +typealias GoogleCalendarId = String + data class GoogleCalendar( override val ownerRef: TherapistRef, - val externalId: String, + val externalId: GoogleCalendarId, override val name: String, ) : Calendar { diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarItem.kt similarity index 96% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarItem.kt index 3462804f..824fb3cb 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarItem.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarItem.kt @@ -1,4 +1,4 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.model import pro.azhidkov.platform.java.time.toLocalDateTime import pro.qyoga.core.calendar.api.CalendarItem diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarSettings.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarSettings.kt new file mode 100644 index 00000000..414ac5b6 --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/model/GoogleCalendarSettings.kt @@ -0,0 +1,10 @@ +package pro.qyoga.i9ns.calendars.google.model + +import pro.qyoga.core.users.therapists.TherapistRef + +data class GoogleCalendarSettings( + val ownerRef: TherapistRef, + val googleAccountRef: GoogleAccountRef, + val calendarId: String, + val shouldBeShown: Boolean, +) diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleAccountsDao.kt similarity index 86% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleAccountsDao.kt index 302428fc..057cdeff 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleAccountsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleAccountsDao.kt @@ -1,4 +1,4 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.persistance import org.springframework.data.jdbc.core.JdbcAggregateTemplate import org.springframework.data.jdbc.core.findAllById @@ -7,6 +7,8 @@ import org.springframework.stereotype.Repository import pro.azhidkov.platform.spring.jdbc.taDataClassRowMapper import pro.azhidkov.platform.spring.sdj.query.query import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.model.GoogleAccount +import pro.qyoga.i9ns.calendars.google.model.GoogleAccountRef private val googleAccountRowMapper = taDataClassRowMapper() @@ -32,7 +34,7 @@ class GoogleAccountsDao( return jdbcAggregateTemplate.findAllById(accountIds.map { it.id }) } - fun findGoogleAccount(therapistRef: TherapistRef, calendarId: String): List { + fun findGoogleAccounts(therapistRef: TherapistRef, calendarId: String): List { val query = """ SELECT * FROM therapist_google_accounts diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleCalendarsDao.kt similarity index 83% rename from app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt rename to app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleCalendarsDao.kt index 040a402c..d93587e1 100644 --- a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/GoogleCalendarsDao.kt +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/persistance/GoogleCalendarsDao.kt @@ -1,20 +1,16 @@ -package pro.qyoga.i9ns.calendars.google +package pro.qyoga.i9ns.calendars.google.persistance import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.stereotype.Repository import pro.azhidkov.platform.spring.jdbc.taDataClassRowMapper import pro.azhidkov.platform.uuid.UUIDv7 import pro.qyoga.core.users.therapists.TherapistRef +import pro.qyoga.i9ns.calendars.google.model.GoogleAccountRef +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendarId +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendarSettings typealias GoogleCalendarSettingsPatch = Map -data class GoogleCalendarSettings( - val ownerRef: TherapistRef, - val googleAccountRef: GoogleAccountRef, - val calendarId: String, - val shouldBeShown: Boolean, -) - @Repository class GoogleCalendarsDao( private val jdbcClient: JdbcClient @@ -43,7 +39,7 @@ class GoogleCalendarsDao( .update() } - fun findCalendarsSettings(therapist: TherapistRef): Map { + fun findCalendarsSettings(therapist: TherapistRef): Map { val query = """ SELECT * FROM therapist_google_calendar_settings WHERE owner_ref = :ownerRef """ diff --git a/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/views/GoogleAccountCalendarsSettingsView.kt b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/views/GoogleAccountCalendarsSettingsView.kt new file mode 100644 index 00000000..e0fd0edb --- /dev/null +++ b/app/src/main/kotlin/pro/qyoga/i9ns/calendars/google/views/GoogleAccountCalendarsSettingsView.kt @@ -0,0 +1,73 @@ +package pro.qyoga.i9ns.calendars.google.views + +import pro.qyoga.i9ns.calendars.google.model.GoogleAccount +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendar +import pro.qyoga.i9ns.calendars.google.model.GoogleCalendarSettings +import java.util.* + + +private const val DEFAULT_CALENDAR_VISIBILITY = false + +data class GoogleAccountCalendarsSettingsView( + val id: UUID, + val email: String, + val content: GoogleCalendarsSettingsView +) { + + companion object { + + fun of( + account: GoogleAccount, + calendars: Result>, + calendarSettings: Map + ): GoogleAccountCalendarsSettingsView = GoogleAccountCalendarsSettingsView( + account.id, + account.email, + GoogleCalendarsSettingsView(calendars, calendarSettings) + ) + } + +} + +sealed interface GoogleCalendarsSettingsView { + + val calendars: List + val isError: Boolean + + data class Calendars( + override val calendars: List, + ) : GoogleCalendarsSettingsView { + override val isError: Boolean = false + } + + data object Error : GoogleCalendarsSettingsView { + override val calendars: List = emptyList() + override val isError: Boolean = true + } + + companion object { + + operator fun invoke( + calendars: Result>, + calendarSettings: Map + ): GoogleCalendarsSettingsView = + if (calendars.isSuccess) { + Calendars(calendars.getOrThrow().map { + GoogleCalendarSettingsView( + it.externalId, + it.name, + calendarSettings[it.externalId]?.shouldBeShown ?: DEFAULT_CALENDAR_VISIBILITY + ) + }) + } else { + Error + } + } + +} + +data class GoogleCalendarSettingsView( + val id: String, + val title: String, + val shouldBeShown: Boolean +) diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index 4e01713d..f8904d6d 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -104,8 +104,4 @@ trainer-advisor: google-calendar: root-url: https://www.googleapis.com/ -logging: - level: - org.springframework.security: WARN - -debug: false \ No newline at end of file +debug: false diff --git a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql index a625e117..2abc00b4 100644 --- a/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql +++ b/app/src/main/resources/db/migration/common/current/V25091101__add_google_calendars.sql @@ -17,4 +17,4 @@ CREATE TABLE therapist_google_calendar_settings should_be_shown BOOLEAN NOT NULL, UNIQUE (owner_ref, google_account_ref, calendar_id) -); \ No newline at end of file +); diff --git a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html index d5a66c38..6545abde 100644 --- a/app/src/main/resources/templates/therapist/appointments/google-settings-component.html +++ b/app/src/main/resources/templates/therapist/appointments/google-settings-component.html @@ -7,10 +7,10 @@
Google Calendar