diff --git a/frontend/src/citizen-frontend/messages/ThreadView.tsx b/frontend/src/citizen-frontend/messages/ThreadView.tsx index 878cd2d0b06..42838273f6b 100644 --- a/frontend/src/citizen-frontend/messages/ThreadView.tsx +++ b/frontend/src/citizen-frontend/messages/ThreadView.tsx @@ -321,6 +321,7 @@ export default React.memo( {children.map((child) => ( { // The user has access to all groups, but only the one whose messages are viewed should be available in the // message editor await messageEditor.recipients.open() - await messageEditor.recipients.option(daycareGroup.id).click() - await messageEditor.recipients.option(daycareGroup2.id).waitUntilHidden() - await messageEditor.recipients.option(daycareGroup3.id).waitUntilHidden() + await messageEditor.recipients.option(`${daycareGroup.id}+false`).click() + await messageEditor.recipients + .option(`${daycareGroup2.id}+false`) + .waitUntilHidden() + await messageEditor.recipients + .option(`${daycareGroup3.id}+false`) + .waitUntilHidden() const message = { title: 'Otsikko', content: 'Testiviestin sisältö' } await messageEditor.fillMessage(message) @@ -401,7 +405,7 @@ describe('Messages page', () => { test('Employee sees sent messages', async () => { await staffStartsNewMessage() await messageEditor.recipients.open() - await messageEditor.recipients.option(daycareGroup.id).click() + await messageEditor.recipients.option(`${daycareGroup.id}+false`).click() const message = { title: 'Otsikko', content: 'Testiviestin sisältö' } await messageEditor.fillMessage(message) await messageEditor.send.click() @@ -419,7 +423,7 @@ describe('Messages page', () => { test('Employee sees a draft message and can send it', async () => { let messageEditor = await staffStartsNewMessage() await messageEditor.recipients.open() - await messageEditor.recipients.option(daycareGroup.id).click() + await messageEditor.recipients.option(`${daycareGroup.id}+false`).click() const message = { title: 'Otsikko', content: 'Testiviestin sisältö' } await messageEditor.fillMessage(message) await messageEditor.close.click() @@ -442,7 +446,7 @@ describe('Messages page', () => { test('Employee can discard a draft message', async () => { let messageEditor = await staffStartsNewMessage() await messageEditor.recipients.open() - await messageEditor.recipients.option(daycareGroup.id).click() + await messageEditor.recipients.option(`${daycareGroup.id}+false`).click() const message = { title: 'Otsikko', content: 'Testiviestin sisältö' } await messageEditor.fillMessage(message) await messageEditor.close.click() diff --git a/frontend/src/e2e-test/specs/7_messaging/messaging-by-staff.spec.ts b/frontend/src/e2e-test/specs/7_messaging/messaging-by-staff.spec.ts index 26e5f23f148..1fe49641539 100644 --- a/frontend/src/e2e-test/specs/7_messaging/messaging-by-staff.spec.ts +++ b/frontend/src/e2e-test/specs/7_messaging/messaging-by-staff.spec.ts @@ -6,6 +6,7 @@ import { PersonId } from 'lib-common/generated/api-types/shared' import HelsinkiDateTime from 'lib-common/helsinki-date-time' import LocalDate from 'lib-common/local-date' import LocalTime from 'lib-common/local-time' +import { formatFirstName } from 'lib-common/names' import config from '../../config' import { runPendingAsyncJobs } from '../../dev-api' @@ -17,7 +18,8 @@ import { testChild, testChild2, testDaycare, - testDaycareGroup + testDaycareGroup, + testPreschool } from '../../dev-api/fixtures' import { createDaycareGroups, @@ -61,6 +63,7 @@ beforeEach(async () => { await resetServiceState() await Fixture.careArea(testCareArea).save() await Fixture.daycare(testDaycare).save() + await Fixture.daycare(testPreschool).save() await Fixture.family({ guardian: testAdult, children: [testChild, testChild2] @@ -78,7 +81,8 @@ beforeEach(async () => { .save() unitSupervisor = await Fixture.employee() - .unitSupervisor(testDaycare.id) + .withDaycareAcl(testDaycare.id, 'UNIT_SUPERVISOR') + .withDaycareAcl(testPreschool.id, 'UNIT_SUPERVISOR') .save() const unitId = testDaycare.id @@ -227,6 +231,155 @@ describe('Sending and receiving messages', () => { }) } ) + + test('Unit supervisor can send a message to a starter', async () => { + const daycarePlacementFixture1 = await Fixture.placement({ + childId, + unitId: testPreschool.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + const preschoolGroup = await Fixture.daycareGroup({ + daycareId: testPreschool.id, + name: 'Esiopetusryhmä' + }).save() + await Fixture.groupPlacement({ + daycarePlacementId: daycarePlacementFixture1.id, + daycareGroupId: preschoolGroup.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + + // Verify that available recipients contain current placements and starters + await initUnitSupervisorPage(mockedDateAt10) + await unitSupervisorPage.goto(`${config.employeeUrl}/messages`) + const messagesPage = new MessagesPage(unitSupervisorPage) + const messageEditor = await messagesPage.openMessageEditor() + const receiverSelector = messageEditor.receiverSelection + await receiverSelector.open() + await receiverSelector.expandAll() // open first level + await receiverSelector.expandAll() // open second level + const labels = await receiverSelector.labels.allTexts() + const starterChildLabel = `${testChild.lastName} ${testChild.firstName} (${mockedDate.addYears(1).addDays(1).format()})` + const expectedReceiverNames = [ + testDaycare.name, + testDaycareGroup.name, + `${testChild.lastName} ${testChild.firstName}`, + `${testChild2.lastName} ${testChild2.firstName}`, + `${testPreschool.name} (aloittavat)`, + `${preschoolGroup.name} (aloittavat)`, + starterChildLabel + ] + expect(labels.sort()).toEqual(expectedReceiverNames.sort()) + + // Send a message to a starter child -> selects the whole unit + await receiverSelector.optionByLabel(starterChildLabel).click() + await receiverSelector.close() + await messageEditor.inputTitle.fill('Aloittavalle otsikko') + await messageEditor.inputContent.fill('Sisältö') + await messageEditor.sendButton.click() + await messageEditor.waitUntilHidden() + + await runPendingAsyncJobs(mockedDateAt10.addMinutes(1)) + + // Verify that the message is received by the starter child + await initCitizenPage(mockedDateAt11) + await citizenPage.goto(config.enduserMessagesUrl) + const citizenMessagesPage = new CitizenMessagesPage(citizenPage) + await citizenMessagesPage.assertThreadContent({ + title: 'Aloittavalle otsikko', + content: 'Sisältö' + }) + }) + + test('Staff can send a message to a starter', async () => { + const secondGroup = await Fixture.daycareGroup({ + daycareId: testDaycare.id, + name: 'Toinen ryhmä' + }).save() + const futureStaff = await Fixture.employee() + .staff(testDaycare.id) + .withGroupAcl(secondGroup.id, mockedDateAt10, mockedDateAt10) + .save() + const daycarePlacementFixture1 = await Fixture.placement({ + childId, + unitId: testDaycare.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + const daycarePlacementFixture2 = await Fixture.placement({ + childId: testChild2.id, + unitId: testDaycare.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + await Fixture.groupPlacement({ + daycarePlacementId: daycarePlacementFixture1.id, + daycareGroupId: secondGroup.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + await Fixture.groupPlacement({ + daycarePlacementId: daycarePlacementFixture2.id, + daycareGroupId: secondGroup.id, + startDate: mockedDate.addYears(1).addDays(1), + endDate: mockedDate.addYears(2) + }).save() + await createMessageAccounts() + + // Verify that available recipients contain current placements and starters + staffPage = await Page.open({ mockedTime: mockedDateAt10 }) + await employeeLogin(staffPage, futureStaff) + await staffPage.goto(`${config.employeeUrl}/messages`) + const messagesPage = new MessagesPage(staffPage) + const messageEditor = await messagesPage.openMessageEditor() + const receiverSelector = messageEditor.receiverSelection + await receiverSelector.open() + await receiverSelector.expandAll() + const labels = await receiverSelector.labels.allTexts() + const expectedReceiverNames = [ + `${secondGroup.name} (aloittavat)`, + `${testChild.lastName} ${testChild.firstName} (${mockedDate.addYears(1).addDays(1).format()})`, + `${testChild2.lastName} ${testChild2.firstName} (${mockedDate.addYears(1).addDays(1).format()})` + ] + expect(labels.sort()).toEqual(expectedReceiverNames.sort()) + + // Send a message to a starter group (contains 2 children) + await receiverSelector + .optionByLabel(`${secondGroup.name} (aloittavat)`) + .click() + await receiverSelector.close() + await messageEditor.inputTitle.fill('Aloittavalle otsikko') + await messageEditor.inputContent.fill('Sisältö') + await messageEditor.sendButton.click() + await messageEditor.waitUntilHidden() + + await runPendingAsyncJobs(mockedDateAt10.addMinutes(1)) + + // Verify that the message is received by the starter + await initCitizenPage(mockedDateAt11) + await citizenPage.goto(config.enduserMessagesUrl) + const citizenMessagesPage = new CitizenMessagesPage(citizenPage) + await citizenMessagesPage.assertThreadContent({ + title: 'Aloittavalle otsikko', + content: 'Sisältö', + childNames: [formatFirstName(testChild), formatFirstName(testChild2)] + }) + + // Reply to the message + await citizenMessagesPage.replyToFirstThread('Vastaukseni') + + await runPendingAsyncJobs(mockedDateAt11.addMinutes(1)) + + // Verify that the message is received by the staff + staffPage = await Page.open({ mockedTime: mockedDateAt12 }) + await employeeLogin(staffPage, futureStaff) + await staffPage.goto(`${config.employeeUrl}/messages`) + const staffMessagesPage = new MessagesPage(staffPage) + await waitUntilEqual(() => staffMessagesPage.getReceivedMessageCount(), 1) + await staffMessagesPage.receivedMessage.click() + await staffMessagesPage.assertMessageContent(1, 'Vastaukseni') + }) }) describe('Sending and receiving sensitive messages', () => { @@ -241,7 +394,7 @@ describe('Sending and receiving sensitive messages', () => { const sensitiveMessage = { ...defaultMessage, sensitive: true, - receivers: [testChild2.id] + receiverKeys: [`${testChild2.id}+false`] } await initStaffPage(mockedDateAt10) @@ -271,7 +424,7 @@ describe('Staff copies', () => { const message = { title: 'Ilmoitus', content: 'Ilmoituksen sisältö', - receivers: [testDaycare.id], + receiverKeys: [`${testDaycare.id}+false`], type: 'BULLETIN' as const } const messageEditor = await new MessagesPage( @@ -294,12 +447,12 @@ describe('Staff copies', () => { const message = { title: 'Ilmoitus', content: 'Ilmoituksen sisältö', - receivers: [testChild2.id] + receiverKeys: [`${testChild2.id}+false`] } const bulletin = { title: 'Ilmoitus', content: 'Ilmoituksen sisältö', - receivers: [testChild2.id], + receiverKeys: [`${testChild2.id}+false`], type: 'BULLETIN' as const } const messagesPage = new MessagesPage(unitSupervisorPage) @@ -321,7 +474,7 @@ describe('Staff copies', () => { title: 'Ilmoitus', content: 'Ilmoituksen sisältö', sender: `${testDaycare.name} - ${testDaycareGroup.name}`, - receivers: [testDaycareGroup.id] + receiverKeys: [`${testDaycareGroup.id}+false`] } const messageEditor = await new MessagesPage( unitSupervisorPage @@ -373,7 +526,7 @@ describe('Additional filters', () => { const message = { title: 'Ilmoitus rajatulle joukolle', content: 'Ilmoituksen sisältö rajatulle joukolle', - receivers: [testDaycare.id], + receiverKeys: [`${testDaycare.id}+false`], yearsOfBirth: [2014] } const messageEditor = await new MessagesPage( @@ -406,7 +559,7 @@ describe('Additional filters', () => { const message = { title: 'Ilmoitus rajatulle joukolle', content: 'Ilmoituksen sisältö rajatulle joukolle', - receivers: [testDaycare.id] + receiverKeys: [`${testDaycare.id}+false`] } let messageEditor = await new MessagesPage( unitSupervisorPage diff --git a/frontend/src/e2e-test/specs/7_messaging/messaging.spec.ts b/frontend/src/e2e-test/specs/7_messaging/messaging.spec.ts index 443e151024e..58fb2d4423a 100644 --- a/frontend/src/e2e-test/specs/7_messaging/messaging.spec.ts +++ b/frontend/src/e2e-test/specs/7_messaging/messaging.spec.ts @@ -249,7 +249,7 @@ describe('Sending and receiving messages', () => { const messageEditor = await messagesPage.openMessageEditor() await messageEditor.sendNewMessage({ ...defaultMessage, - receivers: [testChild2.id] + receiverKeys: [`${testChild2.id}+false`] }) await runPendingAsyncJobs(mockedDateAt10.addMinutes(1)) @@ -763,12 +763,12 @@ describe('Sending and receiving messages', () => { await openCitizen(mockedDateAt11) await citizenPage.goto(config.enduserMessagesUrl) const citizenMessagesPage = new CitizenMessagesPage(citizenPage) - await citizenMessagesPage.openFirstThreadReplyEditor() + await citizenMessagesPage.startReplyToFirstThread() await citizenMessagesPage.discardMessageButton.waitUntilVisible() await citizenMessagesPage.messageReplyContent.fill(defaultContent) await citizenMessagesPage.discardReplyEditor() await citizenMessagesPage.discardMessageButton.waitUntilHidden() - await citizenMessagesPage.openFirstThreadReplyEditor() + await citizenMessagesPage.startReplyToFirstThread() await citizenMessagesPage.messageReplyContent.assertTextEquals('') }) diff --git a/frontend/src/e2e-test/specs/7_messaging/municipal-messaging.spec.ts b/frontend/src/e2e-test/specs/7_messaging/municipal-messaging.spec.ts index de0cb55cf59..ba05334097a 100644 --- a/frontend/src/e2e-test/specs/7_messaging/municipal-messaging.spec.ts +++ b/frontend/src/e2e-test/specs/7_messaging/municipal-messaging.spec.ts @@ -153,7 +153,7 @@ describe('Municipal messaging -', () => { const messageEditor = await messagesPage.openMessageEditor() await messageEditor.sendNewMessage({ ...defaultMessage, - receivers: [testCareArea.id, testCareArea2.id], + receiverKeys: [`${testCareArea.id}+false`, `${testCareArea2.id}+false`], confirmManyRecipients: true }) await runPendingAsyncJobs(messageSendTime.addMinutes(1)) @@ -171,7 +171,7 @@ describe('Municipal messaging -', () => { const messageEditor = await messagesPage.openMessageEditor() await messageEditor.sendNewMessage({ ...defaultMessage, - receivers: [testCareArea.id] + receiverKeys: [`${testCareArea.id}+false`] }) const sentMessagesPage = await messagesPage.openSentMessages() @@ -188,7 +188,7 @@ describe('Municipal messaging -', () => { const messageEditor = await messagesPage.openMessageEditor() await messageEditor.sendNewMessage({ ...defaultMessage, - receivers: [testCareArea.id] + receiverKeys: [`${testCareArea.id}+false`] }) await runPendingAsyncJobs(messageSendTime.addMinutes(1)) diff --git a/frontend/src/e2e-test/specs/7_messaging/out-of-office.spec.ts b/frontend/src/e2e-test/specs/7_messaging/out-of-office.spec.ts index bac8b43b474..cf634efac0a 100644 --- a/frontend/src/e2e-test/specs/7_messaging/out-of-office.spec.ts +++ b/frontend/src/e2e-test/specs/7_messaging/out-of-office.spec.ts @@ -96,7 +96,7 @@ describe('Out of Office', () => { await editor2.sendMessage() // Reply editor shows the out of office period - await messagesPage2.openFirstThreadReplyEditor() + await messagesPage2.startReplyToFirstThread() await messagesPage2.assertThreadOutOfOffice({ name: getSupervisorName(), period: FiniteDateRange.tryCreate(newStartDate, endDate)! diff --git a/frontend/src/e2e-test/utils/page.ts b/frontend/src/e2e-test/utils/page.ts index dadacd8e21c..804300b7aca 100644 --- a/frontend/src/e2e-test/utils/page.ts +++ b/frontend/src/e2e-test/utils/page.ts @@ -170,6 +170,13 @@ export class ElementCollection { async assertTextsEqual(values: string[]) { await waitUntilEqual(() => this.allTexts(), values) } + + async assertTextsEqualAnyOrder(values: string[]) { + await waitUntilEqual(async () => { + const texts = await this.allTexts() + return texts.sort() + }, values.slice().sort()) + } } export class Element { @@ -594,6 +601,7 @@ export class Modal extends Element { export class TreeDropdown extends Element { values = this.findByDataQa('selected-values').findAllByDataQa('value') + labels = this.findByDataQa('select-receiver-tree').findAll('label') private async expanded(): Promise { return ( @@ -623,6 +631,10 @@ export class TreeDropdown extends Element { return new Checkbox(this.findAll(`[data-qa*="tree-checkbox-"]`).nth(0)) } + optionByLabel(label: string): Element { + return this.labels.find(`text=${label}`) + } + async expandOption(key: string): Promise { await this.findByDataQa(`tree-toggle-${key}`).click() } diff --git a/frontend/src/employee-frontend/components/messages/MessageEditor.tsx b/frontend/src/employee-frontend/components/messages/MessageEditor.tsx index 69464ab75e2..3ff1ebd3e43 100644 --- a/frontend/src/employee-frontend/components/messages/MessageEditor.tsx +++ b/frontend/src/employee-frontend/components/messages/MessageEditor.tsx @@ -47,7 +47,8 @@ import { getSelected, receiversAsSelectorNode, SelectedNode, - SelectorNode + SelectorNode, + selectedNodeToReceiver } from 'lib-components/messages/SelectorNode' import { SaveDraftParams } from 'lib-components/messages/types' import { Draft, useDraft } from 'lib-components/messages/useDraft' @@ -80,22 +81,22 @@ import { useTranslation } from '../../state/i18n' import { createMessagePreflightCheckQuery } from './queries' -type Message = Omit< - UpdatableDraftContent, - 'recipientIds' | 'recipientNames' -> & { +type Message = Omit & { sender: SelectOption attachments: Attachment[] } const messageToUpdatableDraftWithAccount = ( m: Message, - recipients: { key: string; text: string }[] + recipients: { id: string; isStarter: boolean; text: string }[] ): Draft => ({ content: m.content, urgent: m.urgent, sensitive: false, - recipientIds: recipients.map(({ key }) => key), + recipients: recipients.map((r) => ({ + accountId: r.id, + starter: r.isStarter + })), recipientNames: recipients.map(({ text }) => text), title: m.title, type: m.type, @@ -216,7 +217,8 @@ export default React.memo(function MessageEditor({ receiversAsSelectorNode( defaultSender.value, availableReceivers, - draftContent?.recipientIds + i18n.messages.receiverSelection.starters, + draftContent?.recipients ) ) const [filtersVisible, useFiltersVisible] = useBoolean(false) @@ -249,7 +251,9 @@ export default React.memo(function MessageEditor({ (changes) => { const updatedMessage = { ...message, ...changes } setMessage(updatedMessage) - const selectedReceivers = getSelected(receiverTree) + const selectedReceivers = getSelected(receiverTree).map( + selectedNodeToReceiver + ) setDraft( messageToUpdatableDraftWithAccount(updatedMessage, selectedReceivers) ) @@ -312,33 +316,40 @@ 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[]) => { setReceiverTree(recipients) const selected = getSelected(recipients) - + const selectedAsReceivers = selected.map(selectedNodeToReceiver) const shouldResetSensitivity = !shouldSensitiveCheckboxBeEnabled( selected, message.type, senderAccountType ) - const updatedMessage = { + const updatedMessage: Message = { ...message, - recipientIds: selected.map((s) => s.key), - recipientNames: selected.map((s) => s.text), sensitive: shouldResetSensitivity ? false : message.sensitive } setMessage(updatedMessage) - setDraft(messageToUpdatableDraftWithAccount(updatedMessage, selected)) + setDraft( + messageToUpdatableDraftWithAccount(updatedMessage, selectedAsReceivers) + ) }, [message, senderAccountType, setDraft] ) 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..a0993b5f533 100644 --- a/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx +++ b/frontend/src/employee-mobile-frontend/messages/MessageEditor.tsx @@ -34,6 +34,7 @@ import { ContentArea } from 'lib-components/layout/Container' import { getSelected, receiversAsSelectorNode, + selectedNodeToReceiver, SelectorNode } from 'lib-components/messages/SelectorNode' import { ConfirmedMutation } from 'lib-components/molecules/ConfirmedMutation' @@ -101,7 +102,10 @@ const messageForm = mapped( }, draftContent: (): UpdatableDraftContent => ({ ...commonContent, - recipientIds: selectedRecipients.map((r) => r.messageRecipient.id), + recipients: selectedRecipients.map(selectedNodeToReceiver).map((r) => ({ + accountId: r.id, + starter: r.isStarter + })), recipientNames: selectedRecipients.map((r) => r.text) }) } @@ -149,14 +153,19 @@ export default React.memo(function MessageEditor({ recipients: receiversAsSelectorNode( accountId, availableRecipients, - draft.recipientIds + i18n.messages.messageEditor.starters, + draft.recipients ), urgent: draft.urgent, title: draft.title, 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..909ef625020 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,49 @@ 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) { + return aDate.compareTo(bDate) + } + 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..8dc1b4302d5 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 @@ -110,8 +111,8 @@ export interface DraftContent { content: string createdAt: HelsinkiDateTime id: MessageDraftId - recipientIds: string[] recipientNames: string[] + recipients: SelectableRecipient[] sensitive: boolean title: string type: MessageType @@ -214,6 +215,7 @@ export interface MessageReceiversResponse { */ export interface MessageRecipient { id: string + starter: boolean type: MessageRecipientType } @@ -343,6 +345,14 @@ export interface ReplyToMessageBody { recipientAccountIds: MessageAccountId[] } +/** +* Generated from fi.espoo.evaka.messaging.SelectableRecipient +*/ +export interface SelectableRecipient { + accountId: string + starter: boolean +} + /** * Generated from fi.espoo.evaka.messaging.SentMessage */ @@ -397,8 +407,8 @@ export interface UnreadCountByAccountAndGroup { */ export interface UpdatableDraftContent { content: string - recipientIds: string[] recipientNames: string[] + recipients: SelectableRecipient[] sensitive: boolean title: string type: MessageType @@ -471,6 +481,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..c83a434f5bf 100644 --- a/frontend/src/lib-components/messages/SelectorNode.ts +++ b/frontend/src/lib-components/messages/SelectorNode.ts @@ -2,10 +2,15 @@ // // 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 + MessageRecipient, + SelectableRecipient } from 'lib-common/generated/api-types/messaging' import { UUID } from 'lib-common/types' import { TreeNode } from 'lib-components/atoms/dropdowns/TreeDropdown' @@ -27,7 +32,8 @@ export interface SelectorNode extends TreeNode { export const receiversAsSelectorNode = ( accountId: UUID, receivers: MessageReceiversResponse[], - checkedIds: UUID[] = [] + starterTranslation: string, + checkedRecipients: SelectableRecipient[] = [] ): SelectorNode[] => { const accountReceivers = receivers.find( (receiver) => receiver.accountId === accountId @@ -36,24 +42,31 @@ 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 })) } - if (checkedIds.length > 0) { - return selectorNodes.map((node) => checkSelected(checkedIds, node)) + if (checkedRecipients.length > 0) { + return selectorNodes.map((node) => checkSelected(checkedRecipients, node)) } return selectorNodes } -function checkSelected(selectedIds: UUID[], node: SelectorNode): SelectorNode { - if (selectedIds.includes(node.key)) { +function checkSelected( + selectedRecipients: SelectableRecipient[], + node: SelectorNode +): SelectorNode { + if ( + selectedRecipients + .map((r) => receiverToKey(r.accountId, r.starter)) + .includes(node.key) + ) { return checkAll(node) } else { const children = node.children.map((child) => - checkSelected(selectedIds, child) + checkSelected(selectedRecipients, child) ) return { ...node, @@ -67,16 +80,53 @@ 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 receiverToKey(accountId: UUID, isStarter: boolean): string { + return `${accountId}+${isStarter}` +} + +function keyToReceiver(key: string): { + accountId: UUID + isStarter: boolean +} { + const [accountId, isStarter] = key.split('+') + return { accountId, isStarter: isStarter === 'true' } +} + +export function selectedNodeToReceiver(node: SelectedNode) { + const r = keyToReceiver(node.key) + return { + id: r.accountId, + isStarter: r.isStarter, + text: node.text + } +} + +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: receiverToKey(receiver.id, isStarter), + checked: false, + text: nameWithStarterIndication, + messageRecipient: { + type: receiver.type, + id: receiver.id, + starter: isStarter + }, + 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 cf2a8a4fdce..2fc35f3949f 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 @@ -445,6 +445,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 75783e9e492..e8c11a6ffed 100755 --- a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx @@ -4607,7 +4607,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/DraftQueriesTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/DraftQueriesTest.kt index d25edea52b0..abcf43eeae4 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/DraftQueriesTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/DraftQueriesTest.kt @@ -38,7 +38,10 @@ class DraftQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { val title = "Hello" val type = MessageType.MESSAGE val recipients = - setOf(MessageAccountId(UUID.randomUUID()), MessageAccountId(UUID.randomUUID())) + setOf( + SelectableRecipient(MessageAccountId(UUID.randomUUID()), false), + SelectableRecipient(MessageAccountId(UUID.randomUUID()), false), + ) val recipientNames = listOf("Auringonkukat", "Hippiäiset") val id = db.transaction { it.initDraft(accountId) } @@ -48,7 +51,7 @@ class DraftQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { type = type, title = title, content = content, - recipientIds = recipients, + recipients = recipients, recipientNames = recipientNames, urgent = false, sensitive = false, @@ -71,7 +74,7 @@ class DraftQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { assertEquals(expected.title, actual.title) assertEquals(expected.type, actual.type) assertEquals(expected.content, actual.content) - assertEquals(expected.recipientIds, actual.recipientIds) + assertEquals(expected.recipients, actual.recipients) assertEquals(expected.recipientNames, actual.recipientNames) } } diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageIntegrationTest.kt index d456bae31cc..8d4fe43e5b6 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageIntegrationTest.kt @@ -1490,7 +1490,7 @@ class MessageIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) { group1Account, "title", "content", - setOf(person2Account), + setOf(SelectableRecipient(person2Account, false)), emptyList(), clock.now().minusDays(updatedDaysAgo.toLong()), ) @@ -1872,7 +1872,7 @@ class MessageIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) { accountId: MessageAccountId, title: String, content: String, - recipientIds: Set, + recipients: Set, recipientNames: List, now: HelsinkiDateTime, ) { @@ -1895,7 +1895,7 @@ class MessageIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) { content, urgent = false, sensitive = false, - recipientIds = recipientIds, + recipients = recipients, recipientNames = recipientNames, ), ) diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageQueriesTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageQueriesTest.kt index 5c4691fecfd..60cbff3e5c5 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageQueriesTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/messaging/MessageQueriesTest.kt @@ -391,6 +391,7 @@ class MessageQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { @Test fun `query citizen receivers for group change over 2 weeks into the future`() { lateinit var group1Account: MessageAccount + lateinit var group2Account: MessageAccount val today = LocalDate.now() val startDate = today.minusDays(30) @@ -399,9 +400,10 @@ class MessageQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { val endDate = today.plusDays(30) db.transaction { tx -> - val (childId, daycareId, group1Id, tempGroup1Account, group2Id) = + val (childId, daycareId, group1Id, tempGroup1Account, group2Id, tempGroup2Account) = prepareDataForReceiversTest(tx) group1Account = tempGroup1Account + group2Account = tempGroup2Account val placementId = tx.insert( @@ -434,13 +436,15 @@ class MessageQueriesTest : PureJdbiTest(resetDbBeforeEach = true) { val receivers = db.read { it.getCitizenReceivers(today, accounts.person1.id).values } val expectedForNewMessage = setOf(group1Account, accounts.employee1) + val expectedForReply = + setOf(group1Account, accounts.employee1, accounts.employee2, group2Account) assertEquals( expectedForNewMessage, receivers.flatMap { r -> r.newMessage.map { a -> a.account } }.toSet(), ) assertEquals( - expectedForNewMessage + accounts.employee2, + expectedForReply, receivers.flatMap { r -> r.reply.map { a -> a.account } }.toSet(), ) } 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/DraftContent.kt b/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftContent.kt index 02467d06a83..d667d3043fb 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftContent.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftContent.kt @@ -10,6 +10,8 @@ import fi.espoo.evaka.shared.MessageDraftId import fi.espoo.evaka.shared.domain.HelsinkiDateTime import org.jdbi.v3.json.Json +data class SelectableRecipient(val accountId: Id<*>, val starter: Boolean) + data class DraftContent( val id: MessageDraftId, val createdAt: HelsinkiDateTime, @@ -18,7 +20,7 @@ data class DraftContent( val content: String, val urgent: Boolean, val sensitive: Boolean, - val recipientIds: Set>, + @Json val recipients: Set, val recipientNames: List, @Json val attachments: List, ) @@ -29,6 +31,6 @@ data class UpdatableDraftContent( val content: String, val urgent: Boolean, val sensitive: Boolean, - val recipientIds: Set>, + val recipients: Set, val recipientNames: List, ) diff --git a/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftQueries.kt index eb07410e882..91468b92c82 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/messaging/DraftQueries.kt @@ -77,7 +77,7 @@ SET urgent = ${bind(draft.urgent)}, sensitive = ${bind(draft.sensitive)}, type = ${bind(draft.type)}, - recipient_ids = ${bind(draft.recipientIds)}, + recipients = ${bindJson(draft.recipients)}, recipient_names = ${bind(draft.recipientNames)}, modified_at = ${bind(now)} WHERE id = ${bind(id)} 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..e8f40f6743a 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) @@ -217,7 +223,11 @@ enum class MessageRecipientType { CITIZEN, } -data class MessageRecipient(val type: MessageRecipientType, val id: Id<*>) { +data class MessageRecipient( + val type: MessageRecipientType, + val id: Id<*>, + val starter: Boolean = false, +) { fun toAreaId(): AreaId? = if (type == MessageRecipientType.AREA) AreaId(id.raw) else null fun toUnitId(): DaycareId? = if (type == MessageRecipientType.UNIT) DaycareId(id.raw) else null 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..5280f447ecb 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/messaging/MessageQueries.kt @@ -764,6 +764,8 @@ fun Database.Read.getCitizenReceivers( val oooPeriod: FiniteDateRange?, ) + val sendNewMessageWeeksBefore = 2L + return createQuery { sql( """ @@ -780,34 +782,44 @@ WITH user_account AS ( FROM user_account acc JOIN foster_parent fp ON acc.person_id = fp.parent_id AND valid_during @> ${bind(today)} ), backup_care_placements AS ( - SELECT p.id, p.unit_id, p.child_id, p.group_id + SELECT p.id, p.unit_id, p.child_id, p.group_id, p.start_date FROM children c - JOIN backup_care p ON p.child_id = c.child_id AND daterange((p.start_date - INTERVAL '2 weeks')::date, p.end_date, '[]') @> ${bind(today)} + JOIN backup_care p ON p.child_id = c.child_id AND p.end_date >= ${bind(today)} WHERE EXISTS ( SELECT 1 FROM daycare u WHERE p.unit_id = u.id AND 'MESSAGING' = ANY(u.enabled_pilot_features) ) ), placements AS ( - SELECT p.id, p.unit_id, p.child_id + SELECT p.id, p.unit_id, p.child_id, p.start_date FROM children c - JOIN placement p ON p.child_id = c.child_id AND daterange((p.start_date - INTERVAL '2 weeks')::date, p.end_date, '[]') @> ${bind(today)} + JOIN placement p ON p.child_id = c.child_id AND p.end_date >= ${bind(today)} AND EXISTS ( SELECT 1 FROM daycare u WHERE p.unit_id = u.id AND 'MESSAGING' = ANY(u.enabled_pilot_features) ) ), relevant_placements AS ( - SELECT p.id, p.unit_id, p.child_id + SELECT p.id, p.unit_id, p.child_id, p.start_date FROM placements p UNION - SELECT bc.id, bc.unit_id, bc.child_id + SELECT bc.id, bc.unit_id, bc.child_id, bc.start_date FROM backup_care_placements bc ), personal_accounts AS ( - SELECT acc.id, acc_name.name, 'PERSONAL' AS type, p.child_id, acl.role != 'UNIT_SUPERVISOR' AS reply_only, ooo.period AS ooo_period - FROM (SELECT DISTINCT unit_id, child_id FROM relevant_placements) p + SELECT + acc.id, + acc_name.name, + 'PERSONAL' AS type, + p.child_id, + (acl.role != 'UNIT_SUPERVISOR' OR ${bind(today.plusWeeks(sendNewMessageWeeksBefore))} < p.start_date) AS reply_only, + ooo.period AS ooo_period + FROM ( + SELECT unit_id, child_id, min(start_date) AS start_date + FROM relevant_placements + GROUP BY unit_id, child_id + ) p JOIN daycare_acl acl ON acl.daycare_id = p.unit_id JOIN message_account acc ON acc.employee_id = acl.employee_id JOIN message_account_view acc_name ON acc_name.id = acc.id @@ -820,15 +832,15 @@ personal_accounts AS ( WHERE active IS TRUE ), group_accounts AS ( - SELECT acc.id, g.name, 'GROUP' AS type, p.child_id + SELECT acc.id, g.name, 'GROUP' AS type, p.child_id, ${bind(today.plusWeeks(sendNewMessageWeeksBefore))} < dgp.start_date AS reply_only FROM placements p - JOIN daycare_group_placement dgp ON dgp.daycare_placement_id = p.id AND ${bind(today)} BETWEEN (dgp.start_date - INTERVAL '2 weeks')::date AND dgp.end_date + JOIN daycare_group_placement dgp ON dgp.daycare_placement_id = p.id AND dgp.end_date >= ${bind(today)} JOIN daycare_group g ON g.id = dgp.daycare_group_id JOIN message_account acc on g.id = acc.daycare_group_id UNION ALL - SELECT acc.id, g.name, 'GROUP' AS type, p.child_id + SELECT acc.id, g.name, 'GROUP' AS type, p.child_id, ${bind(today.plusWeeks(sendNewMessageWeeksBefore))} < p.start_date AS reply_only FROM backup_care_placements p JOIN daycare_group g ON g.id = p.group_id JOIN message_account acc on g.id = acc.daycare_group_id @@ -853,7 +865,7 @@ citizen_accounts AS ( mixed_accounts AS ( SELECT id, name, type, child_id, reply_only, ooo_period FROM personal_accounts UNION ALL - SELECT id, name, type, child_id, FALSE AS reply_only, null as ooo_period FROM group_accounts + SELECT id, name, type, child_id, reply_only, null as ooo_period FROM group_accounts UNION ALL SELECT id, name, type, child_id, FALSE AS reply_only, null as ooo_period FROM citizen_accounts ) @@ -1108,6 +1120,7 @@ data class UnitMessageReceiversResult( val childId: ChildId, val firstName: String, val lastName: String, + val startDate: LocalDate?, ) data class MunicipalMessageReceiversResult( @@ -1131,23 +1144,50 @@ 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, dgp.start_date + FROM placement p + 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 - 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 - JOIN daycare_group g ON p.group_id = g.id - WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) - + WITH current_group_receivers AS ( + SELECT a.id AS account_id, p.child_id, NULL::uuid AS unit_id, NULL::text 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 + JOIN daycare_group g ON p.group_id = g.id + WHERE 'MESSAGING' = ANY(d.enabled_pilot_features) + ), current_unit_receivers AS ( + 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) + ), starting_group_receivers AS ( + SELECT a.id AS account_id, p.child_id, NULL::uuid AS unit_id, NULL::text 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) + ), starting_unit_receivers AS ( + 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 * FROM current_group_receivers 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 - 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) + SELECT * FROM current_unit_receivers + UNION ALL + SELECT * FROM starting_group_receivers + UNION ALL + SELECT * FROM starting_unit_receivers ) SELECT DISTINCT c.account_id, @@ -1156,6 +1196,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 @@ -1163,10 +1204,8 @@ fun Database.Read.getReceiversForNewMessage( WHERE EXISTS ( SELECT 1 FROM guardian g - WHERE g.child_id = c.child_id - - UNION ALL - + WHERE g.child_id = c.child_id) + OR EXISTS ( SELECT 1 FROM foster_parent fp WHERE fp.child_id = c.child_id AND fp.valid_during @> ${bind(today)} @@ -1177,8 +1216,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 +1228,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 +1289,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, ) }, ) @@ -1264,13 +1307,21 @@ fun Database.Read.getMessageAccountsForRecipients( filters: MessageController.PostMessageFilters?, date: LocalDate, ): List> { - val groupedRecipients = recipients.groupBy { it.type } - val areaRecipients = groupedRecipients[MessageRecipientType.AREA]?.map { it.id } ?: listOf() - val unitRecipients = groupedRecipients[MessageRecipientType.UNIT]?.map { it.id } ?: listOf() - val groupRecipients = groupedRecipients[MessageRecipientType.GROUP]?.map { it.id } ?: listOf() - val childRecipients = groupedRecipients[MessageRecipientType.CHILD]?.map { it.id } ?: listOf() - val citizenRecipients = - groupedRecipients[MessageRecipientType.CITIZEN]?.map { it.id } ?: listOf() + + fun getRecipientIdsByType( + recipients: List + ): Map>> { + val recipientMap = + MessageRecipientType.values().associateWith { emptyList>() }.toMutableMap() + recipients + .groupBy { it.type } + .forEach { (type, recipients) -> recipientMap[type] = recipients.map { it.id } } + return recipientMap.toMap() + } + + val recipientsByStartingStatus = recipients.groupBy { it.starter } + val starterRecipients = getRecipientIdsByType(recipientsByStartingStatus[true] ?: emptyList()) + val currentRecipients = getRecipientIdsByType(recipientsByStartingStatus[false] ?: emptyList()) val filterPredicates = PredicateSql.allNotNull( @@ -1298,41 +1349,73 @@ fun Database.Read.getMessageAccountsForRecipients( """ WITH sender AS ( SELECT type, daycare_group_id, employee_id FROM message_account WHERE id = ${bind(accountId)} -), children AS ( +), current_children AS ( SELECT DISTINCT pl.child_id FROM realized_placement_all(${bind(date)}) pl JOIN daycare d ON pl.unit_id = d.id LEFT JOIN person p ON p.id = pl.child_id LEFT JOIN service_need sn ON sn.placement_id = pl.placement_id AND daterange(sn.start_date, sn.end_date, '[]') @> ${bind(date)} - WHERE (d.care_area_id = ANY(${bind(areaRecipients)}) OR pl.unit_id = ANY(${bind(unitRecipients)}) OR pl.group_id = ANY(${bind(groupRecipients)}) OR pl.child_id = ANY(${bind(childRecipients)})) + WHERE (d.care_area_id = ANY(${bind(currentRecipients[MessageRecipientType.AREA])}) + OR pl.unit_id = ANY(${bind(currentRecipients[MessageRecipientType.UNIT])}) + OR pl.group_id = ANY(${bind(currentRecipients[MessageRecipientType.GROUP])}) + OR pl.child_id = ANY(${bind(currentRecipients[MessageRecipientType.CHILD])})) AND ${predicate(filterPredicates)} - AND EXISTS ( - SELECT 1 - FROM child_daycare_acl(${bind(date)}) - JOIN mobile_device_daycare_acl_view USING (daycare_id) - WHERE mobile_device_id = (SELECT sender.employee_id FROM sender) - AND child_id = pl.child_id - - UNION ALL - - SELECT 1 - FROM employee_child_daycare_acl(${bind(date)}) - WHERE employee_id = (SELECT sender.employee_id FROM sender) - AND child_id = pl.child_id - - UNION ALL - - SELECT 1 - FROM sender - WHERE pl.group_id = sender.daycare_group_id - - UNION ALL - - SELECT 1 - FROM sender - WHERE type = 'MUNICIPAL' + AND ( + EXISTS ( + SELECT 1 + FROM child_daycare_acl(${bind(date)}) + JOIN mobile_device_daycare_acl_view USING (daycare_id) + WHERE mobile_device_id = (SELECT sender.employee_id FROM sender) + AND child_id = pl.child_id + ) OR EXISTS ( + SELECT 1 + FROM employee_child_daycare_acl(${bind(date)}) + WHERE employee_id = (SELECT sender.employee_id FROM sender) + AND child_id = pl.child_id + ) OR EXISTS ( + SELECT 1 + FROM sender + WHERE pl.group_id = sender.daycare_group_id + ) OR EXISTS ( + SELECT 1 + FROM sender + WHERE type = 'MUNICIPAL' + ) + ) + AND 'MESSAGING' = ANY(d.enabled_pilot_features) +), starting_children AS ( + SELECT DISTINCT pl.child_id + FROM placement pl + JOIN daycare d ON pl.unit_id = d.id + LEFT JOIN daycare_group_placement dgp ON pl.id = dgp.daycare_placement_id + LEFT JOIN person p ON p.id = pl.child_id + LEFT JOIN service_need sn ON false + WHERE (pl.start_date > ${bind(date)} OR dgp.start_date > ${bind(date)}) + AND (d.care_area_id = ANY(${bind(starterRecipients[MessageRecipientType.AREA])}) + OR pl.unit_id = ANY(${bind(starterRecipients[MessageRecipientType.UNIT])}) + OR dgp.daycare_group_id = ANY(${bind(starterRecipients[MessageRecipientType.GROUP])}) + OR pl.child_id = ANY(${bind(starterRecipients[MessageRecipientType.CHILD])})) + AND ${predicate(filterPredicates)} + AND ( + EXISTS ( + SELECT 1 + FROM daycare_acl_view acl + WHERE acl.daycare_id = pl.unit_id AND acl.employee_id = (SELECT sender.employee_id FROM sender) + ) OR EXISTS ( + SELECT 1 + FROM sender + WHERE dgp.daycare_group_id = sender.daycare_group_id + ) OR EXISTS ( + SELECT 1 + FROM sender + WHERE type = 'MUNICIPAL' + ) ) AND 'MESSAGING' = ANY(d.enabled_pilot_features) +), children AS ( + SELECT child_id FROM current_children + UNION + SELECT child_id FROM starting_children ) SELECT acc.id AS account_id, c.child_id FROM children c @@ -1351,7 +1434,7 @@ UNION SELECT acc.id AS account_id, NULL as child_id FROM person p JOIN message_account acc ON p.id = acc.person_id -WHERE p.id = ANY(${bind(citizenRecipients)}) +WHERE p.id = ANY(${bind(currentRecipients[MessageRecipientType.CITIZEN])}) """ ) } diff --git a/service/src/main/resources/db/migration/V495__draft_message_recipients.sql b/service/src/main/resources/db/migration/V495__draft_message_recipients.sql new file mode 100644 index 00000000000..31c4e5f8757 --- /dev/null +++ b/service/src/main/resources/db/migration/V495__draft_message_recipients.sql @@ -0,0 +1,11 @@ +ALTER TABLE message_draft + ADD COLUMN recipients jsonb DEFAULT '[]'::jsonb NOT NULL; + +UPDATE message_draft +SET recipients = COALESCE( + (SELECT jsonb_agg(jsonb_build_object('accountId', id, 'starter', false)) + FROM (SELECT unnest(recipient_ids) AS id)) + , '[]'::jsonb); + +ALTER TABLE message_draft + DROP COLUMN recipient_ids; diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index bdb09b33adb..a1de03b000f 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -490,3 +490,4 @@ V491__password_blacklist.sql V492__finance_note.sql V493__acl_end_date.sql V494__daycare_acl_schedule.sql +V495__draft_message_recipients.sql