Skip to content

Commit

Permalink
Merge pull request #5971 from espoon-voltti/calendar-event-push-notif…
Browse files Browse the repository at this point in the history
…ications

Mobiilinotifikaatio varatusta/perutusta keskusteluajasta
  • Loading branch information
Joosakur authored Nov 22, 2024
2 parents 75d7e48 + 5db7d19 commit acb5831
Show file tree
Hide file tree
Showing 11 changed files with 449 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,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 (
<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
@@ -0,0 +1,181 @@
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

package fi.espoo.evaka.calendarevent

import fi.espoo.evaka.FullApplicationTest
import fi.espoo.evaka.pis.service.insertGuardian
import fi.espoo.evaka.shared.MobileDeviceId
import fi.espoo.evaka.shared.async.AsyncJob
import fi.espoo.evaka.shared.async.AsyncJobRunner
import fi.espoo.evaka.shared.auth.CitizenAuthLevel
import fi.espoo.evaka.shared.auth.UserRole
import fi.espoo.evaka.shared.dev.DevCalendarEvent
import fi.espoo.evaka.shared.dev.DevCalendarEventAttendee
import fi.espoo.evaka.shared.dev.DevCalendarEventTime
import fi.espoo.evaka.shared.dev.DevCareArea
import fi.espoo.evaka.shared.dev.DevDaycare
import fi.espoo.evaka.shared.dev.DevDaycareGroup
import fi.espoo.evaka.shared.dev.DevDaycareGroupPlacement
import fi.espoo.evaka.shared.dev.DevEmployee
import fi.espoo.evaka.shared.dev.DevMobileDevice
import fi.espoo.evaka.shared.dev.DevPerson
import fi.espoo.evaka.shared.dev.DevPersonType
import fi.espoo.evaka.shared.dev.DevPlacement
import fi.espoo.evaka.shared.dev.insert
import fi.espoo.evaka.shared.domain.FiniteDateRange
import fi.espoo.evaka.shared.domain.MockEvakaClock
import fi.espoo.evaka.shared.security.PilotFeature
import fi.espoo.evaka.webpush.MockWebPushEndpoint
import fi.espoo.evaka.webpush.PushNotificationCategory
import fi.espoo.evaka.webpush.WebPushCrypto
import fi.espoo.evaka.webpush.WebPushSubscription
import fi.espoo.evaka.webpush.upsertPushGroup
import fi.espoo.evaka.webpush.upsertPushSubscription
import java.net.URI
import java.security.SecureRandom
import java.time.LocalTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

class CalendarEventPushNotificationsTest : FullApplicationTest(resetDbBeforeEach = true) {
private val clock = MockEvakaClock(2023, 1, 2, 12, 0) // monday
private val keyPair = WebPushCrypto.generateKeyPair(SecureRandom())

@Autowired private lateinit var mockEndpoint: MockWebPushEndpoint
@Autowired private lateinit var calendarEventController: CalendarEventController
@Autowired private lateinit var asyncJobRunner: AsyncJobRunner<AsyncJob>

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),
),
)
}
}
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,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(),
)
}
Expand Down Expand Up @@ -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(
Expand All @@ -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(),
)
}
Expand Down
Loading

0 comments on commit acb5831

Please sign in to comment.