lib/collections/detailedReports.js
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
}