diff --git a/manifest.webapp b/manifest.webapp index c644824d1cd584035483639ee3b087d147bb9c41..5d5539a4102959956537155ed56a6004f0f5256b 100644 --- a/manifest.webapp +++ b/manifest.webapp @@ -135,6 +135,11 @@ "file": "services/monthlyReportNotification/ecolyo.js", "trigger": "@cron 0 0 10 3 * *" }, + "consumptionAlert": { + "type": "node", + "file": "services/consumptionAlert/ecolyo.js", + "trigger": "@cron 0 3 * * *" + }, "aggregatorUsageEvents": { "type": "node", "file": "services/aggregatorUsageEvents/ecolyo.js", diff --git a/src/components/Options/ReportOptions.spec.tsx b/src/components/Options/ReportOptions.spec.tsx index 8636d80be32438a247c1339d66e8d906f86bfe30..3ca58f67822e906c2460ac34c0453d8b8db0bc29 100644 --- a/src/components/Options/ReportOptions.spec.tsx +++ b/src/components/Options/ReportOptions.spec.tsx @@ -8,6 +8,7 @@ import { } from '../../../tests/__mocks__/store' import * as profileActions from 'store/profile/profile.actions' import { Button } from '@material-ui/core' +import StyledSwitch from 'components/CommonKit/Switch/StyledSwitch' jest.mock('cozy-ui/transpiled/react/I18n', () => { return { @@ -80,4 +81,35 @@ describe('ReportOptions component', () => { sendAnalysisNotification: true, }) }) + + it('should be rendered with sendConsumptionAlert to false', () => { + const wrapper = mount( + <Provider store={store}> + <ReportOptions /> + </Provider> + ) + expect(wrapper.find(StyledSwitch)).toHaveLength(1) + expect( + wrapper + .find(StyledSwitch) + .first() + .props().checked + ).toBeFalsy() + }) + + it('should update the profile with sendConsumptionAlert to true', () => { + const wrapper = mount( + <Provider store={store}> + <ReportOptions /> + </Provider> + ) + wrapper + .find('input') + .first() + .simulate('change', { target: { checked: 'true' } }) + expect(updateProfileSpy).toBeCalledTimes(1) + expect(updateProfileSpy).toHaveBeenCalledWith({ + sendConsumptionAlert: true, + }) + }) }) diff --git a/src/components/Options/ReportOptions.tsx b/src/components/Options/ReportOptions.tsx index b6edc1b0f152145e7ae3c8ae326e677f1b82812f..afae05dec46048b792334a827ae7103e5bfddb0f 100644 --- a/src/components/Options/ReportOptions.tsx +++ b/src/components/Options/ReportOptions.tsx @@ -5,6 +5,7 @@ import { useSelector, useDispatch } from 'react-redux' import { AppStore } from 'store' import { updateProfile } from 'store/profile/profile.actions' import { Button } from '@material-ui/core' +import StyledSwitch from 'components/CommonKit/Switch/StyledSwitch' const ReportOptions: React.FC = () => { const { t } = useI18n() @@ -15,21 +16,36 @@ const ReportOptions: React.FC = () => { dispatch(updateProfile({ sendAnalysisNotification: value })) } + const updateProfileAlert = async (value: boolean) => { + dispatch(updateProfile({ sendConsumptionAlert: value })) + } + + const setWaterLimit = (e: React.ChangeEvent<HTMLInputElement>) => { + dispatch( + updateProfile({ waterDailyConsumptionLimit: parseInt(e.target.value) }) + ) + } + const toggleAnalysisNotification = () => { profile.sendAnalysisNotification ? updateProfileReport(false) : updateProfileReport(true) } + const handleAlertChange = (e: React.ChangeEvent<HTMLInputElement>) => { + e.target.checked ? updateProfileAlert(true) : updateProfileAlert(false) + } + return ( <div className="report-option-root"> <div className="report-option-content"> <div className="head text-16-normal-uppercase"> - {t('profile.report.title')} + {t('profile.report.title_bilan')} </div> - <div className="switch-container"> + {/* Monthly Report activation */} + <div className="switch-container-bilan"> <span className="switch-label text-16-normal"> - {t('profile.report.switch_label')} + {t('profile.report.switch_label_bilan')} </span> <div className="button-container"> <Button @@ -48,6 +64,42 @@ const ReportOptions: React.FC = () => { </Button> </div> </div> + + <div className="head text-16-normal-uppercase"> + {t('profile.report.title_alert')} + </div> + {/* Consumption Alert activation */} + <div className="switch-container-alert"> + <StyledSwitch + checked={profile.sendConsumptionAlert} + onChange={handleAlertChange} + inputProps={{ + 'aria-label': t( + 'profile.accessibility.button_toggle_consumption_alert' + ), + }} + /> + <span className="switch-label text-16-normal"> + {t('profile.report.switch_label_alert')} + </span> + </div> + {profile.sendConsumptionAlert && ( + <div className="alert-inputs-display"> + <div className="head text-16-normal">Eau</div> + <div className="switch-container-alert"> + <input + className="input-style" + type={'number'} + defaultValue={profile.waterDailyConsumptionLimit} + onBlur={setWaterLimit} + aria-label={t('profile.accessibility.input_water_alert_report')} + /> + <span className="switch-label text-16-normal"> + Litre(s) par jour + </span> + </div> + </div> + )} </div> </div> ) diff --git a/src/components/Options/reportOptions.scss b/src/components/Options/reportOptions.scss index c13b56949d2f0183429440868de2491b989d6e2e..63302fffb9ec3d4997cfac52d745c597b21acf40 100644 --- a/src/components/Options/reportOptions.scss +++ b/src/components/Options/reportOptions.scss @@ -17,7 +17,7 @@ margin: 1rem 0; color: $grey-bright; } - .switch-container { + .switch-container-bilan { display: flex; flex-direction: column; color: $grey-bright; @@ -28,9 +28,38 @@ .button-container { max-width: 200px; button { - margin-top: 0.5rem; width: 125px; } } } + + .switch-container-alert { + display: flex; + align-items: center; + color: $grey-bright; + .switch-label { + margin-left: 0.2rem; + padding-right: 0.8rem; + } + .input-style { + width: 45px; + text-align: center; + margin: 0.5rem; + background: $dark-light-2; + color: $white; + border: 1px solid $gold-shadow; + max-width: 5rem; + height: 2rem; + &:focus { + outline: $gold-shadow 1px; + } + &:disabled { + -webkit-text-fill-color: $white; + opacity: 1; + } + } + } + .alert-inputs-display { + padding: 0 1rem; + } } diff --git a/src/db/profileData.json b/src/db/profileData.json index 94d627979699e33ad7e957a62978cfe6f48fc885..b9576397f31cc2c0c4af7361f7138437f00761ce 100644 --- a/src/db/profileData.json +++ b/src/db/profileData.json @@ -1,20 +1,22 @@ -[ - { - "ecogestureHash": "", - "challengeHash": "", - "mailToken": "", - "duelHash": "", - "quizHash": "", - "isFirstConnection": true, - "lastConnectionDate": "0000-01-01T00:00:00.000Z", - "haveSeenOldFluidModal": false, - "haveSeenLastAnalysis": true, - "sendAnalysisNotification": true, - "monthlyAnalysisDate": "0000-01-01T00:00:00.000Z", - "isLastTermAccepted": false, - "isProfileTypeCompleted": false, - "tutorial": { - "isWelcomeSeen": false - } - } -] +[ + { + "ecogestureHash": "", + "challengeHash": "", + "mailToken": "", + "duelHash": "", + "quizHash": "", + "isFirstConnection": true, + "lastConnectionDate": "0000-01-01T00:00:00.000Z", + "haveSeenOldFluidModal": false, + "haveSeenLastAnalysis": true, + "sendAnalysisNotification": true, + "monthlyAnalysisDate": "0000-01-01T00:00:00.000Z", + "sendConsumptionAlert": false, + "waterDailyConsumptionLimit": 0, + "isLastTermAccepted": false, + "isProfileTypeCompleted": false, + "tutorial": { + "isWelcomeSeen": false + } + } +] diff --git a/src/locales/fr.json b/src/locales/fr.json index 54aa6dadd3b949d7f2d6ab67b5090c4803b25307..d4e27f7de535801ed235ef06de11d31616632f6f 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -642,13 +642,17 @@ }, "profile": { "report": { - "title": "Notification par mail", - "switch_label": "Être prévenu de la parution de mon bilan mensuel", + "title_alert": "Notification par mail", + "title_bilan": "Bilan et conseils", + "switch_label_bilan": "Réception mensuelle d'un bilan des consommations, de conseils sur les économies d'énergie et d'eau ainsi que d'informations sur les évolutions du service.", + "switch_label_alert": "Être prévenu d'un dépassement de consommation", "activate": "Activer", "deactivate": "Désactiver" }, "accessibility": { - "button_toggle_mail_report": "Activer les notifications par mail" + "button_toggle_mail_report": "Recevoir mon bilan mensuel par mail", + "button_toggle_consumption_alert": "Recevoir des alertes sur mes consommations journalières", + "input_water_alert_report": "Indiquer une limite de consommation d'eau journalière" } }, "profile_type": { diff --git a/src/models/profile.model.ts b/src/models/profile.model.ts index 5852df8a336e38dccb972d8433b2a7343bfb6d94..2ace99cb90768cc9c9099b922c853e1e7bc84cc4 100644 --- a/src/models/profile.model.ts +++ b/src/models/profile.model.ts @@ -1,35 +1,37 @@ -import { DateTime } from 'luxon' - -interface Tutorial { - isWelcomeSeen: boolean -} - -export interface ProfileEntity { - id: string - ecogestureHash: string - challengeHash: string - duelHash: string - quizHash: string - explorationHash: string - isFirstConnection: boolean - lastConnectionDate: string - haveSeenLastAnalysis: boolean - haveSeenOldFluidModal: string | boolean - sendAnalysisNotification: boolean - monthlyAnalysisDate: string - isProfileTypeCompleted: boolean - tutorial: Tutorial - mailToken: string - _id?: string - _rev?: string -} - -export interface Profile - extends Omit< - ProfileEntity, - 'haveSeenOldFluidModal' | 'lastConnectionDate' | 'monthlyAnalysisDate' - > { - lastConnectionDate: DateTime - haveSeenOldFluidModal: DateTime | boolean - monthlyAnalysisDate: DateTime -} +import { DateTime } from 'luxon' + +interface Tutorial { + isWelcomeSeen: boolean +} + +export interface ProfileEntity { + id: string + ecogestureHash: string + challengeHash: string + duelHash: string + quizHash: string + explorationHash: string + isFirstConnection: boolean + lastConnectionDate: string + haveSeenLastAnalysis: boolean + haveSeenOldFluidModal: string | boolean + sendAnalysisNotification: boolean + monthlyAnalysisDate: string + sendConsumptionAlert: boolean + waterDailyConsumptionLimit: number + isProfileTypeCompleted: boolean + tutorial: Tutorial + mailToken: string + _id?: string + _rev?: string +} + +export interface Profile + extends Omit< + ProfileEntity, + 'haveSeenOldFluidModal' | 'lastConnectionDate' | 'monthlyAnalysisDate' + > { + lastConnectionDate: DateTime + haveSeenOldFluidModal: DateTime | boolean + monthlyAnalysisDate: DateTime +} diff --git a/src/services/consumption.service.ts b/src/services/consumption.service.ts index 27cf550fb7e79a4e8eeebdac6c24f76fd2f6ee94..18138db7609cc9ce68c63bd6b8466a3e70263d83 100644 --- a/src/services/consumption.service.ts +++ b/src/services/consumption.service.ts @@ -130,6 +130,25 @@ export default class ConsumptionDataManager { } } + // fetch last dataload available for a given fluid - return the daily data + public async getLastDataload( + fluidTypes: FluidType + ): Promise<Dataload[] | null> { + const timePeriod = { + startDate: DateTime.now() + .plus({ days: -3 }) + .startOf('day'), + endDate: DateTime.now(), + } + + const data = await this._queryRunnerService.fetchFluidData( + timePeriod, + TimeStep.DAY, + fluidTypes + ) + return data + } + public async getPerformanceIndicators( timePeriod: TimePeriod, timeStep: TimeStep, diff --git a/src/store/profile/profile.reducer.ts b/src/store/profile/profile.reducer.ts index df049b075d6e5e9913367fae158a738a12aa0e78..6fbcae1dca6251547cc79eba34073b452b06c7b0 100644 --- a/src/store/profile/profile.reducer.ts +++ b/src/store/profile/profile.reducer.ts @@ -1,42 +1,44 @@ -import { Reducer } from 'redux' -import { - UPDATE_PROFILE, - ProfileActionTypes, -} from 'store/profile/profile.actions' -import { Profile } from 'models' -import { DateTime } from 'luxon' - -const initialState: Profile = { - id: '', - ecogestureHash: '', - challengeHash: '', - duelHash: '', - quizHash: '', - explorationHash: '', - isFirstConnection: false, - lastConnectionDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'), - haveSeenOldFluidModal: true, - haveSeenLastAnalysis: true, - sendAnalysisNotification: true, - mailToken: '', - monthlyAnalysisDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'), - isProfileTypeCompleted: false, - tutorial: { - isWelcomeSeen: false, - }, -} - -export const profileReducer: Reducer<Profile> = ( - state = initialState, - action: ProfileActionTypes -): Profile => { - switch (action.type) { - case UPDATE_PROFILE: - return { - ...state, - ...action.payload, - } - default: - return state - } -} +import { Reducer } from 'redux' +import { + UPDATE_PROFILE, + ProfileActionTypes, +} from 'store/profile/profile.actions' +import { Profile } from 'models' +import { DateTime } from 'luxon' + +const initialState: Profile = { + id: '', + ecogestureHash: '', + challengeHash: '', + duelHash: '', + quizHash: '', + explorationHash: '', + isFirstConnection: false, + lastConnectionDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'), + haveSeenOldFluidModal: true, + haveSeenLastAnalysis: true, + sendAnalysisNotification: true, + sendConsumptionAlert: false, + waterDailyConsumptionLimit: 0, + mailToken: '', + monthlyAnalysisDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'), + isProfileTypeCompleted: false, + tutorial: { + isWelcomeSeen: false, + }, +} + +export const profileReducer: Reducer<Profile> = ( + state = initialState, + action: ProfileActionTypes +): Profile => { + switch (action.type) { + case UPDATE_PROFILE: + return { + ...state, + ...action.payload, + } + default: + return state + } +} diff --git a/src/targets/services/consumptionAlert.ts b/src/targets/services/consumptionAlert.ts new file mode 100644 index 0000000000000000000000000000000000000000..16e191c42c3b13c25010f2876aec85ca12240168 --- /dev/null +++ b/src/targets/services/consumptionAlert.ts @@ -0,0 +1,101 @@ +import logger from 'cozy-logger' +import { Client } from 'cozy-client' +import get from 'lodash/get' +import { runService } from './service' +import ProfileService from 'services/profile.service' +import MailService from 'services/mail.service' +import { DateTime } from 'luxon' +const consumptionLimit = require('notifications/consumptionLimit.hbs') +import mjml2html from 'mjml' +import { FluidType } from 'enum/fluid.enum' +import ConsumptionService from 'services/consumption.service' +import { getMonthName } from 'utils/utils' + +const log = logger.namespace('alert') + +interface ConsumptionAlertProps { + client: Client +} + +// Only monitoring WATER fluid for now +const consumptionAlert = async ({ client }: ConsumptionAlertProps) => { + log('info', 'Fetching user profile...') + const upm = new ProfileService(client) + const consumptionService = new ConsumptionService(client) + const userProfil = await upm.getProfile() + if ( + !userProfil || + !userProfil.sendConsumptionAlert || + userProfil.waterDailyConsumptionLimit === 0 + ) { + log( + 'info', + 'End of process - Alert report notification is disabled or lack informations from user profile to run' + ) + return + } + + let username = '' + + log('info', 'water limit is :' + userProfil.waterDailyConsumptionLimit) + + log('info', 'Fetching fluid data...') + // Retrieve public name from the stack + const settings = await client + .getStackClient() + .fetchJSON('GET', '/settings/instance') + const publicName = get(settings, 'data.attributes.public_name') + if (publicName) { + username = publicName + } + + // Retrieve link to ecolyo app from the stack + const apps = await client.getStackClient().fetchJSON('GET', '/apps/ecolyo') + const appLink = get(apps, 'data.links.related') + + const fetchedData = await consumptionService.getLastDataload(FluidType.WATER) + let lastDayValue = 0 + if (fetchedData && fetchedData.length > 0) { + fetchedData.forEach(element => { + if (element.value) { + lastDayValue = element.value + } + }) + } + if (lastDayValue <= userProfil.waterDailyConsumptionLimit) { + log( + 'info', + 'End of process - Limit consumption set by the user has not been passed.' + ) + return + } + + log('info', 'Creation of mail...') + const mailService = new MailService() + const today = DateTime.local().setZone('utc', { keepLocalTime: true }) + + const template = consumptionLimit({ + title: 'Ça déborde !', + username: username, + clientUrl: appLink, + unsubscribeUrl: appLink + '/#/options', + userLimit: userProfil.waterDailyConsumptionLimit, + limitDate: `${today.day} ${getMonthName(today)}`, + }) + + const mailData = { + mode: 'noreply', + subject: '[Ecolyo] - Consommation maximale atteinte', + parts: [ + { + type: 'text/html', + body: mjml2html(template).html, + }, + ], + } + + log('info', 'Sending mail...') + mailService.SendMail(client, mailData) +} + +runService(consumptionAlert) diff --git a/src/targets/services/monthlyReportNotification.ts b/src/targets/services/monthlyReportNotification.ts index deb83f0bdf8e9b588eb67fae0383ec059a66a2f5..01b78d0c5f814d138cadd0cf0ad87a2ecdf0fe4e 100644 --- a/src/targets/services/monthlyReportNotification.ts +++ b/src/targets/services/monthlyReportNotification.ts @@ -13,6 +13,7 @@ import { TimeStep } from 'enum/timeStep.enum' import ConsumptionService from 'services/consumption.service' import { MonthlReport } from 'models/monthlyReport.model' import EnvironementService from 'services/environement.service' +import { getMonthName } from 'utils/utils' const log = logger.namespace('report') @@ -110,28 +111,6 @@ const buildConsumptionText = async (client: Client) => { return text } -const getMonthName = () => { - const monthNames = [ - 'janiver', - 'février', - 'mars', - 'avril', - 'mai', - 'juin', - 'juillet', - 'août', - 'septembre', - 'octobre', - 'novembre', - 'décembre', - ] - - const d = DateTime.local() - .setZone('utc', { keepLocalTime: true }) - .minus({ month: 2 }) - return monthNames[d.month] -} - /** * getMonthlyReport */ @@ -256,6 +235,11 @@ const monthlyReportNotification = async ({ const isPoll: boolean = monthlyReport.question !== '' && monthlyReport.link !== '' + + const date = DateTime.local() + .setZone('utc', { keepLocalTime: true }) + .minus({ month: 2 }) + const template = monthlyReportTemplate({ title: 'Du nouveau dans votre espace Ecolyo !', username: username, @@ -277,7 +261,7 @@ const monthlyReportNotification = async ({ ), pollText: monthlyReport.question.replace(/{cozyUrl}/g, appLink + '#/'), pollUrl: monthlyReport.link, - previousMonth: getMonthName(), + previousMonth: getMonthName(date), consoImageUrl: environementService.getPublicURL() + '/assets/multifluidConsumption.svg', }) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 52aef321e8b7f7b0edf527c5f7051de193e9a67d..6a5cc5f12e1af689d36f7560b37879fc79616b1e 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,6 +2,7 @@ import get from 'lodash/get' import { GetRelationshipsReturn, Relation } from 'models' import { FluidType } from '../enum/fluid.enum' import { KonnectorUpdate } from '../enum/konnectorUpdate.enum' +import { DateTime } from 'luxon' export function getFluidType(type: string) { switch (type.toUpperCase()) { @@ -96,3 +97,26 @@ export const importIconbyId = async (id: string, pathType: string) => { return importedChallengeIcon.default } } + +/** + * Return month string according to month index + * @param date - DateTime + * @returns month in french + */ +export const getMonthName = (date: DateTime) => { + const monthNames = [ + 'janiver', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ] + return monthNames[date.month] +} diff --git a/tests/__mocks__/profile.mock.ts b/tests/__mocks__/profile.mock.ts index 6a0c50c6938d086869023b730eaec7940d0d69ce..46410205ef1cc75449b5012fa8b46e4165050028 100644 --- a/tests/__mocks__/profile.mock.ts +++ b/tests/__mocks__/profile.mock.ts @@ -1,28 +1,30 @@ -import { DateTime } from 'luxon' -import { Profile } from 'models' - -export const profileData: Profile = { - _id: '4d9403218ef13e65b2e3a8ad1700bc41', - _rev: '16-57473da4fc26315247c217083175dfa0', - id: '4d9403218ef13e65b2e3a8ad1700bc41', - ecogestureHash: '9798a0aaccb47cff906fc4931a2eff5f9371dd8b', - challengeHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', - duelHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', - quizHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', - explorationHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', - isFirstConnection: true, - mailToken: '', - lastConnectionDate: DateTime.fromISO('2020-11-03T00:00:00.000Z', { - zone: 'utc', - }), - haveSeenOldFluidModal: false, - haveSeenLastAnalysis: true, - monthlyAnalysisDate: DateTime.fromISO('2020-11-03T00:00:00.000Z', { - zone: 'utc', - }), - sendAnalysisNotification: false, - isProfileTypeCompleted: false, - tutorial: { - isWelcomeSeen: false, - }, -} +import { DateTime } from 'luxon' +import { Profile } from 'models' + +export const profileData: Profile = { + _id: '4d9403218ef13e65b2e3a8ad1700bc41', + _rev: '16-57473da4fc26315247c217083175dfa0', + id: '4d9403218ef13e65b2e3a8ad1700bc41', + ecogestureHash: '9798a0aaccb47cff906fc4931a2eff5f9371dd8b', + challengeHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', + duelHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', + quizHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', + explorationHash: '1136feb6185c7643e071d14180c0e95782aa4ba3', + isFirstConnection: true, + sendConsumptionAlert: false, + waterDailyConsumptionLimit: 0, + mailToken: '', + lastConnectionDate: DateTime.fromISO('2020-11-03T00:00:00.000Z', { + zone: 'utc', + }), + haveSeenOldFluidModal: false, + haveSeenLastAnalysis: true, + monthlyAnalysisDate: DateTime.fromISO('2020-11-03T00:00:00.000Z', { + zone: 'utc', + }), + sendAnalysisNotification: false, + isProfileTypeCompleted: false, + tutorial: { + isWelcomeSeen: false, + }, +} diff --git a/tests/__mocks__/store.ts b/tests/__mocks__/store.ts index fa8a9e56d1e879541397564ce3e0701431aeb05f..1fe5285dc9eb483d33d581a0076ff454bd861395 100644 --- a/tests/__mocks__/store.ts +++ b/tests/__mocks__/store.ts @@ -108,6 +108,8 @@ export const mockInitialProfileState: Profile = { lastConnectionDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'), haveSeenOldFluidModal: true, haveSeenLastAnalysis: true, + sendConsumptionAlert: false, + waterDailyConsumptionLimit: 0, sendAnalysisNotification: true, mailToken: '', monthlyAnalysisDate: DateTime.fromISO('0000-01-01T00:00:00.000Z'),