From c9de8f72dafbef8f865cb1f36ec11744a977c2e3 Mon Sep 17 00:00:00 2001 From: Pierre Ecarlat <pecarlat@grandlyon.com> Date: Wed, 15 Jan 2025 14:46:26 +0000 Subject: [PATCH] feat: Update konnector to efluid API --- .vscode/settings.json | 8 + konnector-dev-config.example.json | 13 +- manifest.konnector | 22 ++- package.json | 6 +- src/helpers/format.js | 62 +++----- src/helpers/format.spec.js | 222 +++++++-------------------- src/helpers/prices.js | 22 +-- src/helpers/utils.js | 38 ++--- src/helpers/utils.spec.js | 45 ++---- src/index.js | 246 +++++++++--------------------- src/requests/epgl.js | 148 ++++++++++++++++++ src/types/enums.js | 15 -- src/types/types.js | 35 +++-- yarn.lock | 25 +-- 14 files changed, 408 insertions(+), 499 deletions(-) create mode 100644 src/requests/epgl.js delete mode 100644 src/types/enums.js diff --git a/.vscode/settings.json b/.vscode/settings.json index b4d4ac0..cddd37d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,21 +28,26 @@ "acces", "Agregat", "Agregats", + "annee", "apikey", "arret", "Arret", "backoffice", + "consommation", "Corrigees", "cozyclient", "criteres", "Derniere", "Detaillees", + "Efluid", "enedissgegrandlyon", "EPGL", + "Estime", "Etage", "etat", "faultstring", "firstname", + "Fuite", "Generales", "grandlyon", "HISTO", @@ -50,11 +55,14 @@ "konnector", "konnectors", "lastname", + "libelle", "maxpower", + "mois", "numero", "Perimetre", "periodicite", "PMAX", + "postes", "Recurrente", "Releve", "resultat", diff --git a/konnector-dev-config.example.json b/konnector-dev-config.example.json index aefd96b..2993be6 100644 --- a/konnector-dev-config.example.json +++ b/konnector-dev-config.example.json @@ -1,14 +1,15 @@ { "COZY_URL": "http://cozy.tools:8080/", "fields": { - "login": 1234567, - "password": "" + "contractId": "", + "meteringId": "" }, "COZY_PARAMETERS": { "secret": { - "eglBaseURL": "", - "eglAPIAuthKey": "", - "boBaseUrl": "https://ecolyo-agent-rec.apps.grandlyon.com/" + "boBaseUrl": "https://ecolyo-agent-rec.apps.grandlyon.com/", + "epglBaseUrl": "https://prepro-epgl-ecolyo-efluidconnect.multield.net/", + "epglUser": "", + "epglPassword": "" } } -} \ No newline at end of file +} diff --git a/manifest.konnector b/manifest.konnector index 9c786c5..4812fce 100644 --- a/manifest.konnector +++ b/manifest.konnector @@ -11,11 +11,11 @@ "frequency": "daily", "categories": ["energy"], "fields": { - "login": { + "contractId": { "type": "text" }, - "password": { - "type": "password" + "meteringId": { + "type": "text" }, "advancedFields": { "folderPath": { @@ -51,6 +51,14 @@ "accounts": { "description": "Utilisé pour accéder à vos données de consommation." } + }, + "fields": { + "contractId": { + "label": "Numéro de contrat" + }, + "meteringId": { + "label": "Numéro de compteur" + } } }, "en": { @@ -63,6 +71,14 @@ "accounts": { "description": "Used to access your consumption data." } + }, + "fields": { + "contractId": { + "label": "Contract ID" + }, + "meteringId": { + "label": "Water metering ID" + } } } }, diff --git a/package.json b/package.json index 7620358..b83f8f8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start": "node ./src/index.js", "dev": "cozy-konnector-dev", "standalone": "cozy-konnector-standalone", + "standalone-no-data": "NO_DATA=true cozy-konnector-standalone src/index.js", "lint": "eslint src --fix", "pretest": "npm run clean", "test": "jest", @@ -28,9 +29,10 @@ "dependencies": { "@sentry/node": "7.30.0", "@sentry/tracing": "7.30.0", - "axios": "1.7.2", + "axios": "1.7.7", "cozy-konnector-libs": "^5.12.1", - "luxon": "^3.4.3" + "moment": "^2.30.1", + "moment-timezone": "^0.5.45" }, "devDependencies": { "cozy-jobs-cli": "^2.4.4", diff --git a/src/helpers/format.js b/src/helpers/format.js index 1bc065e..b31f1e1 100644 --- a/src/helpers/format.js +++ b/src/helpers/format.js @@ -1,51 +1,35 @@ -const { log } = require('cozy-konnector-libs') -const { DateTime } = require('luxon') const Sentry = require('@sentry/node') /** - * Formats and processes data retrieved from the getData response. + * Formats and processes data retrieved from the fetchData response. * - * @param {Releve[]} data - The data response to be formatted. + * @param {FetchDataResponse} data - The data response to be formatted. * * @throws {Error} - Throws an error with an error code in case of data processing failure. * @returns {FormattedData[]} - An array of formatted data objects. */ -function format(data) { - log('info', 'origin response size is: ' + data.length) - /** Filter loads where value is 0 */ - const filteredData = data.filter(value => value.ValeurIndex !== 0) - const formattedLoads = [] - for (let i = 1; i < filteredData.length; i++) { - const previousValue = filteredData[i - 1] - const currentValue = filteredData[i] - const processedLoad = currentValue.ValeurIndex - previousValue.ValeurIndex - const loadDate = DateTime.fromISO(currentValue.DateReleve, { - setZone: true, +function formatData(data) { + try { + const loadData = data.postes[0].data + const isInMeterCube = data.unites.consommation.toLowerCase() === 'm3' + + return loadData.map(load => ({ + load: load.consommation * (isInMeterCube ? 1000 : 1), + year: load.annee, + month: load.mois + 1, // Months are from 0 to 11 in Efluid + day: load.jour, + hour: 0, + minute: 0, + })) + } catch { + Sentry.captureMessage('New water meter or invalid load value', { + tags: { + section: 'format', + }, }) - // Check that load is positive and that water meter has not changed - if (processedLoad >= 0 && currentValue.TypeAgregat !== 'D') { - formattedLoads.push({ - load: processedLoad, - year: loadDate.year, - month: loadDate.month, - day: loadDate.day, - hour: 0, - minute: 0, - type: currentValue.TypeAgregat, - }) - } else { - Sentry.captureMessage('New water meter or invalid load value', { - extra: { - processedLoad, - currentValue: currentValue, - }, - tags: { - section: 'format', - }, - }) - } } - return formattedLoads + + return [] } -module.exports = { format } +module.exports = { formatData } diff --git a/src/helpers/format.spec.js b/src/helpers/format.spec.js index 5f641e1..4577b24 100644 --- a/src/helpers/format.spec.js +++ b/src/helpers/format.spec.js @@ -1,4 +1,4 @@ -const { format } = require('./format') +const { formatData } = require('./format') const mockCaptureMessage = jest.fn() jest.mock('@sentry/node', () => ({ @@ -15,187 +15,81 @@ describe('format', () => { jest.clearAllMocks() }) it('should format empty array', () => { - const formatted = format([]) - expect(formatted).toStrictEqual([]) + const formattedData = formatData([]) + expect(formattedData).toStrictEqual([]) }) - it('should format array of length 1', () => { - const formatted = format([ + it('should format empty array, no data', () => { + const formattedData = formatData([ { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, + postes: [], + unites: { + consommation: 'l', + index: 'l', + debitMin: 'L/h', + volumeEstimeFuite: 'l', + }, }, ]) - expect(formatted).toStrictEqual([]) + expect(formattedData).toStrictEqual([]) }) - it('should process loads between two days', () => { - const formatted = format([ - { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-01-15T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10001, - }, - ]) - expect(formatted).toStrictEqual([ - { + it('should format load properly', () => { + const formattedData = formatData({ + postes: [ + { + data: [ + { + consommation: 100, + index: 123456, + jour: 2, + mois: 0, + annee: 2024, + }, + ], + }, + ], + unites: { + consommation: 'l', + }, + }) + expect(formattedData).toStrictEqual([ + { + load: 100, year: 2024, month: 1, - day: 15, - hour: 0, - minute: 0, - load: 1, - type: 'R', - }, - ]) - }) - it('should handle summer time change', () => { - const formatted = format([ - { - DateReleve: '2024-03-31T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-04-01T00:00:00+02:00', - TypeAgregat: 'R', - ValeurIndex: 10001, - }, - { - DateReleve: '2024-04-02T00:00:00+02:00', - TypeAgregat: 'R', - ValeurIndex: 10003, - }, - ]) - expect(formatted).toStrictEqual([ - { - year: 2024, - month: 4, - day: 1, - hour: 0, - minute: 0, - load: 1, - type: 'R', - }, - { - year: 2024, - month: 4, day: 2, hour: 0, minute: 0, - load: 2, - type: 'R', }, ]) }) - it('should handle winter time change', () => { - const formatted = format([ - { - DateReleve: '2024-10-27T00:00:00+02:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-10-28T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10001, - }, - { - DateReleve: '2024-10-29T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10003, - }, - ]) - expect(formatted).toStrictEqual([ - { - year: 2024, - month: 10, - day: 28, - hour: 0, - minute: 0, - load: 1, - type: 'R', - }, - { - year: 2024, - month: 10, - day: 29, - hour: 0, - minute: 0, - load: 2, - type: 'R', - }, - ]) - }) - it('should filter loads where value is 0', () => { - const formatted = format([ - { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'T', - ValeurIndex: 0, - }, - { - DateReleve: '2024-01-15T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10001, - }, - { - DateReleve: '2024-01-15T00:00:00+01:00', - TypeAgregat: 'T', - ValeurIndex: 0, - }, - ]) - expect(formatted).toStrictEqual([ - { + it('should convert load to liters', () => { + const formattedData = formatData({ + postes: [ + { + data: [ + { + consommation: 100, + index: 123456, + jour: 2, + mois: 0, + annee: 2024, + }, + ], + }, + ], + unites: { + consommation: 'm3', + }, + }) + expect(formattedData).toStrictEqual([ + { + load: 100000, year: 2024, month: 1, - day: 15, + day: 2, hour: 0, minute: 0, - load: 1, - type: 'R', - }, - ]) - }) - it('should not process loads if water meter changed', () => { - const formatted = format([ - { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-01-15T00:00:00+01:00', - TypeAgregat: 'D', - ValeurIndex: 10001, - }, - ]) - expect(mockCaptureMessage).toHaveBeenCalledTimes(1) - expect(formatted).toStrictEqual([]) - }) - it('should not process loads if load is negative', () => { - const formatted = format([ - { - DateReleve: '2024-01-14T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 10000, - }, - { - DateReleve: '2024-01-15T00:00:00+01:00', - TypeAgregat: 'R', - ValeurIndex: 1, }, ]) - expect(mockCaptureMessage).toHaveBeenCalledTimes(1) - expect(formatted).toStrictEqual([]) }) }) diff --git a/src/helpers/prices.js b/src/helpers/prices.js index d56fdeb..c580323 100644 --- a/src/helpers/prices.js +++ b/src/helpers/prices.js @@ -1,7 +1,7 @@ const { log } = require('cozy-konnector-libs') const axios = require('axios').default const Sentry = require('@sentry/node') -const { DateTime } = require('luxon') +const moment = require('moment-timezone') require('../types/types') /** @@ -32,25 +32,19 @@ async function getPrices(boBaseUrl) { */ async function applyPrices(data, fluidPrices) { // Sort prices by descending start date - fluidPrices.sort( - (a, b) => - DateTime.fromISO(b.startDate, { zone: 'UTC' }).toMillis() - - DateTime.fromISO(a.startDate, { zone: 'UTC' }).toMillis() + fluidPrices.sort((a, b) => + moment(b.startDate).tz('UTC').diff(moment(a.startDate).tz('UTC')) ) return data.map(load => { // Select the first price that is before the load date - const loadDate = DateTime.fromObject( - { - year: load.year, - month: load.month, - day: load.day, - }, - { zone: 'UTC' } + const loadDate = moment.tz( + { year: load.year, month: load.month - 1, day: load.day }, + 'UTC' ) const fluidPrice = fluidPrices.find(p => { - const startDate = DateTime.fromISO(p.startDate, { zone: 'UTC' }) - return loadDate >= startDate + const startDate = moment.tz(p.startDate, 'UTC') + return loadDate.isSameOrAfter(startDate) }) if (!fluidPrice) return load return { ...load, price: fluidPrice.price * load.load } diff --git a/src/helpers/utils.js b/src/helpers/utils.js index 8c656b4..81f98c1 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -1,7 +1,6 @@ const { cozyClient } = require('cozy-konnector-libs') -const { AggregateTypes } = require('./../types/enums') const { rangeDate } = require('./../types/constants') -const { DateTime } = require('luxon') +const moment = require('moment') /** * @param {FormattedData[]} data - The daily data to aggregate. @@ -38,7 +37,6 @@ async function aggregateLoads(data, hasFoundPrices) { day: 0, hour: 0, minute: 0, - type: AggregateTypes.REEL, } if (hasFoundPrices) monthDeltas[monthKey].price = 0 } @@ -52,7 +50,6 @@ async function aggregateLoads(data, hasFoundPrices) { day: 0, hour: 0, minute: 0, - type: AggregateTypes.REEL, } if (hasFoundPrices) yearDeltas[yearKey].price = 0 } @@ -139,31 +136,28 @@ async function getLastDailyLoadDate() { }, allDailyLoads[0]) if (lastDailyLoad) - return DateTime.fromObject({ + return moment({ year: lastDailyLoad.year, - month: lastDailyLoad.month, + month: lastDailyLoad.month - 1, day: lastDailyLoad.day, - }).setLocale('en') + }).locale('en') } -async function getStartDateString() { - const manualExecution = process.env.COZY_JOB_MANUAL_EXECUTION === 'true' - const defaultStartDate = DateTime.now() - .setLocale('en') - .minus({ year: manualExecution ? 1 : 3 }) - const defaultStartDateString = defaultStartDate.toLocaleString({ - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) +async function getStartDateString(isManualExecution) { + const defaultStartDate = moment() + .startOf('day') + .locale('en') + .subtract(isManualExecution ? 1 : 3, 'year') const lastDailyLoadDate = await getLastDailyLoadDate() if (!lastDailyLoadDate) { - return defaultStartDateString + return defaultStartDate.toISOString() } // Fetch data up to one month before last daily load - const oneMonthBeforeLastDailyLoad = lastDailyLoadDate.minus({ months: 1 }) + const oneMonthBeforeLastDailyLoad = lastDailyLoadDate + .clone() + .subtract(1, 'month') // Ensure this does not exceed defaultStartDate const startDate = @@ -171,11 +165,7 @@ async function getStartDateString() { ? defaultStartDate : oneMonthBeforeLastDailyLoad - return startDate.toLocaleString({ - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) + return startDate.toISOString() } module.exports = { diff --git a/src/helpers/utils.spec.js b/src/helpers/utils.spec.js index a55823a..023773f 100644 --- a/src/helpers/utils.spec.js +++ b/src/helpers/utils.spec.js @@ -1,5 +1,5 @@ const { cozyClient } = require('cozy-konnector-libs') -const { DateTime } = require('luxon') +const moment = require('moment') const { getLastDailyLoadDate, getStartDateString } = require('./utils') jest.mock('cozy-konnector-libs', () => ({ @@ -25,58 +25,45 @@ describe('utils', () => { describe('getLastDailyLoadDate', () => { it('should return the last daily load date as a DateTime object', async () => { cozyClient.data.findAll.mockResolvedValue(mockDailyDataLoads) - const result = await getLastDailyLoadDate() - - expect(result).toBeInstanceOf(DateTime) - expect(result.year).toBe(2024) - expect(result.month).toBe(10) - expect(result.day).toBe(15) + expect(result).toBeInstanceOf(moment) + expect(result.year()).toBe(2024) + expect(result.month()).toBe(10 - 1) + expect(result.date()).toBe(15) }) it('should return undefined if there are no daily loads', async () => { cozyClient.data.findAll.mockResolvedValue([]) - const result = await getLastDailyLoadDate() - expect(result).toBeUndefined() }) }) describe('getStartDateString', () => { // Mock DateTime.now() to return a fixed date - const fixedDate = DateTime.fromObject({ year: 2024, month: 10, day: 1 }) - jest.spyOn(DateTime, 'now').mockReturnValue(fixedDate) + const fixedDate = moment({ year: 2024, month: 10 - 1, day: 1 }) + jest.spyOn(moment, 'now').mockReturnValue(fixedDate) it('should return a date one month before last daily load date', async () => { cozyClient.data.findAll.mockResolvedValue(mockDailyDataLoads) - - const startDate = await getStartDateString() - + const startDate = await getStartDateString(false) + const frenchStartDate = moment(startDate).local().format('YYYY-MM-DD') expect(startDate).toBeDefined() - expect(startDate).toBe('09/15/2024') + expect(frenchStartDate).toBe('2024-09-15') }) it('should return 1 year ago if there are no daily loads on manual execution', async () => { cozyClient.data.findAll.mockResolvedValue([]) - - // Mock a manual execution - process.env.COZY_JOB_MANUAL_EXECUTION = 'true' - - const startDate = await getStartDateString() - - expect(startDate).toBe('10/01/2023') + const startDate = await getStartDateString(true) + const frenchStartDate = moment(startDate).local().format('YYYY-MM-DD') + expect(frenchStartDate).toBe('2023-10-01') }) it('should return 3 years ago if there are no daily loads on automatic execution', async () => { cozyClient.data.findAll.mockResolvedValue([]) - - // Mock an automatic execution - process.env.COZY_JOB_MANUAL_EXECUTION = 'false' - - const startDate = await getStartDateString() - - expect(startDate).toBe('10/01/2021') + const startDate = await getStartDateString(false) + const frenchStartDate = moment(startDate).local().format('YYYY-MM-DD') + expect(frenchStartDate).toBe('2021-10-01') }) }) }) diff --git a/src/index.js b/src/index.js index 2ff11dd..4ad590f 100644 --- a/src/index.js +++ b/src/index.js @@ -5,25 +5,26 @@ const { updateOrCreate, hydrateAndFilter, } = require('cozy-konnector-libs') - -const axios = require('axios').default -const { DateTime } = require('luxon') - +const moment = require('moment') const Sentry = require('@sentry/node') // eslint-disable-next-line no-unused-vars const Tracing = require('@sentry/tracing') // Needed for tracking performance in Sentry const { version } = require('../package.json') const { isDev } = require('./helpers/env') const { rangeDate } = require('./types/constants') +const { getAuthToken, isValidContract, fetchData } = require('./requests/epgl') const { getStartDateString, aggregateLoads, updateAggregates, } = require('./helpers/utils') -const { format } = require('./helpers/format') +const { formatData } = require('./helpers/format') const { getPrices, applyPrices } = require('./helpers/prices.js') require('./types/types') +const NO_DATA = process.env.NO_DATA === 'true' +const manualExecution = process.env.COZY_JOB_MANUAL_EXECUTION === 'true' + module.exports = new BaseKonnector(start) /** @@ -47,46 +48,74 @@ Sentry.init({ }) /** - * The start function is run by the BaseKonnector instance only when it receives all the account information (fields). - * When you run this connector in 'standalone' or 'dev' mode, the account information comes from the ./konnector-dev-config.json file. + * The start function is run by the BaseKonnector instance only when it got all the account + * information (fields). When you run this connector yourself in "standalone" mode or "dev" mode, + * the account information come from ./konnector-dev-config.json file + * cozyParameters are static parameters, independents from the account. Most often, it can be a + * secret api key. * - * @param {object} fields - The account information fields. - * @param {object} cozyParameters - Cozy platform parameters. + * @param {import('./types').fields} fields - The account information fields. + * @param {{secret: import('./types').parameters}} cozyParameters - Cozy platform parameters. */ async function start(fields, cozyParameters) { const transaction = Sentry.startTransaction({ op: 'konnector', - name: 'EGL Konnector', + name: 'EPGL Konnector', }) transaction.startChild({ op: 'Konnector starting' }) - const startDateString = await getStartDateString() - const endDateString = DateTime.now() - .setLocale('en') - .toLocaleString({ day: '2-digit', month: '2-digit', year: 'numeric' }) + log('info', `isManual execution: ${manualExecution}`) + + if (NO_DATA) { + log( + 'debug', + 'NO_DATA is enabled, konnector will stop before fetching EPGL data' + ) + } + + const startDateString = await getStartDateString(manualExecution) + const endDateString = moment().startOf('day').locale('en') try { - const eglBaseUrl = cozyParameters.secret.eglBaseURL const boBaseUrl = cozyParameters.secret.boBaseUrl - const apiAuthKey = cozyParameters.secret.eglAPIAuthKey + const epglBaseUrl = cozyParameters.secret.epglBaseUrl + const epglUser = cozyParameters.secret.epglUser + const epglPassword = cozyParameters.secret.epglPassword - log('info', 'Authenticating ...') - const response = await authenticate( - fields.login, - fields.password, - eglBaseUrl, - apiAuthKey - ) - log('info', 'Successfully logged in') + // Prevent missing configuration + if (!boBaseUrl || !epglBaseUrl || !epglUser || !epglPassword) { + throw new Error('Missing configuration secrets') + } + + const accessToken = await getAuthToken(epglBaseUrl, epglUser, epglPassword) + + if ( + !(await isValidContract( + epglBaseUrl, + accessToken, + fields.contractId, + fields.meteringId + )) + ) { + throw errors.LOGIN_FAILED + } - let eglData = await getData( + if (NO_DATA) { + log('debug', `Stopping konnector before fetching data`) + process.exit() + } + + const responseData = await fetchData( + epglBaseUrl, + accessToken, + fields.contractId, + fields.meteringId, startDateString, - endDateString, - response, - eglBaseUrl, - apiAuthKey + endDateString ) - if (eglData.length === 0) { + let epglData = formatData(responseData) + + if (epglData.length === 0) { log('debug', 'No data found') transaction.setStatus(Tracing.SpanStatus.Ok) transaction.finish() @@ -98,32 +127,33 @@ async function start(fields, cozyParameters) { const hasFoundPrices = prices && prices.length > 0 if (hasFoundPrices) { - log('info', 'Found BO prices, applying them to EGL data') - eglData = await applyPrices(eglData, prices) + log('info', 'Found BO prices, applying them to EPGL data') + epglData = await applyPrices(epglData, prices) } - log('debug', 'Process EGL daily data') + log('debug', 'Process EPGL daily data') const filterDayKeys = [...rangeDate.day.keys, 'load'] if (prices) filterDayKeys.push('price') /** @type FormattedData[] */ const daysToUpdate = await hydrateAndFilter( - eglData, + epglData, rangeDate.day.doctype, { keys: filterDayKeys } ) + const { monthDeltas, yearDeltas } = await aggregateLoads( daysToUpdate, hasFoundPrices ) - log('debug', 'Store EGL daily load data') + log('debug', 'Store EPGL daily load data') await updateOrCreate( daysToUpdate, rangeDate.day.doctype, rangeDate.day.keys ) - log('debug', 'Store EGL monthly load data') + log('debug', 'Store EPGL monthly load data') await updateAggregates( monthDeltas, rangeDate.month.doctype, @@ -131,7 +161,7 @@ async function start(fields, cozyParameters) { hasFoundPrices ) - log('debug', 'Store EGL yearly load data') + log('debug', 'Store EPGL yearly load data') await updateAggregates( yearDeltas, rangeDate.year.doctype, @@ -142,13 +172,9 @@ async function start(fields, cozyParameters) { transaction.setStatus(Tracing.SpanStatus.Ok) transaction.finish() } catch (error) { - const errorMessage = `EGL konnector encountered an error. Response data: ${JSON.stringify( - error.message - )}` - Sentry.captureMessage(errorMessage, { + Sentry.captureException(error, { tags: { section: 'start', - login: fields.login, }, }) transaction.setStatus(Tracing.SpanStatus.Aborted) @@ -157,139 +183,3 @@ async function start(fields, cozyParameters) { throw error } } - -/** - * Authenticates a user with the provided credentials and returns an authentication response. - * - * @param {number} login - The user's login. - * @param {string} password - The user's password. - * @param {string} baseUrl - The base URL for the authentication request. - * @param {string} apiAuthKey - The API authentication key. - * - * @throws {Error} - Throws a Cozy error (VENDOR_DOWN or LOGIN_FAILED) in case of authentication failure. - * @returns {Promise<AuthResponse>} - The authentication response containing a token. - */ -async function authenticate(login, password, baseUrl, apiAuthKey) { - log('info', 'Authenticating ...') - const authRequest = { - method: 'post', - url: baseUrl + '/connect.aspx', - headers: { - AuthKey: apiAuthKey, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - data: { - login: login, - pass: password, - }, - } - - try { - /** @type {AuthResponse} */ - const respData = (await axios(authRequest)).data - - if (respData.codeRetour === 100) { - return respData - } - const errorMessage = `Authentication failed. Response data: ${respData?.libelleRetour}` - log('error', errorMessage) - log('error', `Code retour: ${respData?.codeRetour}`) - throw new Error(errors.VENDOR_DOWN) - } catch (error) { - log('error', error.response?.data) - Sentry.captureException(error, { - tags: { - section: 'authenticate', - }, - extra: { - compte: login, - }, - }) - if (error.response?.data.codeRetour === -4) { - throw new Error(errors.LOGIN_FAILED) - } - throw new Error(errors.VENDOR_DOWN) - } -} - -/** - * Retrieves data from a specified API using the provided response data and API configuration. - * - * @param {AuthResponse} response - The authentication response containing a valid token. - * @param {string} baseUrl - The base URL for the data request. - * @param {string} apiAuthKey - The API authentication key. - * - * @throws {Error} - Throws a Cozy error (VENDOR_DOWN, LOGIN_FAILED or UNKNOWN_ERROR) in case of failure. - * @returns {Promise<FormattedData[]>} - A promise that resolves to the retrieved and formatted data. - */ -async function getData( - startDateString, - endDateString, - response, - baseUrl, - apiAuthKey -) { - log('debug', `Get EGL data from ${startDateString} to ${endDateString}`) - const dataRequest = { - method: 'post', - url: baseUrl + '/getAllAgregatsByAbonnement.aspx', - headers: { - AuthKey: apiAuthKey, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - data: { - token: response.resultatRetour.token, - num_abt: response.resultatRetour.num_abt, - date_debut: startDateString, - date_fin: endDateString, - }, - } - - try { - /** @type {GetDataResponse} */ - const respData = (await axios(dataRequest)).data - - switch (respData.codeRetour) { - case 100: - // Sort data by date - respData.resultatRetour.sort( - (a, b) => new Date(a.DateReleve) - new Date(b.DateReleve) - ) - return format(respData.resultatRetour) - case -2: - log( - 'error', - `Get data failed. codeRetour -2. ${respData.libelleRetour}` - ) - throw errors.LOGIN_FAILED - case -1: - log( - 'error', - `Get data failed. codeRetour -1. ${respData.libelleRetour}` - ) - throw errors.VENDOR_DOWN - default: - log( - 'error', - `Get data failed. ${respData.codeRetour}. ${respData.libelleRetour}` - ) - log('error', respData) - throw errors.UNKNOWN_ERROR - } - } catch (error) { - log('debug', error.message) - Sentry.captureException(error, { - tags: { - section: 'getData', - }, - extra: { - start: startDateString, - end: endDateString, - }, - }) - if (axios.isAxiosError(error)) { - throw new Error(errors.VENDOR_DOWN) - } - throw error - } -} diff --git a/src/requests/epgl.js b/src/requests/epgl.js new file mode 100644 index 0000000..b89b542 --- /dev/null +++ b/src/requests/epgl.js @@ -0,0 +1,148 @@ +// @ts-check +const { default: Axios } = require('axios') +const { errors, log } = require('cozy-konnector-libs') +const Sentry = require('@sentry/node') + +/** + * Requests an authentication token + * + * @param {string} epglBaseUrl + * @param {string} epglUser + * @param {string} epglPassword + * @returns {Promise<string>} + */ +async function getAuthToken(epglBaseUrl, epglUser, epglPassword) { + log('info', 'getAuthToken') + const data = JSON.stringify({ + user: epglUser, + password: epglPassword, + }) + + try { + const response = await Axios({ + method: 'POST', + url: `${epglBaseUrl}/login`, + headers: { + 'Content-Type': 'application/json', + }, + data: data, + }) + + const epglToken = response.headers.efluidconnect_token + if (!epglToken) { + throw new Error('Unable to find EFLUIDCONNECT_TOKEN header') + } + + return epglToken + } catch (error) { + Sentry.captureException(`Error while getting auth token: ${error}`, { + tags: { + section: 'getAuthToken', + }, + }) + throw errors.VENDOR_DOWN + } +} + +/** + * Makes sure the pair contract ID / metering + * + * @param {string} epglBaseUrl + * @param {string} epglToken + * @param {string} contractId + * @param {string} meteringId + * @returns {Promise<boolean>} + */ +async function isValidContract(epglBaseUrl, epglToken, contractId, meteringId) { + log('info', 'isValidContract') + const data = JSON.stringify({ + refContrat: contractId, + refCompteur: meteringId, + }) + + try { + await Axios({ + method: 'POST', + url: `${epglBaseUrl}/INT38/acteurs/rechercherClientParCompteur`, + headers: { + 'Content-Type': 'application/json', + EFLUIDCONNECT_TOKEN: epglToken, + }, + data: data, + }) + return true + } catch (error) { + // If error 500, everything worked fine, but the contract and metering don't match + if (error.response && error.response.status === 500) { + return false + } + + // Else, network or API error + Sentry.captureException(`Couldn't check contract validity: ${error}`, { + tags: { + section: 'isValidContract', + }, + }) + throw errors.VENDOR_DOWN + } +} + +/** + * Fetches data with EFluid API + * + * @param {string} epglBaseUrl + * @param {string} epglToken + * @param {string} contractId + * @param {string} meteringId + * @param {string} startingDate + * @param {string} endingDate + * + * @throws {Error} - Throws a Cozy error (VENDOR_DOWN) in case of failure. + * @returns {Promise<FetchDataResponse>} - A promise that resolves to the retrieved data. + */ +async function fetchData( + epglBaseUrl, + epglToken, + contractId, + meteringId, + startingDate, + endingDate +) { + log('info', 'fetchData') + const data = JSON.stringify({ + refContrat: contractId, + refCompteur: meteringId, + dateDebut: startingDate, + dateFin: endingDate, + }) + + try { + const response = await Axios({ + method: 'POST', + url: `${epglBaseUrl}/INT38/contrats/consommationsJournalieres`, + headers: { + 'Content-Type': 'application/json', + EFLUIDCONNECT_TOKEN: epglToken, + }, + data: data, + }) + + return response.data + } catch (error) { + log('info', error.response) + // If error 500, everything worked fine, but the contract and metering don't match + if (error.response && error.response.status === 500) { + log('info', `Unexpected error status: ${error.response}`) + } + + // Else, network or API error + Sentry.captureException(`Error while fetching data: ${error}`, { + tags: { + section: 'fetchData', + }, + }) + throw errors.VENDOR_DOWN + } +} + +module.exports = { getAuthToken, isValidContract, fetchData } diff --git a/src/types/enums.js b/src/types/enums.js deleted file mode 100644 index 026cc26..0000000 --- a/src/types/enums.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Enum for aggregate types. - * @readonly - * @enum {string} - */ -const AggregateTypes = { - ANOMALIE: 'A', - ATTENTE: 'T', - DEPART: 'D', - REEL: 'R', - SERVI_A_VENTILATION: 'X', - VENTILE: 'V', -} - -module.exports = { AggregateTypes } diff --git a/src/types/types.js b/src/types/types.js index bbd157b..b2f8c8a 100644 --- a/src/types/types.js +++ b/src/types/types.js @@ -9,34 +9,37 @@ * @property {number} hour - The hour of the data point (in this case, always 0). * @property {number} minute - The minute of the data point (in this case, always 0). * @property {number} price - The price of the data point. - * @property {string} type - The type of the data point. */ /** - * @typedef {Object} GetDataResponse - * @property {number} codeRetour - * @property {string} libelleRetour - * @property {Releve[]} resultatRetour + * @typedef {Object} FetchDataResponse + * @property {Postes[]} postes + * @property {Unites} unites */ /** - * @typedef {Object} Releve - * @property {string} DateReleve - * @property {string} TypeAgregat - * @property {number} ValeurIndex + * @typedef {Object} Postes + * @property {Releve[]} data + * @property {string} libelle */ /** - * @typedef {Object} AuthResponse - * @property {number} codeRetour - * @property {string} libelleRetour - * @property {AuthResult} resultatRetour + * @typedef {Object} Releve + * @property {number} consommation + * @property {number} index + * @property {number} jour + * @property {number} mois + * @property {number} annee + * @property {number} [debitMin] + * @property {number} [volumeEstimeFuite] */ /** - * @typedef {Object} AuthResult - * @property {number} num_abt - * @property {string} token + * @typedef {Object} Unites + * @property {string} consommation + * @property {string} index + * @property {string} debitMin + * @property {string} volumeEstimeFuite */ /** diff --git a/yarn.lock b/yarn.lock index f1e32e7..91e9f6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2326,10 +2326,10 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axios@1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" - integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== +axios@1.7.7: + version "1.7.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" + integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -6108,11 +6108,6 @@ lru_map@^0.3.3: resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== -luxon@^3.4.3: - version "3.5.0" - resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" - integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== - make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -6320,6 +6315,18 @@ modify-values@^1.0.0: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== +moment-timezone@^0.5.45: + version "0.5.46" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a" + integrity sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw== + dependencies: + moment "^2.29.4" + +moment@^2.29.4, moment@^2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + morgan@^1.9.1: version "1.10.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" -- GitLab