Skip to content

Commit

Permalink
implement calendar event reservation/cancellation push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
Joosakur committed Nov 15, 2024
1 parent ef3a5de commit ca8814c
Show file tree
Hide file tree
Showing 10 changed files with 255 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -183,9 +185,20 @@ 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
? pushSettings.categories
: pushSettings.categories.filter((c) => c !== 'CALENDAR_EVENT_RESERVATION')

const deviceCategories = personalDevice
? enabledCategories.filter((c) => c !== 'CALENDAR_EVENT_RESERVATION')
: enabledCategories

return (
<SettingsSections
categories={pushNotificationCategories.map((category) => (
categories={deviceCategories.map((category) => (
<Checkbox
key={category}
data-qa={category}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib-common/generated/api-types/webpush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { UUID } from '../../types'
*/
export const pushNotificationCategories = [
'RECEIVED_MESSAGE',
'NEW_ABSENCE'
'NEW_ABSENCE',
'CALENDAR_EVENT_RESERVATION'
] as const

export type PushNotificationCategory = typeof pushNotificationCategories[number]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,8 @@ export const fi = {
label: 'Aiheet, joista lähetetään ilmoitus tähän puhelimeen',
values: {
RECEIVED_MESSAGE: 'Saapuneet viestit',
NEW_ABSENCE: 'Lasten kuluvan päivän poissaolomerkinnät'
NEW_ABSENCE: 'Lasten kuluvan päivän poissaolomerkinnät',
CALENDAR_EVENT_RESERVATION: 'Varatut ja perutut keskusteluajat'
}
},
groups: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -814,7 +819,20 @@ class CalendarEventController(
calendarEventTime = finalEventTime,
recipientId = it.id,
)
},
} +
groupDevices.map {
AsyncJob.SendCalendarEventReservationPushNotification(
user = user,
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(),
)
}
Expand Down Expand Up @@ -849,6 +867,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(
Expand All @@ -861,7 +884,19 @@ class CalendarEventController(
calendarEventTime = eventTimeDetails.eventTime,
recipientId = it.id,
)
},
} +
groupDevices.map {
AsyncJob.SendCalendarEventReservationPushNotification(
user = user,
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(),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// 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.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<AsyncJob>,
) {
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<ByteArray>("ecdh_key")),
authSecret = column("auth_secret"),
),
)
}

private fun sendCalendarEventPushNotification(
dbc: Database.Connection,
clock: EvakaClock,
job: AsyncJob.SendCalendarEventReservationPushNotification,
) {
if (webPush == null) 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 = Duration.ofDays(1),
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<GroupDevice> =
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<GroupDevice>()
22 changes: 20 additions & 2 deletions service/src/main/kotlin/fi/espoo/evaka/shared/async/AsyncJob.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -118,6 +120,21 @@ sealed interface AsyncJob : AsyncJobPayload {
override val user: AuthenticatedUser? = null
}

enum class CalendarEventReservationNotificationType {
RESERVED,
CANCELLED,
}

data class SendCalendarEventReservationPushNotification(
override val user: AuthenticatedUser,
val device: MobileDeviceId,
val groupId: GroupId,
val type: CalendarEventReservationNotificationType,
val date: LocalDate,
val startTime: LocalTime,
val endTime: LocalTime,
) : AsyncJob

data class UploadToKoski(val key: KoskiStudyRightKey) : AsyncJob {
override val user: AuthenticatedUser? = null
}
Expand Down Expand Up @@ -492,8 +509,9 @@ sealed interface AsyncJob : AsyncJobPayload {
AsyncJobPool.Config(concurrency = 4),
setOf(
MarkMessagesAsSent::class,
SendMessagePushNotification::class,
SendAbsencePushNotification::class,
SendCalendarEventReservationPushNotification::class,
SendMessagePushNotification::class,
UpdateMessageThreadRecipients::class,
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE push_notification_category ADD VALUE 'CALENDAR_EVENT_RESERVATION';
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions service/src/main/resources/migrations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,5 @@ V460__modification_metadata.sql
V461__replacement_invoices.sql
V462__drop_table_holiday.sql
V463__calendar_event_modified_by.sql
V464__push_notification_calendar_event_reservation.sql
V465__push_notification_calendar_event_reservation_defaults.sql

0 comments on commit ca8814c

Please sign in to comment.