timeld-cli
Version:
Live shared timesheets command interface
541 lines (503 loc) • 16.2 kB
JavaScript
import { Repl } from '@m-ld/m-ld-cli/lib/Repl.js';
import { ResultsProc } from './ResultsProc.mjs';
import { PromiseProc } from './PromiseProc.mjs';
import { ENTRY_FORMAT_OPTIONS, getSubjectFormat, TableFormat } from './DisplayFormat.mjs';
import isEmail from 'validator/lib/isEmail.js';
import { AccountOwnedId, dateJsonLd, durationFromInterval } from 'timeld-common';
import { EMPTY } from 'rxjs';
import { any } from '@m-ld/m-ld';
import { parseDate, parseDuration } from './util.mjs';
import { SyncProc } from '@m-ld/m-ld-cli/lib/Proc.js';
import { Readable } from 'stream';
/**
* @typedef {object} DetailArgs
* @property {string} detail
* @property {string} value
* @property {string} [project]
* @property {string} [timesheet]
*/
export default class AdminSession extends Repl {
/**
* @param {GatewayClient} gateway
* @param {string} account
* @param {string|number} [logLevel]
*/
constructor({ gateway, account, logLevel }) {
// The only user account we are an admin of, is our own
super({ prompt: `${account}>`, logLevel });
this.gateway = gateway;
this.account = account;
this.isUserAccount = account === gateway.user;
// TODO: Warn if the user is not an admin of the account
}
get detailParamChoices() {
return ['ts', 'timesheet', 'project', 'link', 'connector']
.concat(this.isUserAccount ?
['email', 'org', 'organisation'] :
// Technically you could administer emails and organisations from an org
// account session by editing the logged-in user, but omit for clarity
['admin', 'administrator']);
}
get describeValueParam() {
return this.isUserAccount ?
'timesheet or project name, email address, organisation name, or connector module' :
'timesheet or project name, admin user name, or connector module';
}
buildCommands(yargs, ctx) {
// noinspection JSCheckFunctionSignatures
return yargs
.option('project', {
type: 'string',
describe: 'Project (use with "link" or "report")',
conflicts: 'timesheet'
})
.option('timesheet', {
alias: 'ts',
type: 'string',
describe: 'Timesheet (use with "link" or "report")',
conflicts: 'project'
})
.check(argv => {
if (argv.detail === 'link' && !argv.project && !argv.timesheet)
return 'Link requires a "--project" or "--timesheet"';
return true;
})
.command(
'key',
'Show your API access key',
yargs => yargs,
() => ctx.exec(() =>
new SyncProc(Readable.from([this.gateway.authKey.toString()])))
)
.command(
['list <detail>', 'ls'],
'List details of this account',
yargs => yargs
.positional('detail', {
describe: 'Details to list',
choices: this.detailParamChoices
}),
argv => ctx.exec(() =>
this.getDetailHandler(argv).list())
)
.command(
['add <detail> <value>', 'a', '+'],
'Add details to this account',
yargs => yargs
.positional('detail', {
describe: 'Details to add',
choices: this.detailParamChoices
})
.positional('value', {
type: 'string',
describe: this.describeValueParam
})
.option('start', {
describe: 'The project start date/time',
type: 'array',
coerce: parseDate
})
.option('end', {
describe: 'The project end date & time',
type: 'array',
coerce: parseDate
})
.option('duration', {
describe: 'The project duration, e.g. 1w',
type: 'string',
coerce: parseDuration
})
.option('milestone', {
describe: 'Project milestones',
type: 'array'
}),
argv => ctx.exec(() =>
this.getDetailHandler(argv).add())
)
.command(
['remove <detail> <value>', 'rm'],
'Remove details from this account',
yargs => yargs
.positional('detail', {
describe: 'Details to remove',
choices: this.detailParamChoices
})
.positional('value', {
type: 'string',
describe: this.describeValueParam
})
.option('project', {
type: 'string',
describe: 'Project to unlink from timesheet (use with "link")'
}),
argv => ctx.exec(() =>
this.getDetailHandler(argv).remove())
)
.command(
'report <id>',
'Report on the time entries in a timesheet or project',
yargs => yargs
.option('format', ENTRY_FORMAT_OPTIONS),
argv => ctx.exec(
() => this.reportEntriesProc(argv))
);
}
/**
* @param {DetailArgs} argv
* @returns {AccountDetail}
*/
getDetailHandler(argv) {
switch (argv.detail) {
case 'email':
return this.emailDetail(argv);
case 'org':
case 'organisation':
return this.orgDetail(argv);
case 'admin':
case 'administrator':
return this.adminDetail(argv);
case 'ts':
case 'timesheet':
return this.ownedDetail(argv, 'Timesheet');
case 'project':
return this.ownedDetail(argv, 'Project');
case 'link':
return this.linkDetail(argv);
case 'connector':
return this.connectorDetail(argv);
default:
throw `${argv.detail} not available`;
}
}
writeProc(pattern) {
return new PromiseProc(this.gateway.write(pattern));
}
listProc(pattern, format) {
return new ResultsProc(this.gateway.read(pattern), format);
}
/**
* @param {EntryFormatName} format
* @param {string} id timesheet or project to report on
* @returns {Proc}
*/
reportEntriesProc({ id, format }) {
const ownedId = this.resolveId(id);
return new ResultsProc(
this.gateway.report(ownedId.account, ownedId.name),
getSubjectFormat(format));
}
resolveId(owned) {
if (owned) {
// Projects share a namespace with timesheets
let id = AccountOwnedId.fromString(owned);
if (id.gateway && id.gateway !== this.gateway.domainName)
throw `Cannot write to ${id.gateway}`;
id.account ||= this.account;
return id;
}
}
userIsAdmin(accountId = this.account) {
const isAdmin = {
'@id': accountId,
'@type': 'Account'
};
if (accountId !== this.gateway.user)
isAdmin['vf:primaryAccountable'] = { '@id': this.gateway.user };
return isAdmin;
}
/**
* @param {string} email
* @returns {AccountDetail}
*/
emailDetail({ value: email }) {
return new class extends AccountDetail {
list() {
return this.session.listProc({
'@select': '?email', '@where': {
...this.session.userIsAdmin(), email: '?email'
}
}, new TableFormat('?email'));
}
add() {
if (!isEmail(email)) // Check validity for inserts
throw `${email} is not a valid email address`;
return this.update('@insert');
}
update(verb) {
return this.session.writeProc({
[verb]: { '@id': this.session.account, email },
'@where': this.session.userIsAdmin()
});
}
}(this);
}
/**
* @param {string} org
* @returns {AccountDetail}
*/
orgDetail({ value: org }) {
return new class extends AccountDetail {
list() {
return this.session.listProc({
'@select': '?org', '@where': this.session.userIsAdmin('?org')
}, new TableFormat('?org'));
}
add() {
if (!AccountOwnedId.isComponentId(org))
throw `${org} is not a valid organisation ID`;
// I am the admin of any org I create
return this.session.writeProc(this.session.userIsAdmin(org));
}
remove() {
return this.session.writeProc({
'@delete': { '@id': org },
'@where': this.session.userIsAdmin(org)
});
}
}(this);
}
/**
* @param {string} admin
* @returns {AccountDetail}
*/
adminDetail({ value: admin }) {
return new class extends AccountDetail {
list() {
const where = this.session.userIsAdmin();
where['vf:primaryAccountable'] = [where['vf:primaryAccountable'], '?admin'];
return this.session.listProc({
'@select': '?admin', '@where': where
}, new TableFormat('?admin'));
}
update(verb) {
if (!AccountOwnedId.isComponentId(admin))
throw `${admin} is not a valid user name`;
return this.session.writeProc({
[verb]: {
'@id': this.session.account,
'vf:primaryAccountable': { '@id': admin }
},
'@where': this.session.userIsAdmin()
});
}
}(this);
}
/**
* @param {string} owned
* @param {Date} [start]
* @param {Date} [end]
* @param {number} [duration]
* @param {string[]} [milestone]
* @param {'Timesheet'|'Project'} type
* @returns {AccountDetail}
*/
ownedDetail({
value: owned,
start, end, duration,
milestone
}, type) {
return new class extends AccountDetail {
list() {
return this.session.listProc({
'@select': '?owned', '@where': {
...this.session.userIsAdmin(), [type.toLowerCase()]: '?owned'
}
}, new TableFormat('?owned'));
}
add() {
return this.updateOwned('@insert', subject => {
if (type === 'Project') {
// Fill out project properties
if (start != null) {
subject.start = dateJsonLd(start);
if (end != null && duration == null)
duration = durationFromInterval(start, end);
if (duration != null)
subject.duration = duration;
} else if (duration != null) {
throw 'Please specify a start date';
}
subject.milestone = milestone?.map(m => `${m}`);
}
});
}
remove() {
return this.updateOwned('@delete', (subject, where) => {
// Delete must delete the owned subject in full
const allProps = { [any()]: any() };
Object.assign(subject, allProps);
where[type.toLowerCase()] = { '@id': subject['@id'], ...allProps };
});
}
/**
* @param {'@insert'|'@delete'} verb update key
* @param {(subject: object, where: object) => void} complete
* @returns {PromiseProc}
*/
updateOwned(verb, complete) {
const ownedId = this.session.resolveId(owned);
const subject = { '@id': ownedId.toIri(), '@type': type };
const where = this.session.userIsAdmin(ownedId.account);
complete(subject, where);
return this.session.writeProc({
[verb]: { '@id': ownedId.account, [type.toLowerCase()]: subject },
'@where': where
});
}
}(this);
}
/**
* @param {object} argv
* @param {string} [argv.project] command option, exclusive with timesheet
* @param {string} [argv.timesheet] command option, exclusive with project
* @param {string} [argv.value] the other id, one of project or timesheet
* @returns {AccountDetail}
*/
linkDetail(argv) {
return new class extends AccountDetail {
project = this.session.resolveId(argv.project || argv.value);
timesheet = this.session.resolveId(argv.timesheet || argv.value);
list() {
if (this.project) {
return this.session.listProc({
'@select': '?timesheet',
'@where': [{
// I can see timesheets for projects that I administer
...this.session.userIsAdmin(this.project.account),
project: this.project.toReference()
}, {
'@id': '?timesheet',
'@type': 'Timesheet',
project: this.project.toReference()
}]
}, new TableFormat('?timesheet'));
} else if (this.timesheet) {
return this.session.listProc({
'@select': '?project',
'@where': [{
// I can see projects for timesheets that I administer
...this.session.userIsAdmin(this.timesheet.account),
timesheet: this.timesheet.toReference()
}, {
...this.timesheet.toReference(),
'@type': 'Timesheet',
project: { '@id': '?project' }
}]
}, new TableFormat('?project'));
}
throw new RangeError('Must provide timesheet or project');
}
update(verb) {
// The given id is a timesheet or a project
if (!this.project || !this.timesheet)
throw new RangeError('Timesheet or project missing');
return this.session.writeProc({
[verb]: {
...this.timesheet.toReference(),
project: this.project.toReference()
},
'@where': {
// The account must own the timesheet (not necessarily the project)
...this.session.userIsAdmin(this.timesheet.account),
timesheet: this.timesheet.toReference()
}
});
}
}(this);
}
/**
* @param {object} argv
* @param {string} [argv.project] command option
* @param {string} [argv.timesheet] command option
* @param {string} [argv.value] the connector module
* @param {object} [argv.config] any module configuration
* @returns {AccountDetail}
*/
connectorDetail(argv) {
if ((argv.project != null) === (argv.timesheet != null))
throw new RangeError('Must provide timesheet or project');
if ('config' in argv)
if (typeof argv.config != 'object' || argv.config == null)
throw new RangeError('config must have properties');
return new class extends AccountDetail {
projectId = this.session.resolveId(argv.project);
timesheetId = this.session.resolveId(argv.timesheet);
get ownedRef() {
return (this.projectId || this.timesheetId).toReference();
}
get ownedIsOwned() {
return {
...this.session.userIsAdmin(),
[this.projectId != null ? 'project' : 'timesheet']: this.ownedRef
};
}
list() {
return this.session.listProc({
'@select': '?module', '@where': [
{
'@type': 'Connector',
module: '?module',
appliesTo: this.ownedRef
},
this.ownedIsOwned
]
}, new TableFormat('?module'));
}
add() {
const module = argv.value;
return this.session.writeProc({
'@insert': {
'@type': 'Connector',
module,
appliesTo: this.ownedRef,
config: JSON.stringify(argv.config)
},
'@where': this.ownedIsOwned
});
}
remove() {
const module = argv.value;
return this.session.writeProc({
// TODO: Leaves garbage if this is the last appliesTo
'@delete': { '@id': '?id', appliesTo: this.ownedRef },
'@where': [{ '@id': '?id', '@type': 'Connector', module }, this.ownedIsOwned]
});
}
}(this);
}
}
class AccountDetail {
/**@type {AdminSession}*/session;
/** @param {AdminSession} session */
constructor(session) {
this.session = session;
}
/**
* @abstract
* @returns {ResultsProc}
*/
list() {
return EMPTY;
}
/**
* Called by {@link add} and {@link remove} for convenience, if the only
* difference between the two is `verb`.
* @param {'@insert'|'@delete'} verb update key
*/
update(verb) {
return new PromiseProc(Promise.reject(
`Cannot ${verb === '@insert' ? 'add' : 'remove'} detail`));
}
/**
* @returns {PromiseProc}
*/
add() {
return this.update('@insert');
}
/**
* @returns {PromiseProc}
*/
remove() {
return this.update('@delete');
}
}