diff --git a/frontend/src/employee-mobile-frontend/settings/NotificationSettings.tsx b/frontend/src/employee-mobile-frontend/settings/NotificationSettings.tsx index e4d3fd3caa8..88d3b9cc9b3 100644 --- a/frontend/src/employee-mobile-frontend/settings/NotificationSettings.tsx +++ b/frontend/src/employee-mobile-frontend/settings/NotificationSettings.tsx @@ -42,8 +42,10 @@ import { } from 'lib-components/molecules/ExpandingInfo' import { fontWeights, H2 } from 'lib-components/typography' import { Gap } from 'lib-components/white-space' +import { featureFlags } from 'lib-customizations/employeeMobile' import { renderResult } from '../async-rendering' +import { UserContext } from '../auth/state' import { useTranslation } from '../common/i18n' import { ServiceWorkerContext } from '../common/service-worker' import { unitInfoQuery } from '../units/queries' @@ -183,9 +185,22 @@ const SettingsSectionsView = React.memo(function SettingsSectionsView({ groupInfos: GroupInfo[] }) { const { i18n } = useTranslation() + const { user } = useContext(UserContext) + const personalDevice = user.map((u) => u && u.personalDevice).getOrElse(false) + + const enabledCategories = featureFlags.discussionReservations + ? pushNotificationCategories + : pushNotificationCategories.filter( + (c) => c !== 'CALENDAR_EVENT_RESERVATION' + ) + + const deviceCategories = personalDevice + ? enabledCategories.filter((c) => c !== 'CALENDAR_EVENT_RESERVATION') + : enabledCategories + return ( ( + categories={deviceCategories.map((category) => ( + + private val area = DevCareArea() + private val daycare = + DevDaycare( + areaId = area.id, + enabledPilotFeatures = setOf(PilotFeature.MESSAGING, PilotFeature.PUSH_NOTIFICATIONS), + ) + private val group = DevDaycareGroup(daycareId = daycare.id) + private val employee = DevEmployee() + private val device = + DevMobileDevice( + unitId = daycare.id, + pushNotificationCategories = setOf(PushNotificationCategory.CALENDAR_EVENT_RESERVATION), + ) + private val citizen = DevPerson() + private val child = DevPerson() + private val placement = + DevPlacement( + childId = child.id, + unitId = daycare.id, + startDate = clock.today(), + endDate = clock.today().plusYears(1), + ) + private val groupPlacement = + DevDaycareGroupPlacement( + daycarePlacementId = placement.id, + daycareGroupId = group.id, + startDate = placement.startDate, + endDate = placement.endDate, + ) + private val calendarEvent = + DevCalendarEvent( + title = "Vasukeskustelu", + description = "test", + period = FiniteDateRange(clock.today().plusDays(1), clock.today().plusDays(9)), + modifiedAt = clock.now(), + modifiedBy = employee.evakaUserId, + eventType = CalendarEventType.DISCUSSION_SURVEY, + ) + private val calendarEventAttendee = + DevCalendarEventAttendee( + calendarEventId = calendarEvent.id, + unitId = daycare.id, + groupId = group.id, + ) + private val calendarEventTime = + DevCalendarEventTime( + calendarEventId = calendarEvent.id, + date = clock.today().plusDays(3), + start = LocalTime.of(9, 0), + end = LocalTime.of(10, 0), + childId = null, + modifiedAt = clock.now(), + modifiedBy = employee.evakaUserId, + ) + + @BeforeEach + fun beforeEach() { + mockEndpoint.clearData() + db.transaction { tx -> + tx.insert(area) + tx.insert(daycare) + tx.insert(group) + tx.insert(employee, unitRoles = mapOf(daycare.id to UserRole.STAFF)) + tx.insert(device) + tx.upsertPushGroup(clock.now(), device.id, group.id) + tx.insert(citizen, DevPersonType.ADULT) + tx.insert(child, DevPersonType.CHILD) + tx.insertGuardian(guardianId = citizen.id, childId = child.id) + tx.insert(placement) + tx.insert(groupPlacement) + tx.insert(calendarEvent) + tx.insert(calendarEventAttendee) + tx.insert(calendarEventTime) + } + } + + @Test + fun `a push notification is sent when a citizen reserves a discussion time`() { + val endpoint = URI("http://localhost:$httpPort/public/mock-web-push/subscription/1234") + upsertSubscription(device.id, endpoint) + + calendarEventController.addCalendarEventTimeReservation( + dbInstance(), + citizen.user(CitizenAuthLevel.WEAK), + clock, + CalendarEventTimeCitizenReservationForm( + calendarEventTimeId = calendarEventTime.id, + childId = child.id, + ), + ) + asyncJobRunner.runPendingJobsSync(clock) + assertNotificationSent() + + mockEndpoint.clearData() + assertEquals(0, mockEndpoint.getCapturedRequests("1234").size) + calendarEventController.deleteCalendarEventTimeReservation( + dbInstance(), + citizen.user(CitizenAuthLevel.WEAK), + clock, + calendarEventTimeId = calendarEventTime.id, + childId = child.id, + ) + asyncJobRunner.runPendingJobsSync(clock) + assertNotificationSent() + } + + private fun assertNotificationSent() { + val request = mockEndpoint.getCapturedRequests("1234").single() + assertEquals("normal", request.headers["urgency"]) + assertNotNull(request.headers["ttl"]?.toIntOrNull()) + assertTrue(request.headers["authorization"]?.startsWith("vapid") ?: false) + assertEquals("aes128gcm", request.headers["content-encoding"]) + assertTrue(request.body.isNotEmpty()) + } + + private fun upsertSubscription(device: MobileDeviceId, endpoint: URI) = + db.transaction { tx -> + tx.upsertPushSubscription( + device, + WebPushSubscription( + endpoint = endpoint, + expires = null, + ecdhKey = WebPushCrypto.encode(keyPair.publicKey).toList(), + authSecret = listOf(0x00, 0x11, 0x22, 0x33), + ), + ) + } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventController.kt b/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventController.kt index aba763dfc37..da32e57c23c 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventController.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventController.kt @@ -800,10 +800,15 @@ class CalendarEventController( ) } - // send reservation email if reservation changes + // send reservation email and mobile notification if reservation changes if (eventTimeDetails.eventTime.childId != body.childId) { val recipients = getRecipientsForChild(tx, body.childId) val finalEventTime = eventTimeDetails.eventTime.copy(childId = body.childId) + val groupDevices = + tx.getCalendarEventReservationGroupDevices( + body.calendarEventTimeId, + body.childId, + ) asyncJobRunner.plan( tx, recipients.map { @@ -814,7 +819,19 @@ class CalendarEventController( calendarEventTime = finalEventTime, recipientId = it.id, ) - }, + } + + groupDevices.map { + AsyncJob.SendCalendarEventReservationPushNotification( + device = it.device, + groupId = it.groupId, + type = + AsyncJob.CalendarEventReservationNotificationType + .RESERVED, + date = eventTimeDetails.eventTime.date, + startTime = eventTimeDetails.eventTime.startTime, + endTime = eventTimeDetails.eventTime.endTime, + ) + }, runAt = clock.now(), ) } @@ -849,6 +866,11 @@ class CalendarEventController( val eventTimeDetails = tx.getDiscussionTimeDetailsByEventTimeId(body.calendarEventTimeId) ?: throw BadRequest("Calendar event time not found") + val groupDevices = + tx.getCalendarEventReservationGroupDevices( + body.calendarEventTimeId, + body.childId, + ) tx.freeCalendarEventTimeReservation(user, clock.now(), body.calendarEventTimeId) val recipients = getRecipientsForChild(tx, body.childId) asyncJobRunner.plan( @@ -861,7 +883,18 @@ class CalendarEventController( calendarEventTime = eventTimeDetails.eventTime, recipientId = it.id, ) - }, + } + + groupDevices.map { + AsyncJob.SendCalendarEventReservationPushNotification( + device = it.device, + groupId = it.groupId, + type = + AsyncJob.CalendarEventReservationNotificationType.CANCELLED, + date = eventTimeDetails.eventTime.date, + startTime = eventTimeDetails.eventTime.startTime, + endTime = eventTimeDetails.eventTime.endTime, + ) + }, runAt = clock.now(), ) } diff --git a/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventPushNotifications.kt b/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventPushNotifications.kt new file mode 100644 index 00000000000..438d115a834 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/calendarevent/CalendarEventPushNotifications.kt @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.calendarevent + +import fi.espoo.evaka.shared.CalendarEventTimeId +import fi.espoo.evaka.shared.ChildId +import fi.espoo.evaka.shared.GroupId +import fi.espoo.evaka.shared.MobileDeviceId +import fi.espoo.evaka.shared.async.AsyncJob +import fi.espoo.evaka.shared.async.AsyncJob.CalendarEventReservationNotificationType +import fi.espoo.evaka.shared.async.AsyncJobRunner +import fi.espoo.evaka.shared.auth.AuthenticatedUser +import fi.espoo.evaka.shared.db.Database +import fi.espoo.evaka.shared.domain.EvakaClock +import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import fi.espoo.evaka.shared.security.AccessControl +import fi.espoo.evaka.shared.security.Action +import fi.espoo.evaka.webpush.WebPush +import fi.espoo.evaka.webpush.WebPushCrypto +import fi.espoo.evaka.webpush.WebPushEndpoint +import fi.espoo.evaka.webpush.WebPushNotification +import fi.espoo.evaka.webpush.WebPushPayload +import fi.espoo.evaka.webpush.deletePushSubscription +import fi.espoo.voltti.logging.loggers.info +import java.time.Duration +import java.time.format.DateTimeFormatter +import mu.KotlinLogging +import org.springframework.stereotype.Service + +@Service +class CalendarEventPushNotifications( + private val webPush: WebPush?, + private val accessControl: AccessControl, + asyncJobRunner: AsyncJobRunner, +) { + init { + asyncJobRunner.registerHandler(::sendCalendarEventPushNotification) + } + + private val logger = KotlinLogging.logger {} + + data class CalendarEventReservationNotification( + val groupId: GroupId, + val groupName: String, + val endpoint: WebPushEndpoint, + ) + + private fun Database.Read.getNotification( + job: AsyncJob.SendCalendarEventReservationPushNotification + ): CalendarEventReservationNotification? = + createQuery { + sql( + """ +SELECT dg.id as group_id, dg.name as group_name, mdps.endpoint, mdps.auth_secret, mdps.ecdh_key +FROM mobile_device md +JOIN mobile_device_push_group mdpg ON mdpg.device = md.id +JOIN mobile_device_push_subscription mdps ON mdps.device = md.id +JOIN daycare_group dg ON dg.id = mdpg.daycare_group +JOIN daycare d ON d.id = dg.daycare_id +WHERE md.id = ${bind(job.device)} AND md.employee_id IS NULL AND dg.id = ${bind(job.groupId)} +AND 'PUSH_NOTIFICATIONS' = ANY(d.enabled_pilot_features) +AND 'CALENDAR_EVENT_RESERVATION' = ANY(md.push_notification_categories) +""" + ) + } + .exactlyOneOrNull { + CalendarEventReservationNotification( + groupId = column("group_id"), + groupName = column("group_name"), + WebPushEndpoint( + uri = column("endpoint"), + ecdhPublicKey = + WebPushCrypto.decodePublicKey(column("ecdh_key")), + authSecret = column("auth_secret"), + ), + ) + } + + private fun sendCalendarEventPushNotification( + dbc: Database.Connection, + clock: EvakaClock, + job: AsyncJob.SendCalendarEventReservationPushNotification, + ) { + if (webPush == null) return + + val untilReservationEnd = + Duration.between( + clock.now().toInstant(), + HelsinkiDateTime.of(job.date, job.endTime).toInstant(), + ) + + if (untilReservationEnd.isNegative) { + // reservation is in the past, no need to send notification + return + } + + val device = job.device + + val (vapidJwt, notification) = + dbc.transaction { tx -> + tx.getNotification(job) + ?.takeIf { + accessControl.hasPermissionFor( + tx, + AuthenticatedUser.MobileDevice(device), + clock, + Action.Group.RECEIVE_PUSH_NOTIFICATIONS, + it.groupId, + ) + } + ?.let { Pair(webPush.getValidToken(tx, clock, it.endpoint.uri), it) } + } ?: return + dbc.close() + + val dateFormat = DateTimeFormatter.ofPattern("d.M.") + val timeFormat = DateTimeFormatter.ofPattern("HH:mm") + + logger.info(mapOf("endpoint" to notification.endpoint.uri)) { + "Sending push notification to $device" + } + try { + webPush.send( + vapidJwt, + WebPushNotification( + notification.endpoint, + ttl = untilReservationEnd.coerceIn(Duration.ofMinutes(15), Duration.ofDays(5)), + payloads = + listOf( + WebPushPayload.NotificationV1( + title = + "${notification.groupName}: Huoltaja ${when (job.type) { + CalendarEventReservationNotificationType.RESERVED -> "varannut" + CalendarEventReservationNotificationType.CANCELLED -> "perunut" + }} keskusteluajan ${job.date.format(dateFormat)} klo ${job.startTime.format(timeFormat)} - ${job.endTime.format(timeFormat)}" + ) + ), + ), + ) + } catch (e: WebPush.SubscriptionExpired) { + logger.warn( + "Subscription expired for device $device (HTTP status ${e.status}) -> deleting" + ) + dbc.transaction { it.deletePushSubscription(device) } + } + } +} + +data class GroupDevice(val groupId: GroupId, val device: MobileDeviceId) + +fun Database.Read.getCalendarEventReservationGroupDevices( + calendarEventTimeId: CalendarEventTimeId, + childId: ChildId, +): Set = + createQuery { + sql( + """ +SELECT cea.group_id, mdpg.device +FROM calendar_event_time cet +JOIN calendar_event_attendee cea ON cea.calendar_event_id = cet.calendar_event_id +JOIN daycare_group dg ON dg.id = cea.group_id +JOIN daycare d ON d.id = dg.daycare_id AND d.id = cea.unit_id AND 'PUSH_NOTIFICATIONS' = ANY(d.enabled_pilot_features) +JOIN mobile_device_push_group mdpg ON mdpg.daycare_group = dg.id +WHERE cet.id = ${bind(calendarEventTimeId)} +AND EXISTS ( + SELECT FROM mobile_device md + JOIN mobile_device_push_subscription mdps ON mdpg.device = md.id + WHERE md.id = mdpg.device + AND md.employee_id IS NULL -- not for personal mobiles + AND 'CALENDAR_EVENT_RESERVATION' = ANY(md.push_notification_categories) +) AND EXISTS ( + SELECT FROM daycare_group_placement dgp + JOIN placement pl ON dgp.daycare_placement_id = pl.id + WHERE pl.child_id = ${bind(childId)} + AND dgp.daycare_group_id = cea.group_id + AND daterange(dgp.start_date, dgp.end_date, '[]') @> cet.date +) + """ + ) + } + .toSet() diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/async/AsyncJob.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/async/AsyncJob.kt index 903bf4de115..8c966098ba8 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/async/AsyncJob.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/async/AsyncJob.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2017-2020 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -26,6 +26,7 @@ import fi.espoo.evaka.shared.DaycareId import fi.espoo.evaka.shared.DecisionId import fi.espoo.evaka.shared.EmployeeId import fi.espoo.evaka.shared.FeeDecisionId +import fi.espoo.evaka.shared.GroupId import fi.espoo.evaka.shared.InvoiceCorrectionId import fi.espoo.evaka.shared.MessageAccountId import fi.espoo.evaka.shared.MessageContentId @@ -48,6 +49,7 @@ import fi.espoo.evaka.specialdiet.SpecialDiet import fi.espoo.evaka.varda.VardaChildCalculatedServiceNeedChanges import java.time.Duration import java.time.LocalDate +import java.time.LocalTime import java.util.UUID import kotlin.reflect.KClass @@ -118,6 +120,22 @@ sealed interface AsyncJob : AsyncJobPayload { override val user: AuthenticatedUser? = null } + enum class CalendarEventReservationNotificationType { + RESERVED, + CANCELLED, + } + + data class SendCalendarEventReservationPushNotification( + val device: MobileDeviceId, + val groupId: GroupId, + val type: CalendarEventReservationNotificationType, + val date: LocalDate, + val startTime: LocalTime, + val endTime: LocalTime, + ) : AsyncJob { + override val user: AuthenticatedUser? = null + } + data class UploadToKoski(val key: KoskiStudyRightKey) : AsyncJob { override val user: AuthenticatedUser? = null } @@ -492,8 +510,9 @@ sealed interface AsyncJob : AsyncJobPayload { AsyncJobPool.Config(concurrency = 4), setOf( MarkMessagesAsSent::class, - SendMessagePushNotification::class, SendAbsencePushNotification::class, + SendCalendarEventReservationPushNotification::class, + SendMessagePushNotification::class, UpdateMessageThreadRecipients::class, ), ) diff --git a/service/src/main/kotlin/fi/espoo/evaka/webpush/PushNotificationCategory.kt b/service/src/main/kotlin/fi/espoo/evaka/webpush/PushNotificationCategory.kt index 167e3b34b91..cd11a3cbcae 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/webpush/PushNotificationCategory.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/webpush/PushNotificationCategory.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2017-2023 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -10,7 +10,8 @@ import fi.espoo.evaka.shared.db.DatabaseEnum @ConstList("pushNotificationCategories") enum class PushNotificationCategory : DatabaseEnum { RECEIVED_MESSAGE, - NEW_ABSENCE; + NEW_ABSENCE, + CALENDAR_EVENT_RESERVATION; override val sqlType: String = "push_notification_category" } diff --git a/service/src/main/resources/db/migration/V466__push_notification_calendar_event_reservation.sql b/service/src/main/resources/db/migration/V466__push_notification_calendar_event_reservation.sql new file mode 100644 index 00000000000..0fc13cad494 --- /dev/null +++ b/service/src/main/resources/db/migration/V466__push_notification_calendar_event_reservation.sql @@ -0,0 +1 @@ +ALTER TYPE push_notification_category ADD VALUE 'CALENDAR_EVENT_RESERVATION'; \ No newline at end of file diff --git a/service/src/main/resources/db/migration/V467__push_notification_calendar_event_reservation_defaults.sql b/service/src/main/resources/db/migration/V467__push_notification_calendar_event_reservation_defaults.sql new file mode 100644 index 00000000000..ffa27b8e81f --- /dev/null +++ b/service/src/main/resources/db/migration/V467__push_notification_calendar_event_reservation_defaults.sql @@ -0,0 +1,3 @@ +UPDATE mobile_device +SET push_notification_categories = push_notification_categories || 'CALENDAR_EVENT_RESERVATION'::push_notification_category +WHERE cardinality(push_notification_categories) > 0 AND employee_id IS NULL; diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index 89beaded682..37141bcfde2 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -461,3 +461,5 @@ V462__drop_table_holiday.sql V463__calendar_event_modified_by.sql V464__scheduled_tasks_priority.sql V465__invoice_replacement_info.sql +V466__push_notification_calendar_event_reservation.sql +V467__push_notification_calendar_event_reservation_defaults.sql