From 326eed3877afb94b27e09917abcd5a9d02ac024a Mon Sep 17 00:00:00 2001 From: Tero Laakso Date: Fri, 24 Jan 2025 16:59:08 +0200 Subject: [PATCH] Allow starting childs as possible recipients --- .../components/messages/MessageEditor.tsx | 12 +++- .../generated/api-clients/messaging.ts | 3 +- .../generated/api-clients/messaging.ts | 3 +- .../messages/MessageEditor.tsx | 7 +- .../src/lib-common/api-types/messaging.ts | 66 +++++++++++++++++-- .../generated/api-types/messaging.ts | 9 +++ .../lib-components/messages/SelectorNode.ts | 45 +++++++++---- .../employee-mobile-frontend/i18n/fi.ts | 1 + .../defaults/employee/i18n/fi.tsx | 3 +- .../main/kotlin/evaka/codegen/api/Config.kt | 6 +- .../MessageReceiversIntegrationTest.kt | 8 +++ .../fi/espoo/evaka/messaging/Message.kt | 10 ++- .../espoo/evaka/messaging/MessageQueries.kt | 42 ++++++++++-- 13 files changed, 182 insertions(+), 33 deletions(-) diff --git a/frontend/src/employee-frontend/components/messages/MessageEditor.tsx b/frontend/src/employee-frontend/components/messages/MessageEditor.tsx index 69464ab75e2..ee54bddcd50 100644 --- a/frontend/src/employee-frontend/components/messages/MessageEditor.tsx +++ b/frontend/src/employee-frontend/components/messages/MessageEditor.tsx @@ -216,6 +216,7 @@ export default React.memo(function MessageEditor({ receiversAsSelectorNode( defaultSender.value, availableReceivers, + i18n.messages.receiverSelection.starters, draftContent?.recipientIds ) ) @@ -312,13 +313,20 @@ export default React.memo(function MessageEditor({ const accountReceivers = receiversAsSelectorNode( sender.value, - availableReceivers + availableReceivers, + i18n.messages.receiverSelection.starters ) if (accountReceivers) { setReceiverTree(accountReceivers) } }, - [availableReceivers, message.type, selectedReceivers, updateMessage] + [ + availableReceivers, + i18n.messages.receiverSelection.starters, + message.type, + selectedReceivers, + updateMessage + ] ) const handleRecipientChange = useCallback( (recipients: SelectorNode[]) => { diff --git a/frontend/src/employee-frontend/generated/api-clients/messaging.ts b/frontend/src/employee-frontend/generated/api-clients/messaging.ts index 0e08236ce5b..344319a1c67 100644 --- a/frontend/src/employee-frontend/generated/api-clients/messaging.ts +++ b/frontend/src/employee-frontend/generated/api-clients/messaging.ts @@ -30,6 +30,7 @@ import { UpdatableDraftContent } from 'lib-common/generated/api-types/messaging' import { client } from '../../api/client' import { createUrlSearchParams } from 'lib-common/api' import { deserializeJsonDraftContent } from 'lib-common/generated/api-types/messaging' +import { deserializeJsonMessageReceiversResponse } from 'lib-common/generated/api-types/messaging' import { deserializeJsonMessageThread } from 'lib-common/generated/api-types/messaging' import { deserializeJsonPagedMessageCopies } from 'lib-common/generated/api-types/messaging' import { deserializeJsonPagedMessageThreads } from 'lib-common/generated/api-types/messaging' @@ -208,7 +209,7 @@ export async function getReceiversForNewMessage(): Promise deserializeJsonMessageReceiversResponse(e)) } diff --git a/frontend/src/employee-mobile-frontend/generated/api-clients/messaging.ts b/frontend/src/employee-mobile-frontend/generated/api-clients/messaging.ts index a186d412e1b..bf8a14bbe7c 100644 --- a/frontend/src/employee-mobile-frontend/generated/api-clients/messaging.ts +++ b/frontend/src/employee-mobile-frontend/generated/api-clients/messaging.ts @@ -28,6 +28,7 @@ import { UpdatableDraftContent } from 'lib-common/generated/api-types/messaging' import { client } from '../../client' import { createUrlSearchParams } from 'lib-common/api' import { deserializeJsonDraftContent } from 'lib-common/generated/api-types/messaging' +import { deserializeJsonMessageReceiversResponse } from 'lib-common/generated/api-types/messaging' import { deserializeJsonMessageThread } from 'lib-common/generated/api-types/messaging' import { deserializeJsonPagedMessageThreads } from 'lib-common/generated/api-types/messaging' import { deserializeJsonPagedSentMessages } from 'lib-common/generated/api-types/messaging' @@ -149,7 +150,7 @@ export async function getReceiversForNewMessage(): Promise deserializeJsonMessageReceiversResponse(e)) } diff --git a/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx b/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx index 8d370fd9a0a..d308b2c1834 100644 --- a/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx +++ b/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx @@ -149,6 +149,7 @@ export default React.memo(function MessageEditor({ recipients: receiversAsSelectorNode( accountId, availableRecipients, + i18n.messages.messageEditor.starters, draft.recipientIds ), urgent: draft.urgent, @@ -156,7 +157,11 @@ export default React.memo(function MessageEditor({ content: draft.content } : { - recipients: receiversAsSelectorNode(accountId, availableRecipients), + recipients: receiversAsSelectorNode( + accountId, + availableRecipients, + i18n.messages.messageEditor.starters + ), urgent: false, title: '', content: '' diff --git a/frontend/src/lib-common/api-types/messaging.ts b/frontend/src/lib-common/api-types/messaging.ts index 88c970bf50f..c52fb7db6d9 100644 --- a/frontend/src/lib-common/api-types/messaging.ts +++ b/frontend/src/lib-common/api-types/messaging.ts @@ -2,12 +2,15 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import { JsonOf } from 'lib-common/json' +import LocalDate from 'lib-common/local-date' import { UUID } from 'lib-common/types' import { MessageRecipientType } from '../generated/api-types/messaging' export type MessageReceiver = - | MessageReceiverBase // unit in area, or child, or citizen + | MessageReceiverBase // unit in area, or citizen + | MessageReceiverChild | MessageReceiverArea | MessageReceiverUnit | MessageReceiverGroup @@ -18,6 +21,10 @@ interface MessageReceiverBase { type: MessageRecipientType } +interface MessageReceiverChild extends MessageReceiverBase { + startDate: LocalDate | null +} + interface MessageReceiverArea extends MessageReceiverBase { receivers: MessageReceiverUnitInArea[] } @@ -26,10 +33,12 @@ type MessageReceiverUnitInArea = MessageReceiverBase interface MessageReceiverUnit extends MessageReceiverBase { receivers: MessageReceiverGroup[] + hasStarters: boolean } interface MessageReceiverGroup extends MessageReceiverBase { receivers: MessageReceiverBase[] + hasStarters: boolean } export const sortReceivers = ( @@ -47,6 +56,55 @@ export const sortReceivers = ( } : receiver ) - .sort((a, b) => - a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()) - ) + .sort((a, b) => { + const aStarter = messageReceiverIsStarter(a) ? 1 : 0 + const bStarter = messageReceiverIsStarter(b) ? 1 : 0 + + if (aStarter !== bStarter) { + return aStarter - bStarter + } + + const aDate = messageReceiverStartDate(a) + const bDate = messageReceiverStartDate(b) + + if (aDate && bDate) { + if (aDate.isBefore(bDate)) return -1 + if (aDate.isAfter(bDate)) return 1 + } else if (aDate && !bDate) { + return -1 + } else if (!aDate && bDate) { + return 1 + } + return a.name + .toLocaleLowerCase() + .localeCompare(b.name.toLocaleLowerCase()) + }) + +export function messageReceiverStartDate( + receiver: MessageReceiver +): LocalDate | null { + return 'startDate' in receiver ? receiver.startDate : null +} + +export function messageReceiverIsStarter(receiver: MessageReceiver): boolean { + return ( + ('hasStarters' in receiver && receiver.hasStarters) || + messageReceiverStartDate(receiver) !== null + ) +} + +export function deserializeMessageReceiver( + json: JsonOf +): MessageReceiver { + const result: MessageReceiver = { + ...json, + ...('startDate' in json && { + startDate: + json.startDate !== null ? LocalDate.parseIso(json.startDate) : null + }), + ...('receivers' in json && { + receivers: json.receivers.map(deserializeMessageReceiver) + }) + } + return result +} diff --git a/frontend/src/lib-common/generated/api-types/messaging.ts b/frontend/src/lib-common/generated/api-types/messaging.ts index 66a5eaa233a..0411abf87eb 100644 --- a/frontend/src/lib-common/generated/api-types/messaging.ts +++ b/frontend/src/lib-common/generated/api-types/messaging.ts @@ -20,6 +20,7 @@ import { MessageId } from './shared' import { MessageReceiver } from '../../api-types/messaging' import { MessageThreadId } from './shared' import { PersonId } from './shared' +import { deserializeMessageReceiver } from '../../api-types/messaging' /** * Generated from fi.espoo.evaka.messaging.AccountType @@ -471,6 +472,14 @@ export function deserializeJsonMessageCopy(json: JsonOf): MessageCo } +export function deserializeJsonMessageReceiversResponse(json: JsonOf): MessageReceiversResponse { + return { + ...json, + receivers: json.receivers.map(e => deserializeMessageReceiver(e)) + } +} + + export function deserializeJsonMessageThread(json: JsonOf): MessageThread { return { ...json, diff --git a/frontend/src/lib-components/messages/SelectorNode.ts b/frontend/src/lib-components/messages/SelectorNode.ts index 734a615abd9..81de068b90a 100644 --- a/frontend/src/lib-components/messages/SelectorNode.ts +++ b/frontend/src/lib-components/messages/SelectorNode.ts @@ -2,7 +2,11 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import { MessageReceiver } from 'lib-common/api-types/messaging' +import { + MessageReceiver, + messageReceiverIsStarter, + messageReceiverStartDate +} from 'lib-common/api-types/messaging' import { MessageReceiversResponse, MessageRecipient @@ -27,6 +31,7 @@ export interface SelectorNode extends TreeNode { export const receiversAsSelectorNode = ( accountId: UUID, receivers: MessageReceiversResponse[], + starterTranslation: string, checkedIds: UUID[] = [] ): SelectorNode[] => { const accountReceivers = receivers.find( @@ -36,9 +41,9 @@ export const receiversAsSelectorNode = ( if (!accountReceivers) { return [] } - - const selectorNodes = accountReceivers.map(receiverAsSelectorNode) - + const selectorNodes = accountReceivers.map((r) => + receiverAsSelectorNode(r, starterTranslation) + ) if (selectorNodes.length === 1 && selectorNodes[0].children.length === 0) { return selectorNodes.map((node) => ({ ...node, checked: true })) } @@ -67,16 +72,28 @@ function checkAll(node: SelectorNode): SelectorNode { return { ...node, checked: true, children: node.children.map(checkAll) } } -const receiverAsSelectorNode = (receiver: MessageReceiver): SelectorNode => ({ - key: receiver.id, - checked: false, - text: receiver.name, - messageRecipient: { type: receiver.type, id: receiver.id }, - children: - 'receivers' in receiver - ? receiver.receivers.map(receiverAsSelectorNode) - : [] -}) +function receiverAsSelectorNode( + receiver: MessageReceiver, + starterTranslation: string +): SelectorNode { + const startDate = messageReceiverStartDate(receiver) + const isStarter = messageReceiverIsStarter(receiver) + const nameWithStarterIndication = isStarter + ? `${receiver.name} (${startDate?.format() ?? starterTranslation})` + : receiver.name + return { + key: `${receiver.id}-${isStarter}`, + checked: false, + text: nameWithStarterIndication, + messageRecipient: { type: receiver.type, id: receiver.id }, + children: + 'receivers' in receiver + ? receiver.receivers.map((r) => + receiverAsSelectorNode(r, starterTranslation) + ) + : [] + } +} export type SelectedNode = { key: UUID diff --git a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts index 67582307839..8cdad0b56dc 100644 --- a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts +++ b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts @@ -444,6 +444,7 @@ export const fi = { sender: 'Lähettäjä', receivers: 'Vastaanottajat', recipientsPlaceholder: 'Valitse...', + starters: 'aloittavat', subject: { heading: 'Otsikko', placeholder: 'Kirjoita...' diff --git a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx index fd567df03b7..ae6f8f3e1b2 100755 --- a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx @@ -4587,7 +4587,8 @@ export const fi = { childName: 'Nimi', childDob: 'Syntymäaika', receivers: 'Vastaanottajat', - confirmText: 'Lähetä viesti valituille' + confirmText: 'Lähetä viesti valituille', + starters: 'aloittavat' }, noTitle: 'Ei otsikkoa', notSent: 'Ei lähetetty', diff --git a/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt b/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt index 0d17e419752..3de48484ed4 100644 --- a/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt +++ b/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt @@ -61,6 +61,8 @@ object Imports { val vasuQuestion = TsImport.Named(LibCommon / "api-types/vasu.ts", "VasuQuestion") val mapVasuQuestion = TsImport.Named(LibCommon / "api-types/vasu.ts", "mapVasuQuestion") val messageReceiver = TsImport.Named(LibCommon / "api-types/messaging.ts", "MessageReceiver") + val deserializeMessageReceiver = + TsImport.Named(LibCommon / "api-types/messaging.ts", "deserializeMessageReceiver") val uuid = TsImport.Named(LibCommon / "types.d.ts", "UUID") val action = TsImport.Named(LibCommon / "generated/action.ts", "Action") val jsonOf = TsImport.Named(LibCommon / "json.d.ts", "JsonOf") @@ -184,7 +186,9 @@ val defaultMetadata = TsExternalTypeRef( "MessageReceiver", keyRepresentation = null, - deserializeJson = null, + deserializeJson = { json -> + TsCode { "${ref(Imports.deserializeMessageReceiver)}(${inline(json)})" } + }, serializePathVariable = null, serializeRequestParam = null, Imports.messageReceiver, diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageReceiversIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageReceiversIntegrationTest.kt index e371876cb06..b35c45e04f1 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageReceiversIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageReceiversIntegrationTest.kt @@ -128,17 +128,20 @@ class MessageReceiversIntegrationTest : FullApplicationTest(resetDbBeforeEach = MessageReceiver.Unit( id = daycare1.id, name = daycare1.name, + hasStarters = false, receivers = listOf( MessageReceiver.Group( id = group1.id, name = group1.name, + hasStarters = false, receivers = listOf( MessageReceiver.Child( id = child1.id, name = "${child1.lastName} ${child2.firstName}", + startDate = null, ) ), ) @@ -153,11 +156,13 @@ class MessageReceiversIntegrationTest : FullApplicationTest(resetDbBeforeEach = MessageReceiver.Group( id = group1.id, name = group1.name, + hasStarters = false, receivers = listOf( MessageReceiver.Child( id = child1.id, name = "${child1.lastName} ${child1.firstName}", + startDate = null, ) ), ) @@ -182,17 +187,20 @@ class MessageReceiversIntegrationTest : FullApplicationTest(resetDbBeforeEach = MessageReceiver.Unit( id = daycare2.id, name = daycare2.name, + hasStarters = false, receivers = listOf( MessageReceiver.Group( id = group2.id, name = group2.name, + hasStarters = false, receivers = listOf( MessageReceiver.Child( id = child2.id, name = "${child2.lastName} ${child2.firstName}", + startDate = null, ) ), ) diff --git a/service/src/main/kotlin/fi/espoo/evaka/messaging/Message.kt b/service/src/main/kotlin/fi/espoo/evaka/messaging/Message.kt index 4950013de89..7b3080c9d2d 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/messaging/Message.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/messaging/Message.kt @@ -22,6 +22,7 @@ import fi.espoo.evaka.shared.config.SealedSubclassSimpleName import fi.espoo.evaka.shared.db.DatabaseEnum import fi.espoo.evaka.shared.domain.FiniteDateRange import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import java.time.LocalDate import org.jdbi.v3.core.mapper.Nested import org.jdbi.v3.core.mapper.PropagateNull import org.jdbi.v3.json.Json @@ -156,16 +157,21 @@ sealed class MessageReceiver(val type: MessageRecipientType) { override val id: DaycareId, override val name: String, val receivers: List, + val hasStarters: Boolean, ) : MessageReceiver(MessageRecipientType.UNIT) data class Group( override val id: GroupId, override val name: String, val receivers: List, + val hasStarters: Boolean, ) : MessageReceiver(MessageRecipientType.GROUP) - data class Child(override val id: ChildId, override val name: String) : - MessageReceiver(MessageRecipientType.CHILD) + data class Child( + override val id: ChildId, + override val name: String, + val startDate: LocalDate?, + ) : MessageReceiver(MessageRecipientType.CHILD) data class Citizen(override val id: PersonId, override val name: String) : MessageReceiver(MessageRecipientType.CITIZEN) diff --git a/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt index 214bdfc71c4..e4f71a1d013 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt @@ -1108,6 +1108,7 @@ data class UnitMessageReceiversResult( val childId: ChildId, val firstName: String, val lastName: String, + val startDate: LocalDate?, ) data class MunicipalMessageReceiversResult( @@ -1131,8 +1132,13 @@ fun Database.Read.getReceiversForNewMessage( WHERE ${predicate(idFilter.forTable("message_account"))} AND type = ANY('{PERSONAL,GROUP}'::message_account_type[]) + ), starting_children as( + SELECT p.child_id, p.unit_id, dgp.daycare_group_id AS group_id, COALESCE(dgp.start_date, p.start_date) AS start_date + FROM placement p + LEFT JOIN daycare_group_placement dgp ON p.id = dgp.daycare_placement_id + WHERE p.start_date > ${bind(today)} OR dgp.start_date > ${bind(today)} ), children AS ( - SELECT a.id AS account_id, p.child_id, NULL AS unit_id, NULL AS unit_name, p.group_id, g.name AS group_name + SELECT a.id AS account_id, p.child_id, NULL AS unit_id, NULL AS unit_name, p.group_id, g.name AS group_name, NULL::date AS start_date FROM accounts a JOIN realized_placement_all(${bind(today)}) p ON a.daycare_group_id = p.group_id JOIN daycare d ON p.unit_id = d.id @@ -1140,14 +1146,33 @@ fun Database.Read.getReceiversForNewMessage( WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) UNION ALL - - SELECT a.id AS account_id, p.child_id, p.unit_id, d.name AS unit_name, p.group_id, g.name AS group_name + + SELECT a.id AS account_id, p.child_id, p.unit_id, d.name AS unit_name, p.group_id, g.name AS group_name, NULL::date AS start_date FROM accounts a JOIN daycare_acl_view acl ON a.employee_id = acl.employee_id JOIN daycare d ON acl.daycare_id = d.id JOIN daycare_group g ON d.id = g.daycare_id JOIN realized_placement_all(${bind(today)}) p ON g.id = p.group_id WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) + + UNION ALL + + SELECT a.id AS account_id, p.child_id, NULL AS unit_id, NULL AS unit_name, p.group_id, g.name AS group_name, p.start_date + FROM accounts a + JOIN starting_children p ON a.daycare_group_id = p.group_id + JOIN daycare d ON p.unit_id = d.id + JOIN daycare_group g ON p.group_id = g.id + WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) + + UNION ALL + + SELECT a.id AS account_id, p.child_id, p.unit_id, d.name AS unit_name, p.group_id, g.name AS group_name, p.start_date + FROM accounts a + JOIN daycare_acl_view acl ON a.employee_id = acl.employee_id + JOIN daycare d ON acl.daycare_id = d.id + JOIN starting_children p ON d.id = p.unit_id + LEFT JOIN daycare_group g ON p.group_id = g.id + WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) ) SELECT DISTINCT c.account_id, @@ -1156,6 +1181,7 @@ fun Database.Read.getReceiversForNewMessage( c.group_id, c.group_name, c.child_id, + c.start_date, p.first_name, p.last_name FROM children c @@ -1177,8 +1203,9 @@ fun Database.Read.getReceiversForNewMessage( } .toList() .groupBy { it.accountId } - .map { (accountId, receivers) -> - val units = receivers.groupBy { it.unitId to it.unitName } + .map { (groupKey, receivers) -> + val units = + receivers.groupBy { Triple(it.unitId, it.unitName, it.startDate != null) } val accountReceivers = units.flatMap { (unit, groups) -> val (unitId, unitName) = unit @@ -1188,11 +1215,12 @@ fun Database.Read.getReceiversForNewMessage( MessageReceiver.Unit( id = unitId, name = unitName, + hasStarters = groups.any { it.startDate != null }, receivers = getReceiverGroups(groups), ) ) } - MessageReceiversResponse(accountId = accountId, receivers = accountReceivers) + MessageReceiversResponse(accountId = groupKey, receivers = accountReceivers) } val municipalReceivers = @@ -1248,11 +1276,13 @@ private fun getReceiverGroups( MessageReceiver.Group( id = groupId, name = groupName, + hasStarters = children.any { it.startDate != null }, receivers = children.map { MessageReceiver.Child( id = it.childId, name = formatName(it.firstName, it.lastName, true), + startDate = it.startDate, ) }, )