/**
* 任务模块。这里任务的接口分为两类,一类是通用接口,一类是特殊接口。
* @module core/task
*/
const ajv = new (require('ajv'))();
const {errorsEnum, coreOkay, coreValidate, coreThrow, coreAssert} = require('./errors');
const {makeThumbnail} = require('./utils');
const idRegex = /^[a-f\d]{24}$/i;
const querySchema = ajv.compile({
type: 'object',
properties: {
populate: {type: 'string', enum: ['false', 'true']}
},
additionalProperties: false
});
const dataSchema = ajv.compile({
type: 'object',
properties: {
data: {type: 'string', enum: ['false', 'true']}
},
additionalProperties: false
});
const createTaskSchema = ajv.compile({
type: 'object',
required: ['name', 'description', 'excerption'],
properties: {
name: {type: 'string', minLength: 1},
description: {type: 'string', minLength: 1},
excerption: {type: 'string', minLength: 1, maxLength: 140},
deadline: {type: 'string', format: 'date-time'},
type: {type: 'string', pattern: '^[-_a-zA-Z\\d]+$'},
tags: {
type: 'array',
items: {
type: 'string',
minLength: 1
},
uniqueItems: true,
maxItems: 5
}
},
additionalProperties: false
});
/**
* 创建任务。需要具有`PUBLISHER`权限`
* - ajax: POST /api/task/:id
* - socket.io: emit task:create
* @param params {object}
* - auth {object} 权限
* - query {object} 请求query
* - populate {boolean} 可选,默认false,返回task id
* - files {object} 上传的图片,额外的数据请通过`PATCH`上传
* - picture {object} 上传的图片
* - data {object} 访问的数据
* - name {string} 必须,任务标题
* - description {string} 必须,任务描述,Markdown
* - excerption {string} 必须,任务摘要,无Markdown,最长只能有140字
* - tags {string[]} 可选,标签,最多只有5个
* - type {string} 任务类型,如果创建的时候指定了就不能够再次更改
* - deadline {string} 可选,失效日期
* @param global {object}
* - tasks {object} Tasks model
* @return {Promise.<object>} 如果不`populate`,`data`为任务的`_id`,否则为整个任务字段。
*/
async function createTask(params, global) {
const {tasks, users, config, taskTemplates} = global;
coreAssert(params.auth && (params.auth.role & users.roleEnum.PUBLISHER),
errorsEnum.PERMISSION, 'Requires publisher privilege');
coreValidate(querySchema, params.query);
coreValidate(createTaskSchema, params.data);
coreAssert(params.data.type === undefined ||
(taskTemplates[params.data.type] !== undefined &&
taskTemplates[params.data.type].meta.enabled), errorsEnum.INVALID, {
message: 'Invalid type',
data: Object.keys(taskTemplates)
});
if (params.data.deadline !== undefined)
params.data.deadline = new Date(params.data.deadline);
const task = await new tasks(
Object.assign({
valid: false,
status: tasks.statusEnum.EDITING,
publisher: params.auth.uid
}, params.data)
);
if (params.file) {
const thumbnail = await makeThumbnail(params.file.path, {
size: [487, 365],
destination: config['upload-dir']
});
params._files.push(thumbnail.path);
task.picture = params.file.filename;
task.pictureThumbnail = thumbnail.filename;
}
await task.save();
return coreOkay({
data: params.query.populate === 'true' ? task.toPlainObject(params.auth) : task._id
});
}
/**
* 获取任务详情。如果任务处于未发布状态,只有任务本身的发布者或者任务管理员可以有权限获取。
* - ajax: GET /api/task/:id
* - socket.io: emit task:get
* @param params {object}
* - auth {object} 权限
* - id {string} 要获取详情的任务的id
* @param global {object}
* - tasks {object} Tasks model
* @return {Promise<object>}
*/
async function getTask(params, global) {
const {tasks, users} = global;
coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
const task = await tasks.findById(params.id).notDeleted();
coreAssert(task, errorsEnum.EXIST, 'Task does not exist');
coreAssert(
task.status === tasks.statusEnum.PUBLISHED ||
(params.auth && (task.publisher.equals(params.auth.uid) ||
params.auth.role & users.roleEnum.TASK_ADMIN)),
errorsEnum.PERMISSION, 'Permission denied'
);
return coreOkay({
data: task.toPlainObject(params.auth)
});
}
const findTaskSchema = ajv.compile({
type: 'object',
properties: {
populate: {type: 'string', enum: ['false', 'true']},
count: {type: 'string', enum: ['false', 'true']},
filter: {
type: 'object',
properties: {
search: {type: 'string'},
name: {type: 'string', minLength: 1},
publisher: {type: 'string', pattern: '[a-fA-F\\d]{24}'},
tag: {type: 'string', minLength: 1},
type: {type: 'string', pattern: '^[-_a-zA-Z\\d]+$'},
valid: {type: 'string', enum: ['true', 'false']},
deadline: {
type: 'object',
properties: {
from: {type: 'string', format: 'date-time'},
to: {type: 'string', format: 'date-time'}
},
additionalProperties: false
},
status: {type: 'string', enum: ['EDITING', 'SUBMITTED', 'ADMITTED', 'PUBLISHED']},
completed: {type: 'string', enum: ['true', 'false']}
},
additionalProperties: false
},
limit: {type: 'string', pattern: '^\\d+$'},
lastId: {type: 'string', pattern: '[a-fA-F\\d]{24}'}
},
additionalProperties: false
});
/**
* 搜索任务,通过以下两种方式暴露:
* - ajax:GET /api/task
* - socket.io: emit task:find
* @param params 请求数据
* - auth {object} 权限
* - query {object} 请求的query
* - populate {boolean} 是否展开数据
* - count {boolean} 统计总数,需要额外的开销
* - filter {Object.<string, string>}
* - search {string} 全文检索
* - name {string}
* - publisher {string} 对于发布者,这个值只能是自己。必须拥有TASK_ADMIN权限
* 才能设置别的值,考虑对于同时有两权限的用户的请求一致性,建议以发布者身份搜索时永远设置这个值,
* 以普通用户身份搜索时,请不要附带权限信息。
* - tag {string} 包含某个标签
* - type {string}
* - deadline {{from:string, to:string}} 某个时间范围,无deadline等价于deadline无穷
* - status 对于普通用户,这个值只能是`PUBLISHED`,而发布者和任务管理员可以设置别的值,
* 同样考虑多种权限的用户的请求一致性,建议以普通用户搜索时永远设置这个值。
* - valid {boolean}
* - completed {boolean} 对于没有进度概念的,等价于永远未完成
* - limit {number} 可选,小于等于50大于0数字,默认为10
* - lastId {string} 可选,请求的上一个Id
* @param global
* @return {Promise<object>}
*/
async function findTask(params, global) {
const {tasks, users} = global;
coreValidate(findTaskSchema, params.query);
let role; // 0 for subscriber, 1 for publisher, 2 for task admin
if (!params.auth)
role = 0;
else if (params.auth.role & users.roleEnum.TASK_ADMIN)
role = 2;
else if (params.auth.role & users.roleEnum.PUBLISHER)
role = 1;
else
coreThrow(errorsEnum.PERMISSION, 'Permission denied');
let limit;
if (params.query.limit !== undefined) {
limit = parseInt(params.query.limit);
coreAssert(limit > 0 && limit < 50, errorsEnum.SCHEMA, 'Invalid limit');
} else
limit = 10;
params.query.filter = params.query.filter || {};
const and = params.query.filter.$and = [];
if (params.query.filter.search !== undefined) {
const search = params.query.filter.search
.split(/\s+/).filter(x => x)
.map(x => new RegExp(x.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'), 'i'));
delete params.query.filter.search;
if (search.length !== 0) {
const or = [];
search.forEach(x => {
or.push({name: {$regex: x}});
or.push({description: {$regex: x}});
or.push({excerption: {$regex: x}});
});
and.push({$or: or});
}
}
if (params.query.valid !== undefined)
params.query.valid = params.query.valid === 'true';
if (params.query.filter.tag !== undefined) {
params.query.filter.tags = params.query.filter.tag;
delete params.query.filter.tag;
}
if (params.query.filter.deadline !== undefined) {
const from = params.query.filter.deadline.from;
const to = params.query.filter.deadline.to;
delete params.query.filter.deadline;
if (from !== undefined && to !== undefined)
params.query.filter.deadline = {$gte: new Date(from), $lte: new Date(to)};
else if (to !== undefined)
params.query.filter.deadline = {$lte: new Date(to)};
else if (from !== undefined)
and.push({
$or: [
{deadline: {$exists: false}},
{deadline: {$gte: new Date(from)}}
]
});
}
if (params.query.filter.status !== undefined)
params.query.filter.status = tasks.statusEnum[params.query.filter.status];
if (params.query.filter.completed !== undefined) {
const completed = params.query.filter.completed === 'true';
delete params.query.filter.completed;
if (completed)
params.query.filter.remain = {$lte: 0};
else
and.push({
$or: [
{remain: {$exists: false}},
{remain: {$gt: 0}}
]
});
}
if (role === 1) {
if (params.query.filter.publisher !== undefined)
coreAssert(params.query.filter.publisher === params.auth.uid,
errorsEnum.SCHEMA, 'Invalid publisher');
else
params.query.filter.publisher = params.auth.uid;
} else if (role === 0) {
if (params.query.filter.status !== undefined)
coreAssert(params.query.filter.status === tasks.statusEnum.PUBLISHED,
errorsEnum.SCHEMA, 'Invalid status');
else
params.query.filter.status = tasks.statusEnum.PUBLISHED;
}
if (params.query.filter.$and.length === 0)
delete params.query.filter.$and;
const result = {};
if (params.query.lastId !== undefined) {
params.query.filter._id = {$lt: params.query.lastId};
result.lastId = params.query.lastId;
}
if (params.query.populate === 'true') {
result.data = (await tasks.find(params.query.filter)
.notDeleted()
.sort({_id: -1})
.limit(limit)).map(x => x.toPlainObject(params.auth));
if (result.data.length !== 0)
result.lastId = result.data[result.data.length - 1]._id;
} else {
result.data = (await tasks.find(params.query.filter)
.notDeleted()
.sort({_id: -1})
.select({_id: 1})
.limit(limit)).map(x => x._id);
if (result.data.length !== 0)
result.lastId = result.data[result.data.length - 1];
}
if (params.query.count === 'true') {
delete params.query.filter._id;
result.total = await tasks.count(params.query.filter).notDeleted();
}
return coreOkay({data: result});
}
const patchTaskSchema = ajv.compile({
type: 'object',
properties: {
name: {type: 'string', minLength: 1},
description: {type: 'string', minLength: 1},
excerption: {type: 'string', minLength: 1, maxLength: 140},
deadline: {type: ['string', 'null'], format: 'date-time'},
type: {type: 'string', pattern: '^[-_a-zA-Z\\d]+$'},
tags: {
type: 'array',
items: {
type: 'string',
minLength: 1
},
uniqueItems: true,
maxItems: 5
},
status: {type: 'string', enum: ['EDITING', 'SUBMITTED', 'ADMITTED', 'PUBLISHED']}
},
additionalProperties: false
});
/**
* 修改任务详情。任务的拥有者能修改`EDITING`时的任务信息,type只能从无到有,状态可由`EDITING`
* 转换为`SUBMITTED`,同时该任务必须是valid的,也可在`ADMITTED`的更改任务状态为`EDITING`
* 或者`PUBLISHED`。任务管理员可以将任务的状态由`SUBMITTED`改成`EDITING`或者`ADMITTED`。
* - ajax: PATCH /api/task/:id
* - socket.io: emit task:patch
* @param params {object}
* - id {string} 要修改的任务的id
* - query {object}
* - populate {boolean} 可选,默认false,返回task id
* - data {object} 修改的数据,必须是该任务publisher或TASK_ADMIN才能修改
* - name {string} 必须,任务标题
* - description {string} 任务描述,Markdown
* - excerption {string} 任务摘要,无Markdown,最长只能有140字
* - tags {string[]} 标签,最多只有5个
* - type {string} 任务类型,如果创建的时候指定了就不能够再次更改
* - deadline {string|null} 可选,失效日期
* - status {string} 任务状态,`EDITING`,`SUBMITTED`,`ADMITTED`和`PUBLISHED`
* @param global {object}
* - tasks {object} Tasks model
* @return {Promise.<object>}
*/
async function patchTask(params, global) {
const {tasks, users, taskTemplates} = global;
coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
coreValidate(querySchema, params.query);
coreValidate(patchTaskSchema, params.data);
coreAssert(params.auth && (params.auth.role & (users.roleEnum.TASK_ADMIN |
users.roleEnum.PUBLISHER)), errorsEnum.PERMISSION, 'Permission denied');
const task = await tasks.findById(params.id).notDeleted();
coreAssert(task, errorsEnum.EXIST, 'Task does not exist');
const isPublisher = task.publisher.equals(params.auth.uid);
const isTaskAdmin = !!(params.auth.role & users.roleEnum.TASK_ADMIN);
coreAssert(isPublisher || isTaskAdmin, errorsEnum.PERMISSION, 'Permission denied');
const isUpdateInfo = params.data.name !== undefined || params.data.description !== undefined ||
params.data.excerption !== undefined || params.data.deadline !== undefined ||
params.data.type !== undefined || params.data.tags !== undefined;
coreAssert(!isUpdateInfo || isPublisher,
errorsEnum.PERMISSION, 'Requires self privilege');
coreAssert(!isUpdateInfo || task.status === tasks.statusEnum.EDITING,
errorsEnum.INVALID, 'Task is not at EDITING status');
if (params.data.type !== undefined) {
coreAssert(task.type === undefined, errorsEnum.INVALID, 'Task already has a type');
coreAssert(taskTemplates[params.data.type] !== undefined &&
taskTemplates[params.data.type].meta.enabled, errorsEnum.INVALID, {
message: 'Invalid type',
data: Object.keys(taskTemplates)
});
}
if (params.data.deadline !== undefined) {
if (params.data.deadline === null)
params.data.deadline = undefined;
else
params.data.deadline = new Date(params.data.deadline);
}
if (params.data.status !== undefined) {
params.data.status = tasks.statusEnum[params.data.status];
if (params.data.status === task.status)
delete params.data.status;
}
coreAssert(params.data.status !== tasks.statusEnum.EDITING ||
(isPublisher && task.status === tasks.statusEnum.ADMITTED) ||
(isTaskAdmin && task.status === tasks.statusEnum.SUBMITTED),
// eslint-disable-next-line indent
errorsEnum.INVALID, 'Invalid status');
coreAssert(params.data.status !== tasks.statusEnum.SUBMITTED ||
(isPublisher && task.status === tasks.statusEnum.EDITING && task.valid),
// eslint-disable-next-line indent
errorsEnum.INVALID, 'Invalid status');
coreAssert(params.data.status !== tasks.statusEnum.ADMITTED ||
(isTaskAdmin && task.status === tasks.statusEnum.SUBMITTED),
// eslint-disable-next-line indent
errorsEnum.INVALID, 'Invalid status');
coreAssert(params.data.status !== tasks.statusEnum.PUBLISHED ||
(isPublisher && task.status === tasks.statusEnum.ADMITTED),
// eslint-disable-next-line indent
errorsEnum.INVALID, 'Invalid status');
Object.assign(task, params.data);
await task.save();
return coreOkay({
data: params.query.populate === 'true' ? task.toPlainObject(params.auth) : task._id
});
}
/**
* 删除任务,必须为publisher或者拥有`TASK_ADMIN`权限
* @param params
* @param global
* @return {Promise<void>}
*/
async function deleteTask(params, global) {
const {tasks, users} = global;
coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
coreAssert(params.auth && (params.auth.role & (users.roleEnum.TASK_ADMIN |
users.roleEnum.PUBLISHER)), errorsEnum.PERMISSION, 'Permission denied');
const task = await tasks.findById(params.id).notDeleted();
coreAssert(task, errorsEnum.EXIST, 'Task does not exist');
const isPublisher = task.publisher.equals(params.auth.uid);
const isTaskAdmin = !!(params.auth.role & users.roleEnum.TASK_ADMIN);
coreAssert(isPublisher || isTaskAdmin, errorsEnum.PERMISSION, 'Permission denied');
await task.delete();
return coreOkay();
}
/**
* 上传任务数据,提交者必须为任务的发布者,且任务处于`EDITING`状态
* @param ctx {object} koa的context
* - params {object} 请求的数据
* - query {object} 请求的query
* - data {boolean} 是否返回数据,默认false
* @return {Promise<void>}
*/
async function postTaskData(ctx) {
const {params, global} = ctx;
const {tasks, taskTemplates} = global;
coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
coreValidate(dataSchema, params.query);
const task = await tasks.findById(params.id).notDeleted().select('+data');
coreAssert(task, errorsEnum.EXIST, 'Task does not exist');
coreAssert(task.type !== undefined && taskTemplates[task.type] !== undefined &&
taskTemplates[task.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
coreAssert(params.auth && task.publisher.equals(params.auth.uid),
errorsEnum.PERMISSION, 'Requires publisher privilege');
coreAssert(task.status === tasks.statusEnum.EDITING,
errorsEnum.INVALID, 'Task is not at EDITING status');
const taskType = taskTemplates[task.type];
let data;
const next = async () => {
if (typeof taskType.postTaskData === 'function')
data = await taskType.postTaskData(task, params, global);
};
if (typeof taskType.postTaskDataMiddleware === 'function')
await taskType.postTaskDataMiddleware(ctx, next);
else
await next();
if (data !== undefined)
ctx.body = data;
else if (params.query.data === 'true')
ctx.body = coreOkay({
data: (typeof taskType.taskDataToPlainObject === 'function' &&
taskType.taskDataToPlainObject(task, params.auth)) || {}
});
else
ctx.body = coreOkay();
}
/**
* 获取任务数据,可以是任务的发布者,或订阅者且任务处于`PUBLISHED`状态
* @param ctx {object} koa的context
* @return {Promise<void>}
*/
async function getTaskData(ctx) {
const {params, global} = ctx;
const {users, tasks, taskTemplates} = global;
coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
const task = await tasks.findById(params.id).notDeleted().select('+data');
coreAssert(task, errorsEnum.EXIST, 'Task does not exist');
coreAssert(task.type !== undefined && taskTemplates[task.type] !== undefined &&
taskTemplates[task.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
coreAssert(params.auth && (task.publisher.equals(params.auth.uid) ||
((params.auth.role & users.roleEnum.SUBSCRIBER) &&
task.status === tasks.statusEnum.PUBLISHED)),
// eslint-disable-next-line
errorsEnum.PERMISSION, 'Permission denied');
const taskType = taskTemplates[task.type];
let data;
if (typeof taskType.getTaskData === 'function')
data = await taskType.getTaskData(task, params, global);
if (data !== undefined)
ctx.body = data;
else
ctx.body = coreOkay({
data: (typeof taskType.taskDataToPlainObject === 'function' &&
taskType.taskDataToPlainObject(task, params.auth)) || {}
});
}
module.exports = {
createTask,
getTask,
findTask,
patchTask,
deleteTask,
postTaskData,
getTaskData
};