Skip to content
Snippets Groups Projects
index.js 17.2 KiB
Newer Older
// @ts-check
Hugo SUBTIL's avatar
Hugo SUBTIL committed
const {
  BaseKonnector,
  log,
  hydrateAndFilter,
  addData,
  errors,
Hugo SUBTIL's avatar
Hugo SUBTIL committed
} = require('cozy-konnector-libs')
const soapRequest = require('easy-soap-request')
const moment = require('moment')
require('moment-timezone')
const xml2js = require('xml2js')
Bastien DUMONT's avatar
Bastien DUMONT committed
const { buildAggregatedData } = require('./helpers/aggregate')
Hugo SUBTIL's avatar
Hugo SUBTIL committed
const {
  parseSgeXmlData,
  formateDataForDoctype,
  parseTags,
  parseValue,
  parseValueHalfHour,
Hugo SUBTIL's avatar
Hugo SUBTIL committed
const {
Bastien DUMONT's avatar
Bastien DUMONT committed
  consultationMesuresDetailleesMaxPower,
  consultationMesuresDetaillees,
} = require('./requests/sge')
const {
  updateBoConsent,
  createBoConsent,
  getBoConsent,
  deleteBoConsent,
Bastien DUMONT's avatar
Bastien DUMONT committed
} = require('./requests/bo')
const {
  verifyUserIdentity,
  activateContract,
  verifyContract,
  terminateContract,
  getContractStartDate,
} = require('./core')
const { getAccount, saveAccountData } = require('./requests/cozy')
Bastien DUMONT's avatar
Bastien DUMONT committed
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
Hugo SUBTIL's avatar
Hugo SUBTIL committed

/*** Connector Constants ***/
const manualExecution =
  process.env.COZY_JOB_MANUAL_EXECUTION === 'true' ? true : false
Hugo SUBTIL's avatar
Hugo SUBTIL committed
let startDailyDate = manualExecution
  ? moment().subtract(12, 'month')
  : moment().subtract(6, 'month')
Hugo SUBTIL's avatar
Hugo SUBTIL committed
let startDailyDateString = startDailyDate.format('YYYY-MM-DD')
const startLoadDate = moment().subtract(7, 'day')
const endDate = moment()
const endDateString = endDate.format('YYYY-MM-DD')
const ACCOUNT_ID = isLocal() ? 'default_account_id' : 'enedissgegrandlyon'
const NO_DATA = process.env.NO_DATA === 'true'
Hugo SUBTIL's avatar
Hugo SUBTIL committed

module.exports = new BaseKonnector(start)

Bastien DUMONT's avatar
Bastien DUMONT committed
/**
 * Sentry
 */
Sentry.init({
  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 }),
  ],
})

/**
 * 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
 */
Hugo SUBTIL's avatar
Hugo SUBTIL committed
async function start(fields, cozyParameters) {
Bastien DUMONT's avatar
Bastien DUMONT committed
  try {
    log('info', 'Konnector configuration ...')
    log('info', `isManual execution: ${manualExecution}`)

    if (NO_DATA) {
      log(
        'debug',
        'NO_DATA is enabled, konnector will stop after verifyUserIdentity()'
      )
    }
Bastien DUMONT's avatar
Bastien DUMONT committed
    const transaction = Sentry.startTransaction({
      op: 'konnector',
      name: 'SGE Konnector',
    })
Bastien DUMONT's avatar
Bastien DUMONT committed
    const pointId = 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's avatar
Bastien DUMONT committed
    // Prevent missing configuration
    if (
      !baseUrl ||
      !apiAuthKey ||
      !contractId ||
      !sgeLogin ||
      !boToken ||
      !boBaseUrl
    ) {
      const errorMessage = 'Missing configuration secrets'
      log('error', errorMessage)
      Sentry.captureException(errorMessage)
      throw errors.VENDOR_DOWN
    }
Hugo SUBTIL's avatar
Hugo SUBTIL committed

Bastien DUMONT's avatar
Bastien DUMONT committed
    /**
     * 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...')

    if (isFirstStart(await getAccount(ACCOUNT_ID))) {
      log('info', 'First start...')
      transaction.startChild({ op: 'First start' })
      const user = await verifyUserIdentity(
        fields,
        baseUrl,
        apiAuthKey,
        sgeLogin
      )
      exitIfDebug(user)

Bastien DUMONT's avatar
Bastien DUMONT committed
      let consent = await createBoConsent(
        boBaseUrl,
        boToken,
        pointId,
        user.lastname,
        user.firstname,
        user.address,
        user.postalCode,
        user.inseeCode,
        user.city,
        user.hasBeenThroughtSafetyOnBoarding
      )
Bastien DUMONT's avatar
Bastien DUMONT committed
      // handle user contract start date in order to properly request data
      const userContractStartDate = await getContractStartDate(
Bastien DUMONT's avatar
Bastien DUMONT committed
        pointId
Bastien DUMONT's avatar
Bastien DUMONT committed
      startDailyDate = moment(userContractStartDate, 'YYYY-MM-DD')
      startDailyDateString = startDailyDate.format('YYYY-MM-DD')
Bastien DUMONT's avatar
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's avatar
Bastien DUMONT committed
        user.pointId
      )
      if (!serviceId) {
        serviceId = await activateContract(
          baseUrl,
          apiAuthKey,
          sgeLogin,
          contractId,
          user.lastname,
          user.pointId,
          contractStartDate,
          contractEndDate
        )
      }
      consent = await updateBoConsent(
Bastien DUMONT's avatar
Bastien DUMONT committed
        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(
        boBaseUrl,
        boToken,
        accountData.data.consentId
      )
      const user = await verifyUserIdentity(
        fields,
        baseUrl,
        apiAuthKey,
        sgeLogin,
        true,
        accountData.data.inseeCode
      exitIfDebug(user)

Bastien DUMONT's avatar
Bastien DUMONT committed
      if (!userConsent) {
        const errorMessage = 'No user consent found'
        log('error', errorMessage)
        Sentry.captureException(errorMessage)
        throw 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,
          boBaseUrl,
          boToken,
          consentEndDate < today
        )
      }
Bastien DUMONT's avatar
Bastien DUMONT committed
    log('info', 'Successfully logged in')
    await gatherData(baseUrl, apiAuthKey, sgeLogin, pointId)

    transaction.finish()
    log('info', 'Konnector success')
  } catch (error) {
    log('debug', 'error catched in start()', error)
    await Sentry.flush()
    throw error
}

/**
 * Delete User Consent
 * @param {Consent} userConsent
 * @param {string} baseUrl
 * @param {string} apiAuthKey
 * @param {string} sgeLogin
 * @param {string} contractId
 * @param {number} pointId
 * @param {string} boBaseUrl
 * @param {string} boToken
 */
async function deleteConsent(
  userConsent,
  baseUrl,
  apiAuthKey,
  sgeLogin,
  contractId,
  pointId,
  boBaseUrl,
) {
  log('error', `Invalid or not found consent for user`)
Bastien DUMONT's avatar
Bastien DUMONT committed
  Sentry.captureMessage(`Invalid or not found consent for user`)
  if (userConsent.serviceID) {
    await terminateContract(
      baseUrl,
      apiAuthKey,
      sgeLogin,
      contractId,
      pointId,
      userConsent.serviceID
    )
    await deleteBoConsent(boBaseUrl, boToken, userConsent.ID || 0)
  } else {
Bastien DUMONT's avatar
Bastien DUMONT committed
    const errorMessage = `No service id retrieved from BO`
    log('error', errorMessage)
    Sentry.captureException(errorMessage)
Bastien DUMONT's avatar
Bastien DUMONT committed
    Sentry.captureException('Consent expired')
    throw errors.USER_ACTION_NEEDED_OAUTH_OUTDATED
  }
}

/**
 * Main method for gathering data
 * @param {string} baseUrl
 * @param {string} apiAuthKey
 * @param {number} pointId
 */
async function gatherData(baseUrl, apiAuthKey, sgeLogin, pointId) {
  log('info', 'Querying data...')
Bastien DUMONT's avatar
Bastien DUMONT committed
  const userContractStartDate = await getContractStartDate(
    baseUrl,
    apiAuthKey,
    sgeLogin,
    pointId
  )

Bastien DUMONT's avatar
Bastien DUMONT committed
  startDailyDate = moment(userContractStartDate, 'YYYY-MM-DD')
  startDailyDateString = startDailyDate.format('YYYY-MM-DD')

  await getData(
    `${baseUrl}/enedis_SGE_ConsultationMesuresDetaillees/1.0`,
    apiAuthKey,
    pointId
Hugo SUBTIL's avatar
Hugo SUBTIL committed
  await getMaxPowerData(
    `${baseUrl}/enedis_SGE_ConsultationMesuresDetaillees/1.0`,
    apiAuthKey,
    pointId
Hugo SUBTIL's avatar
Hugo SUBTIL committed
  )
  await getDataHalfHour(
    `${baseUrl}/enedis_SGE_ConsultationMesuresDetaillees/1.0`,
    apiAuthKey,
    pointId
  )
  log('info', 'Querying data: done')
Hugo SUBTIL's avatar
Hugo SUBTIL committed
}
 * Get hour data
 * @param {string} url
 * @param {string} apiAuthKey
 * @param {string} userLogin
 * @param {number} pointId
 */
async function getData(url, apiAuthKey, userLogin, pointId) {
  log('info', 'Fetching data')
  const sgeHeaders = {
    'Content-Type': 'text/xml;charset=UTF-8',
    apikey: apiAuthKey,
  }
Hugo SUBTIL's avatar
Hugo SUBTIL committed
  limitStartDate()
  const { response } = await soapRequest({
    url: url,
    headers: sgeHeaders,
Bastien DUMONT's avatar
Bastien DUMONT committed
    xml: consultationMesuresDetaillees(
      pointId,
      userLogin,
      startDailyDateString,
Bastien DUMONT's avatar
Bastien DUMONT committed
      endDateString,
      'ENERGIE',
      'EA'
    ),
  }).catch(err => {
Bastien DUMONT's avatar
Bastien DUMONT committed
    log('error', 'consultationMesuresDetaillees')
    log('error', err)
Bastien DUMONT's avatar
Bastien DUMONT committed
    Sentry.captureException('consultationMesuresDetaillees:', err)
    return err
  xml2js.parseString(
    response.body,
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    {
      tagNameProcessors: [parseTags],
      valueProcessors: [parseValue],
      explicitArray: false,
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    },
    processData()
Hugo SUBTIL's avatar
Hugo SUBTIL committed
/**
 * Get Max power data
 * @param {string} url
 * @param {string} apiAuthKey
 * @param {string} userLogin
 * @param {number} pointId
 */
async function getMaxPowerData(url, apiAuthKey, userLogin, pointId) {
  log('info', 'Fetching Max Power data')
  const sgeHeaders = {
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    'Content-Type': 'text/xml;charset=UTF-8',
    apikey: apiAuthKey,
  }

Hugo SUBTIL's avatar
Hugo SUBTIL committed
  limitStartDate()
Hugo SUBTIL's avatar
Hugo SUBTIL committed

  const { response } = await soapRequest({
    url: url,
    headers: sgeHeaders,
Bastien DUMONT's avatar
Bastien DUMONT committed
    xml: consultationMesuresDetailleesMaxPower(
      pointId,
      userLogin,
      startDailyDateString,
      endDateString
    ),
Hugo SUBTIL's avatar
Hugo SUBTIL committed
  }).catch(err => {
    log('error', 'getMaxPowerData')
    log('error', err)
Bastien DUMONT's avatar
Bastien DUMONT committed
    Sentry.captureException('getMaxPowerDate')
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    return err
  })

  xml2js.parseString(
    response.body,
    {
      tagNameProcessors: [parseTags],
      valueProcessors: [parseValue],
      explicitArray: false,
    },
    processData('com.grandlyon.enedis.maxpower')
  )
}

Hugo SUBTIL's avatar
Hugo SUBTIL committed
/**
 * If start date exceed the maximum amount of data we can get with one query
Hugo SUBTIL's avatar
Hugo SUBTIL committed
 * get only 36 month. Or 12 month if manual execution
 * On manual execution, set the start date to one year ago.
Hugo SUBTIL's avatar
Hugo SUBTIL committed
 */
Hugo SUBTIL's avatar
Hugo SUBTIL committed
function limitStartDate() {
  const livingDuration = moment(endDate).diff(startDailyDate, 'months', true)
  // We need to prevent case that there is less than 12 month data
  if (manualExecution && livingDuration > 12) {
    startDailyDate = moment(endDate).subtract(12, 'month')
    startDailyDateString = startDailyDate.format('YYYY-MM-DD')
  } else if (livingDuration > 36) {
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    log(
      'info',
      'Start date exceed 36 month, setting start date to current date minus 36 month'
    )
    startDailyDate = moment(endDate).subtract(36, 'month')
    startDailyDateString = startDailyDate.format('YYYY-MM-DD')
  }
}

/**
 * Get half-hour data
 * @param {string} url
 * @param {string} apiAuthKey
 * @param {string} userLogin
 * @param {number} pointId
 */
async function getDataHalfHour(url, apiAuthKey, userLogin, pointId) {
  log('info', 'Fetching data')
  const sgeHeaders = {
    '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

  for (var i = 0; i < MAX_HISTO; i++) {
    log('info', 'launch process with history')
Bastien DUMONT's avatar
Bastien DUMONT committed
    const incrementedStartDateString = moment(startLoadDate)
      .subtract(7 * i, 'day')
      .format('YYYY-MM-DD')
    const incrementedEndDateString = moment(endDate)
      .subtract(7 * i, 'day')
      .format('YYYY-MM-DD')
    const { response } = await soapRequest({
      url: url,
      headers: sgeHeaders,
Bastien DUMONT's avatar
Bastien DUMONT committed
      xml: consultationMesuresDetaillees(
        pointId,
        userLogin,
Bastien DUMONT's avatar
Bastien DUMONT committed
        incrementedStartDateString,
        incrementedEndDateString,
        'COURBE',
        'PA'
      ),
    }).catch(err => {
Bastien DUMONT's avatar
Bastien DUMONT committed
      log('error', 'consultationMesuresDetaillees half-hour')
      log('error', err)
Bastien DUMONT's avatar
Bastien DUMONT committed
      Sentry.captureException('consultationMesuresDetaillees half-hour')
      return err
    })

    xml2js.parseString(
      response.body,
      {
        tagNameProcessors: [parseTags],
        valueProcessors: [parseValueHalfHour],
        explicitArray: false,
      },
      processData('com.grandlyon.enedis.minute')
    )
  }
}

/**
 * Parse data
 * @param {string} doctype
 * @returns
function processData(doctype = 'com.grandlyon.enedis.day') {
  return async (err, result) => {
    if (err) {
      log('error', err)
Bastien DUMONT's avatar
Bastien DUMONT committed
      Sentry.captureException('error while processing daily data')
      throw err
    }
    // Return only needed part of info
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    try {
      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')
      if (doctype === 'com.grandlyon.enedis.day') {
        log('info', 'Aggregating...')
Bastien DUMONT's avatar
Bastien DUMONT committed
        await aggregateMonthAndYearData(processedDailyData)
      }
    } catch (e) {
      if (doctype === 'com.grandlyon.enedis.minute') {
Bastien DUMONT's avatar
Bastien DUMONT committed
        const errorMessage = `No half-hour activated. Issue: ${result.Envelope.Body.Fault.faultstring}`
        Sentry.captureMessage(errorMessage)
        log('warn', errorMessage)
Bastien DUMONT's avatar
Bastien DUMONT committed
        log('warn', `Unknown error ${e}`)
Hugo SUBTIL's avatar
Hugo SUBTIL committed
    }
/**
 * Save data in the right doctype db and prevent duplicated keys
 * @param {EnedisKonnectorData[]} data
 * @param {string} doctype
 * @param {string[]} filterKeys
Hugo SUBTIL's avatar
Hugo SUBTIL committed
 * @returns {Promise<*>}
 */
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
Bastien DUMONT's avatar
Bastien DUMONT committed
 * Aggregate data from daily data to monthly and yearly data
Bastien DUMONT's avatar
Bastien DUMONT committed
async function aggregateMonthAndYearData(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)
    })
Bastien DUMONT's avatar
Bastien DUMONT committed
    // Aggregation for Month data
    const aggregatedMonthData = await buildAggregatedData(
      monthData,
      'com.grandlyon.enedis.month'
    )
Bastien DUMONT's avatar
Bastien DUMONT committed
    await storeData(aggregatedMonthData, 'com.grandlyon.enedis.month', [
      'year',
      'month',
    ])
Bastien DUMONT's avatar
Bastien DUMONT committed
    // Aggregation for Year data
    const aggregatedYearData = await buildAggregatedData(
      yearData,
      'com.grandlyon.enedis.year'
    )
Bastien DUMONT's avatar
Bastien DUMONT committed
    await storeData(aggregatedYearData, 'com.grandlyon.enedis.year', ['year'])

/**
 * @returns {boolean}
 */
Bastien DUMONT's avatar
Bastien DUMONT committed
  if (account && account.data && account.data.consentId) {
    log('info', 'Konnector not first start')
    return false
  }
  log('info', 'Konnector first start')
  return true

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