Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a02d76
feat/qg-290: реализована подписка на веб-пуши
d-r-q Oct 12, 2025
a4a64b6
feat/qg-290: реализовано хранение настроек уведомлений о заполнении р…
d-r-q Oct 13, 2025
49f489a
feat/qg-290: реализованы уведомления о заполнении расписания
d-r-q Oct 16, 2025
f5d31f4
feat/qg-290: добавлена автоматическая генерация имени pwa-кэша на осн…
d-r-q Oct 16, 2025
2164595
feat/qg-290: добавлены топики веб-пушей
d-r-q Oct 16, 2025
f15b899
chore/qg-290: добавлено дебажное логгирование метода включения уведом…
d-r-q Oct 16, 2025
1d7f17d
chore/qg-290: добавлено дебажное логгирование метода подписки на увед…
d-r-q Oct 16, 2025
52f78bc
refactor/qg-290: отрефакторина логика открытия pwa-окон
d-r-q Oct 25, 2025
f411e02
refactor/qg-290: TherapistWebPushSubscription вынесена в модель прило…
d-r-q Oct 26, 2025
bc06927
refactor/qg-290: отправка нотификаций о заполнении расписания перевед…
d-r-q Oct 26, 2025
9af26d4
refactor/qg-290: пачка мелких полировок
d-r-q Nov 2, 2025
f7eaf8b
refactor/qg-290: упрощена и структурирована логика обработки фетча ре…
d-r-q Nov 2, 2025
c3a9cff
refactor/qg-290: реализовано новое АПИ верификации HTML-я
d-r-q Nov 3, 2025
2289f6f
refactor/qg-290: повышена стабильность e2e-тестов
d-r-q Nov 4, 2025
fc9447f
refactor/qg-290: ассёрт shouldHave удалён в пользу shouldHaveElement
d-r-q Nov 5, 2025
47de8c9
refactor/qg-290: ассёрт Element shouldHave PageMatcher заменён на Ele…
d-r-q Nov 5, 2025
8eddba7
refactor/qg-290: ClientForm переведена на добавление компонентов чере…
d-r-q Nov 5, 2025
0233f98
refactor/qg-290: ClientForm переведена на верификацию через should (в…
d-r-q Nov 5, 2025
ad8fede
refactor/qg-290: выделена логика формирования дескриптора элемента дл…
d-r-q Nov 5, 2025
c3f8962
refactor/qg-290: код настроек гугл-календарей перенесён в schedule.se…
d-r-q Nov 5, 2025
4374e32
feat/qg-290: реализована динамическая загрузка фрагмента настроек нот…
d-r-q Nov 5, 2025
2c93851
refactor/qg-290: ещё пачка мелкой полировки реализации пушей
d-r-q Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import com.gorylenko.GenerateGitPropertiesTask
import kotlinx.kover.gradle.plugin.dsl.AggregationType
import kotlinx.kover.gradle.plugin.dsl.CoverageUnit
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.util.*

plugins {
alias(libs.plugins.kotlin.spring)
Expand Down Expand Up @@ -43,6 +45,8 @@ dependencies {
implementation(libs.google.oauth.client)
implementation(platform(libs.google.auth.bom))
implementation("com.google.auth:google-auth-library-oauth2-http")
implementation(libs.web.push)
implementation(libs.bouncycastle)

developmentOnly("org.springframework.boot:spring-boot-docker-compose")

Expand Down Expand Up @@ -224,3 +228,20 @@ val compileKotlin: KotlinCompile by tasks
compileKotlin.compilerOptions {
freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property"))
}

val generateGitProperties = tasks.named<GenerateGitPropertiesTask>("generateGitProperties")

val gitHash: Provider<String> = generateGitProperties.map { task ->
val props = Properties()
task.output.get().asFile.inputStream().use(props::load)
props.getProperty("git.commit.id.abbrev")
}

tasks.processResources {
dependsOn(generateGitProperties)
filesMatching("static/sw.js") {
expand(
"APP_VERSION" to gitHash.get()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ inline operator fun <reified T> ResultSet.get(colName: String): T = when (T::cla
Duration::class -> (this.getObject(colName) as PGInterval).toDuration()
List::class -> (this.getArray(colName).array as Array<*>).toList()
else -> this.getObject(colName)
} as T
} as T

inline operator fun <reified T> ResultSet.get(colIdx: Int): T =
this.metaData.getColumnName(colIdx)
.let { colName -> this[colName] }
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package pro.azhidkov.platform.spring.sdj.converters

import com.fasterxml.jackson.databind.ObjectMapper
import org.postgresql.util.PGobject
import org.springframework.core.convert.converter.Converter
import org.springframework.data.convert.ReadingConverter
import org.springframework.data.convert.WritingConverter
import kotlin.reflect.KClass


@WritingConverter
fun interface ObjectToJsonbWriter<T : Any> : Converter<T, PGobject>

@ReadingConverter
fun interface JsonbToObjectReader<T : Any> : Converter<PGobject, T>

abstract class JacksonObjectToJsonbWriter<T : Any>(
private val objectMapper: ObjectMapper
) : ObjectToJsonbWriter<T> {

override fun convert(source: T): PGobject =
source.let {
PGobject().apply {
type = "jsonb"
value = objectMapper.writeValueAsString(source)
}
}

}

@ReadingConverter
abstract class JacksonJsonbToObjectReader<T : Any>(
private val objectMapper: ObjectMapper,
private val type: KClass<T>
) : JsonbToObjectReader<T> {

override fun convert(source: PGobject): T? =
if (source.isNull) {
null
} else {
objectMapper.readValue(source.value!!, type.java)
}

}

object ObjectToJsonbConverters {

inline fun <reified T : Any> convertersFor(objectMapper: ObjectMapper) =
setOf(
object : JacksonObjectToJsonbWriter<T>(objectMapper) {},
object : JacksonJsonbToObjectReader<T>(objectMapper, T::class) {},
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,24 @@ data class AggregateReferenceTarget<T : Identifiable<ID>, ID : Any>(
@JsonIgnore
override fun getId(): ID = entity.id

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AggregateReference<*, *>) return false

return if (other is AggregateReferenceTarget<*, *>) {
entity == other.entity
} else {
getId() == other.getId()
}
}

override fun hashCode(): Int {
return entity.id.hashCode()
}

}

@Suppress("UNCHECKED_CAST")
fun <R : AggregateReference<T, ID>?, ID : Any, T : Identifiable<ID>> R.resolveOrThrow(): T =
(this as? AggregateReferenceTarget<T, ID>)?.entity
?: error("$this is not instance of AggregateReferenceTarget")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ interface Identifiable<T : Any> {

}

fun <E : Identifiable<T>, T : Any> E.ref(): AggregateReference<E, T> = AggregateReferenceTarget(this)
fun <E : Identifiable<T>, T : Any> E.ref(): AggregateReference<E, T> =
AggregateReferenceTarget(this)
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class QueryBuilder {
}
}

infix fun <T : Any> KProperty1<*, T?>.`in`(value: Iterable<T>) {
criteria.addLast(Criteria.where(this.name).`in`(value))
}

fun build() = Query.query(toCriteriaDefinition())

private fun toCriteriaDefinition() =
Expand All @@ -74,4 +78,4 @@ fun query(body: QueryBuilder.() -> Unit): Query {
val builder = QueryBuilder()
builder.body()
return builder.build()
}
}
2 changes: 2 additions & 0 deletions app/src/main/kotlin/pro/qyoga/app/QYogaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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.i9ns.pushes.web.WebPushesConf
import pro.qyoga.infra.auth.AuthConfig
import pro.qyoga.infra.cache.CacheConf
import pro.qyoga.infra.db.SdjConfig
Expand All @@ -42,6 +43,7 @@ import pro.qyoga.tech.captcha.CaptchaConf
EmailsConfig::class,
ICalCalendarsConfig::class,
GoogleCalendarConf::class,
WebPushesConf::class,

// Tech
CaptchaConf::class,
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/kotlin/pro/qyoga/app/infra/WebSecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,36 @@ class WebSecurityConfig(
requests
// Therapist
.requestMatchers("/therapist/**").hasAnyAuthority(Role.ROLE_THERAPIST.toString())
.requestMatchers(HttpMethod.POST, "/pushes/web/subscriptions")
.hasAnyAuthority(Role.ROLE_THERAPIST.toString())
.requestMatchers(HttpMethod.DELETE, "/pushes/web/subscriptions/*")
.hasAnyAuthority(Role.ROLE_THERAPIST.toString())

// Public
.requestMatchers(
HttpMethod.GET,
"/",

"/manifest.json",
"/sw.js",
"/offline.html",

"/privacy-policy.html",
"/manifest.json",

"/register",

"/oauth2/**",

"/pushes/web/public-key",

"/components/**",

"/styles/**",
"/img/**",
"/js/**",
"/fonts/**",
"/vendor/**",

"/test/*"
)
.permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package pro.qyoga.app.publc.pushes.web

import org.springframework.http.CacheControl
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import pro.qyoga.i9ns.pushes.web.WebPushesConfProps
import java.time.Duration


@RestController
class PushesPublicKeyController(
private val webPushesConfProps: WebPushesConfProps
) {

@GetMapping(PUBLIC_KEY_PATH, produces = [MediaType.TEXT_PLAIN_VALUE])
fun publicKey(): ResponseEntity<String> {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic())
.body(webPushesConfProps.publicKey)
}

companion object {

const val PUBLIC_KEY_PATH = "/pushes/web/public-key"

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package pro.qyoga.app.pushes.web

import org.springframework.stereotype.Component
import pro.qyoga.core.appointments.notifications.fill_schedule.FillScheduleNotificationsSettings
import pro.qyoga.core.appointments.notifications.fill_schedule.FillScheduleNotificationsSettingsRepo
import pro.qyoga.core.users.therapists.TherapistRef
import pro.qyoga.i9ns.pushes.web.WebPushSubscriptionsRepo
import pro.qyoga.i9ns.pushes.web.model.TherapistWebPushSubscription
import pro.qyoga.i9ns.pushes.web.model.WebPushSubscription


@Component
class RegisterSubscriptionOp(
private val webPushSubscriptionsRepo: WebPushSubscriptionsRepo,
private val fillScheduleNotificationsSettingsRepo: FillScheduleNotificationsSettingsRepo
) : (TherapistRef, WebPushSubscription) -> Unit {

override fun invoke(
therapistRef: TherapistRef,
subscription: WebPushSubscription,
) {
webPushSubscriptionsRepo.addSubscription(TherapistWebPushSubscription(therapistRef, subscription))
fillScheduleNotificationsSettingsRepo.createIfNotExists(
FillScheduleNotificationsSettings.defaultSettingsFor(therapistRef)
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package pro.qyoga.app.pushes.web

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
import pro.qyoga.core.users.auth.dtos.QyogaUserDetails
import pro.qyoga.core.users.therapists.ref
import pro.qyoga.i9ns.pushes.web.WebPushSubscriptionsRepo
import pro.qyoga.i9ns.pushes.web.model.WebPushSubscription


@RestController
class WebPushesController(
private val webPushSubscriptionsRepo: WebPushSubscriptionsRepo,
private val registerSubscriptionOp: RegisterSubscriptionOp
) {

@ResponseStatus(HttpStatus.NO_CONTENT)
@PostMapping(PATH)
fun createSubscription(
@RequestBody @Valid subscription: WebPushSubscription,
@AuthenticationPrincipal therapist: QyogaUserDetails
) {
registerSubscriptionOp(therapist.ref, subscription)
}

@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping(DELETE_SUBSCRIPTION_PATH)
fun deleteSubscription(
@PathVariable p256dh: String,
@AuthenticationPrincipal therapist: QyogaUserDetails
) {
webPushSubscriptionsRepo.deleteSubscription(therapist.ref, p256dh)
}

companion object {
const val PATH = "/pushes/web/subscriptions"
const val DELETE_SUBSCRIPTION_PATH = "$PATH/{p256dh}"
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class GetCalendarAppointmentsOp(
return GetCalendarAppointmentsRs(appointments + drafts, hasErrors)
}

companion object {
const val PATH = "/therapist/schedule"
}

}

private fun calendarIntervalAround(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import pro.qyoga.core.appointments.core.model.AppointmentRef
import pro.qyoga.core.users.auth.dtos.QyogaUserDetails
Expand All @@ -13,12 +12,11 @@ import java.util.*


@Controller
@RequestMapping(SchedulePageController.PATH)
class SchedulePageController(
private val getCalendarAppointments: GetCalendarAppointmentsOp
) {

@GetMapping
@GetMapping(PATH)
fun getCalendarPage(
@RequestParam(DATE) date: LocalDate = LocalDate.now(),
@RequestParam(FOCUSED_APPOINTMENT) focusedAppointment: UUID? = null,
Expand All @@ -29,15 +27,15 @@ class SchedulePageController(
}

companion object {
const val PATH = "/therapist/schedule"
const val PATH = GetCalendarAppointmentsOp.PATH
const val DATE = "date"
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())
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package pro.qyoga.app.therapist.appointments.core.schedule
package pro.qyoga.app.therapist.appointments.core.schedule.settings

import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
Expand Down Expand Up @@ -41,7 +41,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)

}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package pro.qyoga.app.therapist.appointments.core.schedule
package pro.qyoga.app.therapist.appointments.core.schedule.settings

import org.springframework.web.servlet.ModelAndView
import pro.qyoga.i9ns.calendars.google.views.GoogleAccountCalendarsSettingsView
Expand Down
Loading
Loading