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')
const { catchRequestReject } = require('./helpers/catch')
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)
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
/**
* 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)
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
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 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,
processData('com.grandlyon.enedis.day')
/**
* 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,
},
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 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],
explicitArray: false,
},
processData('com.grandlyon.enedis.minute')
)
}
}
function processData(doctype) {
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 ${doctype} data`)

Bastien DUMONT
committed
const data = parseSgeXmlData(result)
const processedDailyData = await storeData(
await formateDataForDoctype(data),
doctype,
['year', 'month', 'day', 'hour', 'minute']
)
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()
}
}