Newer
Older
const soapRequest = require('easy-soap-request')
const moment = require('moment')
require('moment-timezone')
const xml2js = require('xml2js')
const { buildAggregatedData } = require('./helpers/aggregate')
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, isDev } = require('./helpers/env')
const Sentry = require('@sentry/node')
// eslint-disable-next-line
const Tracing = require('@sentry/tracing') // Needed for tracking performance in Sentry
const { version } = require('../package.json')
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(36, 'month')
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'
dsn: 'https://18747a93401447f2a81b83cd8c4bbbdf@grandlyon.errors.cozycloud.cc/5',
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: isLocal() ? 0 : 1.0,
release: version,
environment: isDev() ? 'development' : 'production',
debug: isDev(),
integrations: [
// enable HTTP calls tracing
new Sentry.Integrations.Http({ tracing: 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
*/
const transaction = Sentry.startTransaction({
op: 'konnector',
name: 'SGE Konnector',
})
transaction.startChild({ op: 'Konnector starting' })
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...')
transaction.startChild({ op: '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...')
transaction.startChild({ op: '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,
log('info', 'Successfully logged in')
await gatherData(baseUrl, apiAuthKey, sgeLogin, pointId)
transaction.finish()
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',
},
})
transaction.setStatus(Tracing.spanStatusfromHttpCode(409))
transaction.finish()

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

Bastien DUMONT
committed
async function gatherData(baseUrl, apiAuthKey, sgeLogin, pointId) {
const measuresUrl = new URL(
'/enedis_SGE_ConsultationMesuresDetaillees_v3/1.0',
baseUrl
).href
const contractUrl = new URL(
'/enedis_SGE_ConsultationDonneesTechniquesContractuelles/1.0',
baseUrl
).href
await getData(measuresUrl, apiAuthKey, sgeLogin, pointId)
await getMaxPowerData(measuresUrl, apiAuthKey, sgeLogin, pointId)
await getDataHalfHour(measuresUrl, apiAuthKey, sgeLogin, pointId)
await getOffPeakHours(contractUrl, apiAuthKey, sgeLogin, pointId)
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
/**
* 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
})
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 getData(url, apiAuthKey, userLogin, pointId) {
log('info', 'Fetching data')
'Content-Type': 'text/xml;charset=UTF-8',
apikey: apiAuthKey,
}
const { response } = await soapRequest({
url: url,
Sentry.captureException(`consultationMesuresDetaillees: ${err}`, {
tags: { section: 'getData' },
})
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' },
})
return err
})
xml2js.parseString(
response.body,
{
tagNameProcessors: [parseTags],
valueProcessors: [parseValue],
explicitArray: false,
},
processData('com.grandlyon.enedis.maxpower')
)
}
/**
* Get half-hour data
* @param {string} url
* @param {string} apiAuthKey
* @param {string} userLogin
*/
async function getDataHalfHour(url, apiAuthKey, userLogin, pointId) {
log('info', 'Fetching 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' },
}
)
return err
})
xml2js.parseString(
response.body,
{
tagNameProcessors: [parseTags],
valueProcessors: [parseValueHalfHour],
explicitArray: false,
},
processData('com.grandlyon.enedis.minute')
)
}
}
function processData(doctype = 'com.grandlyon.enedis.day') {
return async (err, result) => {
if (err) {
log('error', err)
Sentry.captureException('error while processing daily data')
throw err
}
// Return only needed part of info

Bastien DUMONT
committed
log('info', doctype)

Bastien DUMONT
committed
const data = parseSgeXmlData(result)
const processedDailyData = await storeData(
await formateDataForDoctype(data),
doctype,
['year', 'month', 'day', 'hour', 'minute']
)
log('info', 'Aggregate enedis daily data for month and year')

Bastien DUMONT
committed
if (doctype === 'com.grandlyon.enedis.day') {
await aggregateMonthAndYearData(processedDailyData)

Bastien DUMONT
committed
}
} catch (e) {
if (doctype === 'com.grandlyon.enedis.minute') {
const errorMessage = `No half-hour activated. Issue: ${result.Envelope.Body.Fault.faultstring}`
Sentry.captureMessage(errorMessage, {
tags: { section: 'processData' },
})

Bastien DUMONT
committed
} else {

Bastien DUMONT
committed
}
/**
* Save data in the right doctype db and prevent duplicated keys
* @param {EnedisKonnectorData[]} data
* @param {string} doctype
* @param {string[]} filterKeys
*/
async function storeData(data, doctype, filterKeys) {
log('debug', doctype, 'Store into')
const filteredDocuments = await hydrateAndFilter(data, doctype, {
keys: filterKeys,
})
await addData(filteredDocuments, doctype)
return filteredDocuments
* Aggregate data from daily data to monthly and yearly data
// Sum year and month values into object with year or year-month as keys
if (data && data.length > 0) {
let monthData = {}
let yearData = {}
data.forEach(element => {
element.year + '-' + element.month in monthData
? (monthData[element.year + '-' + element.month] += element.load)
: (monthData[element.year + '-' + element.month] = element.load)
element.year in yearData
? (yearData[element.year] += element.load)
: (yearData[element.year] = element.load)
})
// Aggregation for Month data
const aggregatedMonthData = await buildAggregatedData(
monthData,
'com.grandlyon.enedis.month'
)
await storeData(aggregatedMonthData, 'com.grandlyon.enedis.month', [
// Aggregation for Year data
const aggregatedYearData = await buildAggregatedData(
yearData,
'com.grandlyon.enedis.year'
)
await storeData(aggregatedYearData, 'com.grandlyon.enedis.year', ['year'])

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()
}
}