dataplug-io/toggl-dataplug

View on GitHub
lib/collections/detailedReports.js

Summary

Maintainability
A
3 hrs
Test Coverage
const _ = require('lodash')
const moment = require('moment')
const { URL } = require('url')
const dataplug = require('@dataplug/dataplug')
const { PagedHttpGetReader } = require('@dataplug/dataplug-http')
const { JsonStreamReader } = require('@dataplug/dataplug-json')
const config = require('../config')

const schema = {
  type: 'object',
  definitions: {},
  properties: {
    id: {
      description: 'Time entry ID',
      type: 'integer'
    },
    pid: {
      description: 'Project ID',
      type: ['integer', 'null']
    },
    project: {
      description: 'Project name for which the time entry was recorded',
      type: ['string', 'null']
    },
    project_color: {
      description: '(Probably) Project color ID',
      type: ['string', 'null']
    },
    project_hex_color: {
      description: '(Probably) Project color (hex)',
      type: ['string', 'null']
    },
    client: {
      description: 'Client name for which the time entry was recorded',
      type: ['string', 'null']
    },
    tid: {
      description: 'Task ID',
      type: ['integer', 'null']
    },
    task: {
      description: 'Task name for which the time entry was recorded',
      type: ['string', 'null']
    },
    uid: {
      description: 'User ID whose time entry it is',
      type: 'integer'
    },
    user: {
      description: 'Full name of the user whose time entry it is',
      type: 'string'
    },
    description: {
      description: 'Time entry description',
      type: 'string'
    },
    start: {
      description: 'Start time of the time entry in ISO 8601 date and time format (YYYY-MM-DDTHH:MM:SS)',
      type: 'string',
      format: 'date-time'
    },
    end: {
      description: 'End time of the time entry in ISO 8601 date and time format (YYYY-MM-DDTHH:MM:SS)',
      type: 'string',
      format: 'date-time'
    },
    dur: {
      description: 'Time entry duration in milliseconds',
      type: 'integer'
    },
    updated: {
      description: 'Last time the time entry was updated in ISO 8601 date and time format (YYYY-MM-DDTHH:MM:SS)',
      type: 'string',
      format: 'date-time'
    },
    use_stop: {
      description: 'If the stop time is saved on the time entry, depends on user\'s personal settings.',
      type: 'boolean'
    },
    is_billable: {
      description: 'If the time entry was billable or not',
      type: 'boolean'
    },
    billable: {
      description: 'Billed amount',
      type: 'number'
    },
    cur: {
      description: 'Billable amount currency',
      type: 'string'
    },
    tags: {
      description: 'Array of tag names, which assigned for the time entry',
      type: 'array',
      items: {
        type: 'string'
      }
    }
  },
  additionalProperties: false,
  required: ['id']
}

const configDeclaration = config.declaration.extended((declaration) => declaration
  .parameters({
    workspace: {
      description: 'The workspace ID to query data from',
      type: 'integer',
      required: true
    },
    since: {
      description: 'Date to query data since',
      type: 'string',
      format: 'date',
      default: '2006-01-01'
    },
    until: {
      description: 'Date to query data until',
      type: 'string',
      format: 'date',
      default: moment().format('YYYY-MM-DD')
    },
    billable: {
      description: 'Filter by billable option',
      enum: ['yes', 'no', 'both'],
      default: 'both'
    },
    clientIds: {
      description: 'Filter by set of client IDs, "0" means "w/o client"',
      type: 'array',
      item: 'integer'
    },
    projectIds: {
      description: 'Filter by set of project IDs, "0" means "w/o project"',
      type: 'array',
      item: 'integer'
    },
    userIds: {
      description: 'Filter by set of user IDs',
      type: 'array',
      item: 'integer'
    },
    membersOfGroupIds: {
      description: 'Filter by set users, members of specified group IDs',
      type: 'array',
      item: 'integer'
    },
    // TODO: or_members_of_group_ids: A list of group IDs separated by a comma. This extends provided user_ids with the members of the given groups.
    // TODO: tag_ids: A list of tag IDs separated by a comma. Use "0" if you want to filter out time entries without a tag.
    // TODO: task_ids: A list of task IDs separated by a comma. Use "0" if you want to filter out time entries without a task.
    // TODO: time_entry_ids: A list of time entry IDs separated by a comma.
    matchDescription: {
      description: 'Matches against time entry descriptions',
      type: 'string'
    },
    filterNoDescriptionEntries: {
      description: 'Filters out the time entries which do not have a description',
      type: 'boolean',
      default: false
    },
    orderField: {
      description: 'Field to sort by',
      enum: ['date', 'description', 'duration', 'user'],
      default: 'date'
    },
    orderDescending: {
      description: 'Sort by descending or ascending order',
      enum: ['on', 'off'],
      default: 'on'
    },
    rounding: {
      description: 'Rounds time according to workspace settings',
      enum: ['on', 'off'],
      default: 'off'
    }
  })
)

const queryMapping = config.mapping.query.extended((mapping) => mapping
  .rename('workspace', 'workspace_id')
  .asIs('since')
  .asIs('until')
  .asIs('billable')
  .rename('clientIds', 'client_ids')
  .rename('projectIds', 'project_ids')
  .rename('userIds', 'user_ids')
  .rename('membersOfGroupIds', 'members_of_group_ids')
  .rename('matchDescription', 'description')
  .rename('filterNoDescriptionEntries', 'without_description')
  .rename('orderField', 'order_field')
  .rename('orderDescending', 'order_desc')
  .asIs('rounding')
)

const factory = (params) => {
  const sequence = []

  const globalSince = moment(params.since)
  const globalUntil = moment(params.until)
  if (globalUntil.diff(globalSince) < 0) {
    throw new Error(`Invalid range: since ${params.since} until ${params.until}`)
  }
  if (globalSince.diff(moment('2006-01-01')) < 0) {
    throw new Error(`Invalid range: since ${params.since} can not be before 2006-01-01`)
  }

  const today = moment().set({'hour': 0, 'minute': 0, 'second': 0, 'millisecond': 0})
  let since = globalSince.clone()
  let until = since.clone().add(1, 'years')
  do {
    if (today.diff(until) < 0) {
      until = today
    }

    const url = new URL('/reports/api/v2/details', params.endpoint)
    const nextPage = (page, data) => {
      if (data) {
        const currentPage = page.query.page || 1
        if (currentPage * data.per_page < data.total_count) {
          page.query.page = currentPage + 1
          return true
        }
      }

      return false
    }
    const transformFactory = () => new JsonStreamReader('!.data.*')
    const query = queryMapping.apply(_.assign({}, params, {
      since: since.format('YYYY-MM-DD'),
      until: until.format('YYYY-MM-DD')
    }))
    const headers = config.mapping.headers.apply(params)

    sequence.push(new PagedHttpGetReader(url, nextPage, {
      transformFactory,
      query,
      headers
    }))

    since = since.add(1, 'years')
    until = since.clone().add(1, 'years')
  } while (today.diff(since) > 0)

  return new dataplug.Sequence(sequence, true)
}

const source = dataplug.source(configDeclaration, factory)

module.exports = {
  origin: 'toggl',
  name: 'detailed-reports',
  schema,
  source
}