Newer
Older
require('./instrument.js') // Sentry initialization
const soapRequest = require('easy-soap-request')
const moment = require('moment')
require('moment-timezone')
const xml2js = require('xml2js')
const {
parseSgeXmlData,
formateDataForDoctype,
parseTags,
parseValue,

Bastien DUMONT
committed
} = require('./helpers/parsing')
consultationMesuresDetailleesMaxPower,
consultationMesuresDetaillees,
updateBoConsent,
createBoConsent,
getBoConsent,
deleteBoConsent,

Bastien DUMONT
committed
const {
verifyUserIdentity,
activateContract,
verifyContract,
terminateContract,
} = require('./core')
const { getAccount, saveAccountData } = require('./requests/cozy')
const { isLocal } = require('./helpers/env')
const { catchRequestReject } = require('./helpers/catch')
const { applyPrices, getPrices } = require('./helpers/prices.js')
const { rangeDate } = require('./constants.js')
const {
aggregateMonthlyLoad,
filterFirstMonthlyLoad,
aggregateYearlyLoad,
filterFirstYearlyLoad,
} = require('./helpers/aggregate.js')
moment.locale('fr') // set the language
moment.tz.setDefault('Europe/Paris') // set the timezone
/** Connector Constants **/
const manualExecution = process.env.COZY_JOB_MANUAL_EXECUTION === 'true'
let startDate = manualExecution
? moment().subtract(12, 'month').add(1, 'day')
: moment().subtract(36, 'month').add(1, 'day')
let startDateString = startDate.format('YYYY-MM-DD')
const startHalfHourDate = moment().subtract(7, 'day')
const endDate = moment()
const endDateString = endDate.format('YYYY-MM-DD')

Guilhem CARRON
committed
const ACCOUNT_ID = isLocal() ? 'default_account_id' : 'enedissgegrandlyon'
const NO_DATA = process.env.NO_DATA === 'true'

Bastien DUMONT
committed
/**
* 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 {fields} fields
* @param {{secret: fields}} cozyParameters
*/
try {
log('info', 'Konnector configuration ...')
log('info', `isManual execution: ${manualExecution}`)
if (NO_DATA) {
log(
'debug',
'NO_DATA is enabled, konnector will stop after verifyUserIdentity()'
)
}
const pointId = parsePointId(parseInt(fields.pointId))
let baseUrl = fields.wso2BaseUrl
let apiAuthKey = fields.apiToken
let contractId = fields.contractId
let sgeLogin = fields.sgeLogin
let boToken = fields.boToken
let boBaseUrl = fields.boBaseUrl
if (cozyParameters && Object.keys(cozyParameters).length !== 0) {
log('debug', 'Found COZY_PARAMETERS')
baseUrl = cozyParameters.secret.wso2BaseUrl
apiAuthKey = cozyParameters.secret.apiToken
contractId = cozyParameters.secret.contractId
sgeLogin = cozyParameters.secret.sgeLogin
boBaseUrl = cozyParameters.secret.boBaseUrl
boToken = cozyParameters.secret.boToken
}

Bastien DUMONT
committed
// Prevent missing configuration
if (
!baseUrl ||
!apiAuthKey ||
!contractId ||
!sgeLogin ||
!boToken ||
!boBaseUrl
) {
const errorMessage = 'Missing configuration secrets'
log('error', errorMessage)
Sentry.captureException(errorMessage, {
tags: { section: 'start' },
})
throw new Error(errors.VENDOR_DOWN)
/**
* If it's first start we have to do the following operations:
* - verify pdl are matching
* - BO: create backoffice consent
* - get contract start date and store it
* - activate half-hour
* - BO: update consent with service ID
*/
log('info', 'User Logging...')
const boUrlSGE = new URL('/api/sge', boBaseUrl).href
if (isFirstStart(await getAccount(ACCOUNT_ID))) {
log('info', 'First start...')
const user = await verifyUserIdentity(
fields,
baseUrl,
apiAuthKey,
sgeLogin
)

Bastien DUMONT
committed
boToken,
pointId,
user.lastname,
user.firstname,
user.address,
user.postalCode,
user.inseeCode,
user.city,
user.hasBeenThroughSafetyOnBoarding

Bastien DUMONT
committed
const contractStartDate = moment().format('YYYY-MM-DD')
const contractEndDate = moment()
.add(1, 'year') // SGE force 1 year duration
.format('YYYY-MM-DD')
let serviceId = await verifyContract(

Bastien DUMONT
committed
baseUrl,
apiAuthKey,
sgeLogin,
contractId,
user.pointId
)
if (!serviceId) {
serviceId = await activateContract(
baseUrl,
apiAuthKey,
sgeLogin,
contractId,
user.lastname,
user.pointId,
contractStartDate,
contractEndDate
).catch(async err => {
await deleteBoConsent(boUrlSGE, boToken, consent.ID)

Guilhem CARRON
committed
boToken,
consent,
serviceId.toString()
)
// Save bo id into account
const accountData = await getAccount(ACCOUNT_ID)
await saveAccountData(ACCOUNT_ID, {
...accountData.data,
consentId: consent.ID,
expirationDate: contractEndDate,
inseeCode: user.inseeCode,
})
} else {
log('info', 'Alternate start...')
const accountData = await getAccount(ACCOUNT_ID)
const userConsent = await getBoConsent(
boToken,
accountData.data.consentId
)
const user = await verifyUserIdentity(
fields,
baseUrl,
apiAuthKey,
sgeLogin,
true,
accountData.data.inseeCode

Bastien DUMONT
committed
)
if (!userConsent) {
const errorMessage = 'No user consent found'
log('error', errorMessage)
Sentry.captureException(errorMessage, {
tags: { section: 'start' },
})
throw new Error(errors.VENDOR_DOWN)
}
const consentEndDate = Date.parse(userConsent.endDate)
const today = Date.now()
if (
user.lastname.toLocaleUpperCase() !==
userConsent.lastname.toLocaleUpperCase() ||
!user ||
consentEndDate < today
) {
await deleteConsent(
userConsent,
baseUrl,
apiAuthKey,
sgeLogin,
contractId,
pointId,
await gatherData(baseUrl, apiAuthKey, sgeLogin, pointId, boBaseUrl)
log('info', 'Konnector success')
} catch (error) {
const errorMessage = `SGE konnector encountered an error. Response data: ${JSON.stringify(
error.message
)}`
Sentry.captureMessage(errorMessage, {
tags: {
section: 'start',
},
})

Bastien DUMONT
committed
}
/**
* Delete User Consent
* @param {Consent} userConsent
* @param {string} baseUrl
* @param {string} apiAuthKey
* @param {string} sgeLogin
* @param {string} contractId

Bastien DUMONT
committed
* @param {string} boBaseUrl
* @param {string} boToken

Guilhem CARRON
committed
* @param {boolean} isConsentExpired

Bastien DUMONT
committed
*/
async function deleteConsent(
userConsent,
baseUrl,
apiAuthKey,
sgeLogin,
contractId,
pointId,
boBaseUrl,

Guilhem CARRON
committed
boToken,
isConsentExpired

Bastien DUMONT
committed
) {
log('error', `Invalid or not found consent for user`)
Sentry.captureMessage(`Invalid or not found consent for user`)

Bastien DUMONT
committed
if (userConsent.serviceID) {
await terminateContract(
baseUrl,
apiAuthKey,
sgeLogin,
contractId,
pointId,
userConsent.serviceID
)
await deleteBoConsent(boBaseUrl, boToken, userConsent.ID || 0)
} else {
const errorMessage = `No service id retrieved from BO`
log('error', errorMessage)
Sentry.captureException(errorMessage, {
tags: { section: 'start' },
})
throw new Error(errors.VENDOR_DOWN)

Bastien DUMONT
committed
}

Guilhem CARRON
committed
if (isConsentExpired) {
Sentry.captureException('Consent expired', {
tags: { section: 'start' },
})
throw new Error(errors.USER_ACTION_NEEDED_OAUTH_OUTDATED)

Guilhem CARRON
committed
}
throw new Error(errors.TERMS_VERSION_MISMATCH)
}
/**
* Main method for gathering data
* @param {string} baseUrl
* @param {string} apiAuthKey

Bastien DUMONT
committed
* @param {string} sgeLogin
async function gatherData(baseUrl, apiAuthKey, sgeLogin, pointId, boBaseUrl) {
const measuresUrl = new URL(
'/enedis_SGE_ConsultationMesuresDetaillees_v3/1.0',
baseUrl
).href
const contractUrl = new URL(
'/enedis_SGE_ConsultationDonneesTechniquesContractuelles/1.0',
baseUrl
).href
log('info', 'Fetching BO prices')
const prices = await getPrices(boBaseUrl)
await getDailyData(measuresUrl, apiAuthKey, sgeLogin, pointId, prices)
await getMaxPowerData(measuresUrl, apiAuthKey, sgeLogin, pointId)
await getDataHalfHour(measuresUrl, apiAuthKey, sgeLogin, pointId, prices)
await getOffPeakHours(contractUrl, apiAuthKey, sgeLogin, pointId)
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
/**
* Get hour data
* @param {string} url
* @param {string} apiAuthKey
* @param {string} userLogin
* @param {string} pointId
*/
async function getOffPeakHours(url, apiAuthKey, userLogin, pointId) {
log('info', 'Fetching off-peak hours')
const sgeHeaders = {
'Content-Type': 'text/xml;charset=UTF-8',
apikey: apiAuthKey,
}
const { response } = await soapRequest({
url: url,
headers: sgeHeaders,
xml: consulterDonneesTechniquesContractuelles(pointId, userLogin, false),
}).catch(err => {
log('error', 'consulterDonneesTechniquesContractuelles')
log('error', err)
Sentry.captureException(
`consulterDonneesTechniquesContractuelles: ${err}`,
{
tags: { section: 'getOffPeakHour' },
extra: {
pointId: pointId,
},
}
)
return err
})
catchRequestReject(response.body)
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
const result = await xml2js.parseStringPromise(response.body, {
tagNameProcessors: [parseTags],
valueProcessors: [parseValue],
explicitArray: false,
})
try {
const offPeakHours = parseUserOffPeakHours(result)
log(
'debug',
`Found off-peak hours : ${offPeakHours}, store them in account data`
)
const accountData = await getAccount(ACCOUNT_ID)
await saveAccountData(ACCOUNT_ID, {
...accountData.data,
offPeakHours,
})
} catch (error) {
log('debug', 'Off-peak hours not found, remove them from account data')
let accountData = await getAccount(ACCOUNT_ID)
delete accountData.data.offPeakHours
await saveAccountData(ACCOUNT_ID, {
...accountData.data,
})
}
}
* @param {string} url
* @param {string} apiAuthKey
* @param {string} userLogin
async function getDailyData(url, apiAuthKey, userLogin, pointId, prices) {
log('info', 'Fetching daily data')
'Content-Type': 'text/xml;charset=UTF-8',
apikey: apiAuthKey,
}
const { response } = await soapRequest({
url: url,
Sentry.captureException(`consultationMesuresDetaillees: ${err}`, {
tags: { section: 'getData' },
})
catchRequestReject(response.body)
tagNameProcessors: [parseTags],
valueProcessors: [parseValue],
explicitArray: false,
/**
* Get Max power data
* @param {string} url
* @param {string} apiAuthKey
* @param {string} userLogin
*/
async function getMaxPowerData(url, apiAuthKey, userLogin, pointId) {
log('info', 'Fetching Max Power data')
'Content-Type': 'text/xml;charset=UTF-8',
apikey: apiAuthKey,
}
const { response } = await soapRequest({
url: url,
xml: consultationMesuresDetailleesMaxPower(
pointId,
userLogin,
}).catch(err => {
log('error', 'getMaxPowerData')
log('error', err)
Sentry.captureException(`getMaxPowerData: ${err}`, {
tags: { section: 'getMaxPowerData' },
})
catchRequestReject(response.body)
xml2js.parseString(
response.body,
{
tagNameProcessors: [parseTags],
valueProcessors: [parseValue],
explicitArray: false,
},
/**
* Get half-hour data
* @param {string} url
* @param {string} apiAuthKey
* @param {string} userLogin
async function getDataHalfHour(url, apiAuthKey, userLogin, pointId, prices) {
log('info', 'Fetching half-hour data')
'Content-Type': 'text/xml;charset=UTF-8',
apikey: apiAuthKey,
}
// If manual execution, retrieve only 1 week otherwise retrieve 4 weeks
const MAX_HISTO = manualExecution ? 1 : 4
const incrementedStartDateString = moment(startHalfHourDate)
.subtract(7 * i, 'day')
.format('YYYY-MM-DD')
const incrementedEndDateString = moment(endDate)
.subtract(7 * i, 'day')
.format('YYYY-MM-DD')

Bastien DUMONT
committed
const { response } = await soapRequest({
url: url,
incrementedEndDateString,
'COURBE',
'PA'
),
}).catch(err => {
log('error', 'consultationMesuresDetaillees half-hour')
Sentry.captureException(
`consultationMesuresDetaillees half-hour: ${err}`,
{
tags: { section: 'getDataHalfHour' },
}
)
catchRequestReject(response.body)
xml2js.parseString(
response.body,
{
tagNameProcessors: [parseTags],
valueProcessors: [parseValueHalfHour],
return async (err, result) => {
if (err) {
log('error', err)
Sentry.captureException('error while processing daily data')
throw err
}
// Return only needed part of info
log('info', `Processing ${rangeDate.day.doctype} data`)

Bastien DUMONT
committed
const data = parseSgeXmlData(result)
let dailyData = await formateDataForDoctype(data)
if (prices && prices.length > 0) {
log('info', 'Found BO prices, applying them to enedis data')
dailyData = await applyPrices(dailyData, prices)

Bastien DUMONT
committed
}
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
const filterDayKeys = [...rangeDate.day.keys, 'load']
if (prices) filterDayKeys.push('price')
const daysToUpdate = await hydrateAndFilter(
dailyData,
rangeDate.day.doctype,
{ keys: filterDayKeys }
)
log('debug', 'Store enedis daily load data')
await updateOrCreate(
daysToUpdate,
rangeDate.day.doctype,
rangeDate.day.keys
)
const { year: firstYear, month: firstMonth } = dailyData[0]
log('debug', 'Aggregate enedis monthly load data')
const monthlyLoads = aggregateMonthlyLoad(dailyData)
log('debug', 'Filter first month aggregate if already in database')
const filteredMonthlyLoads = await filterFirstMonthlyLoad(
firstMonth,
firstYear,
monthlyLoads
)
const filterMonthKeys = [...rangeDate.month.keys, 'load']
if (prices) filterMonthKeys.push('price')
const monthsToUpdate = await hydrateAndFilter(
filteredMonthlyLoads,
rangeDate.month.doctype,
{ keys: filterMonthKeys }
)
log('debug', 'Store aggregated enedis monthly load data')
await updateOrCreate(
monthsToUpdate,
rangeDate.month.doctype,
rangeDate.month.keys
)
log('debug', 'Aggregate enedis yearly load data')
const yearlyLoads = aggregateYearlyLoad(monthlyLoads)
log('debug', 'Filter first year aggregate if already in database')
const filteredYearlyLoads = await filterFirstYearlyLoad(
firstYear,
yearlyLoads
)
const filterYearKeys = [...rangeDate.year.keys, 'load']
if (prices) filterYearKeys.push('price')
const yearsToUpdate = await hydrateAndFilter(
filteredYearlyLoads,
rangeDate.year.doctype,
{ keys: filterYearKeys }
)
log('debug', 'Store aggregated enedis yearly load data')
await updateOrCreate(
yearsToUpdate,
rangeDate.year.doctype,
rangeDate.year.keys
)

Bastien DUMONT
committed
} catch (e) {
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
function processMaxPowerData() {
return async (err, result) => {
if (err) {
log('error', err)
Sentry.captureException('error while processing daily data')
throw err
}
// Return only needed part of info
log('info', `Processing ${rangeDate.maxPower.doctype} data`)
try {
const data = parseSgeXmlData(result)
const maxPowerData = await formateDataForDoctype(data)
const filterMaxPowerKeys = [...rangeDate.maxPower.keys, 'load']
const maxPowerToUpdate = await hydrateAndFilter(
maxPowerData,
rangeDate.maxPower.doctype,
{ keys: filterMaxPowerKeys }
)
log('debug', 'Store Enedis max power load data')
await updateOrCreate(
maxPowerToUpdate,
rangeDate.maxPower.doctype,
rangeDate.maxPower.keys
)
} catch (e) {
log('warn', `Unknown error ${e}`)
}
}
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
function processHalfHourData(prices) {
return async (err, result) => {
if (err) {
log('error', err)
Sentry.captureException('error while processing half-hour data')
throw err
}
// Return only needed part of info
log('info', `Processing ${rangeDate.minute.doctype} data`)
try {
const data = parseSgeXmlData(result)
let minuteData = await formateDataForDoctype(data)
if (
(rangeDate.minute.doctype === 'com.grandlyon.enedis.day' ||
rangeDate.minute.doctype === 'com.grandlyon.enedis.minute') &&
prices &&
prices.length > 0
) {
log('info', 'Found BO prices, applying them to enedis data')
minuteData = await applyPrices(minuteData, prices)
}
const filterMinuteKeys = [...rangeDate.minute.keys, 'load']
if (prices) filterMinuteKeys.push('price')
const minutesToUpdate = await hydrateAndFilter(
minuteData,
rangeDate.minute.doctype,
{ keys: filterMinuteKeys }
)
log('debug', 'Store Enedis minute load data')
await updateOrCreate(
minutesToUpdate,
rangeDate.minute.doctype,
rangeDate.minute.keys
)
} catch (e) {
const errorMessage = `No half-hour activated. Issue: ${result.Envelope.Body.Fault.faultstring}`
Sentry.captureMessage(errorMessage, {
tags: { section: 'processData' },
})
log('warn', errorMessage)
}

Bastien DUMONT
committed
function isFirstStart(account) {
if (account?.data?.consentId) {

Bastien DUMONT
committed
log('info', 'Konnector not first start')
return false
}
log('info', 'Konnector first start')
/**
* Check if konnector is launched in local with NO_DATA option
* If so, logs result from verifyUserIdentity() and stops the konnector before getting any data
* @param {User} user - The user object to log
*/
function exitIfDebug(user) {
if (NO_DATA) {
log(
'debug',
`Stopping konnector before getting data, user found from verifyUserIdentity():`
)
log('debug', user)
process.exit()
}
}