src/core/assignment.js

/**
 * 作业模块。这里作业的接口分为两类,一类是通用接口,一类是特殊接口。
 * @module core/assignment
 */

const ajv = new (require('ajv'))();
const {errorsEnum, coreOkay, coreValidate, coreAssert} = require('./errors');

const idRegex = /^[a-f\d]{24}$/i;

const querySchema = ajv.compile({
  type: 'object',
  properties: {
    populate: {type: 'string', enum: ['false', 'true']}
  },
  additionalProperties: false
});

const queryWithDataSchema = ajv.compile({
  type: 'object',
  properties: {
    populate: {type: 'string', enum: ['false', 'true']},
    data: {type: 'string', enum: ['false', 'true']}
  },
  additionalProperties: false
});

const dataSchema = ajv.compile({
  type: 'object',
  properties: {
    data: {type: 'string', enum: ['false', 'true']}
  },
  additionalProperties: false
});

const createAssignmentSchema = ajv.compile({
  type: 'object',
  required: ['task'],
  properties: {
    task: {type: 'string', pattern: '[a-fA-F\\d]{24}'},
    data: {type: 'object'}
  },
  additionalProperties: false
});

/**
 * 创建任务,用户必须具有subscriber权限。
 *  - ajax: POST /api/assignment
 *  - socket.io: emit assignment:create
 * @param params {object}
 *   - query {object}
 *     - populate {boolean} 是否返回
 *     - data {boolean} 是否返回数据
 *   - data {object}
 *     - task {string} 任务
 *     - data {object} 额外的数据
 * @param global
 * @return {Promise<object>}
 */
async function createAssignment(params, global) {
  const {users, tasks, taskTemplates, assignments} = global;
  coreAssert(params.auth && (params.auth.role & users.roleEnum.SUBSCRIBER),
    errorsEnum.PERMISSION, 'Requires subscriber privilege');
  coreValidate(queryWithDataSchema, params.query);
  coreValidate(createAssignmentSchema, params.data);
  const task = await tasks.findById(params.data.task).notDeleted().select('+data');
  coreAssert(task, errorsEnum.INVALID, 'Task does not exist');
  coreAssert(task.status === tasks.statusEnum.PUBLISHED,
    errorsEnum.INVALID, 'Task is not at PUBLISHED status');
  coreAssert(task.type !== undefined && taskTemplates[task.type] !== undefined &&
    taskTemplates[task.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
  const taskType = taskTemplates[task.type];
  const assignment = new assignments({
    task: task._id,
    publisher: task.publisher,
    subscriber: params.auth.uid,
    type: task.type,
    status: assignments.statusEnum.EDITING,
    valid: false
  });
  if (typeof taskType.createAssignment === 'function') {
    const data = await taskType.createAssignment(task, assignment, params, global);
    if (data !== undefined)
      return data;
  }
  await assignment.save();
  if (params.query.populate === 'true') {
    const data = assignment.toPlainObject(params.auth);
    if (params.query.data === 'true')
      data.data = (typeof taskType.assignmentDataToPlainObject === 'function' &&
        taskType.assignmentDataToPlainObject(assignment, params.auth)) || {};
    return coreOkay({
      data
    });
  }
  return coreOkay({data: assignment._id});
}

/**
 * 获取作业详情。
 *  - ajax: GET /api/assignment/:id
 *  - socket.io: emit assignment:get
 * @param params params {object}
 *  - auth {object} 权限
 *  - id {string} 要获取详情的作业的id
 * @param global
 * @return {Promise<object>}
 */
async function getAssignment(params, global) {
  const {assignments} = global;
  coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
  const assignment = await assignments.findById(params.id).notDeleted();
  coreAssert(assignment, errorsEnum.EXIST, 'Assignment does not exist');
  coreAssert(params.auth && (assignment.publisher.equals(params.auth.uid) ||
    assignment.subscriber.equals(params.auth.uid)),
    // eslint-disable-next-line
    errorsEnum.PERMISSION, 'Permission denied'
  );
  return coreOkay({
    data: assignment.toPlainObject(params.auth)
  });
}

const patchAssignmentSchema = ajv.compile({
  type: 'object',
  properties: {
    status: {type: 'string', enum: ['SUBMITTED', 'ADMITTED', 'REJECTED']}
  },
  additionalProperties: false
});

/**
 * 更改作业的状态,可能的更改包括订阅者从编辑到提交(作业必须为valid)和发布者从提交到接受或拒绝
 *  - ajax: PATCH /api/assignment/:id
 *  - socket.io: emit assignment:patch
 * @param params {object} 请求的数据
 *   - query {object} 请求的query
 *     - populate {boolean}
 *   - data {object} 请求的status
 *     - status {string} 可选,状态
 * @param global {object}
 * @return {Promise<object>}
 */
async function patchAssignment(params, global) {
  const {assignments, taskTemplates} = global;
  coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
  coreValidate(querySchema, params.query);
  coreValidate(patchAssignmentSchema, params.data);
  if (params.data.status !== undefined)
    params.data.status = assignments.statusEnum[params.data.status];
  const assignment = await assignments.findById(params.id).notDeleted().select('+data');
  coreAssert(assignment, errorsEnum.EXIST, 'Assignment does not exist');
  coreAssert(taskTemplates[assignment.type] !== undefined &&
    taskTemplates[assignment.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
  const taskType = taskTemplates[assignment.type];
  const isPublisher = params.auth && assignment.publisher.equals(params.auth.uid);
  const isSubscriber = params.auth && assignment.subscriber.equals(params.auth.uid);
  coreAssert(isPublisher || isSubscriber, errorsEnum.PERMISSION, 'Permission denied');
  coreAssert(params.data.status !== assignments.statusEnum.SUBMITTED ||
    (isSubscriber && assignment.status === assignments.statusEnum.EDITING && assignment.valid),
    // eslint-disable-next-line indent
    errorsEnum.INVALID, 'Invalid status'
  );
  coreAssert((params.data.status !== assignments.statusEnum.ADMITTED &&
    params.data.status !== assignments.statusEnum.REJECTED) ||
    (isPublisher && assignment.status === assignments.statusEnum.SUBMITTED),
    // eslint-disable-next-line indent
    errorsEnum.INVALID, 'Invalid status'
  );
  const oldStatus = assignment.status;
  Object.assign(assignment, params.data);
  if (assignment.status !== oldStatus && typeof taskType.assignmentStatusChanged === 'function')
    await taskType.assignmentStatusChanged(assignment, params, global);
  await assignment.save();
  return coreOkay({
    data: params.query.populate === 'true'
      ? assignment.toPlainObject(params.auth) : assignment._id
  });
}

const findAssignmentSchema = ajv.compile({
  type: 'object',
  properties: {
    populate: {type: 'string', enum: ['false', 'true']},
    count: {type: 'string', enum: ['false', 'true']},
    filter: {
      type: 'object',
      properties: {
        search: {type: 'string'},
        task: {type: 'string', pattern: '[a-fA-F\\d]{24}'},
        publisher: {type: 'string', pattern: '[a-fA-F\\d]{24}'},
        subscriber: {type: 'string', pattern: '[a-fA-F\\d]{24}'},
        type: {type: 'string', pattern: '^[-_a-zA-Z\\d]+$'},
        status: {type: 'string', enum: ['EDITING', 'SUBMITTED', 'ADMITTED', 'REJECTED']},
        valid: {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/assignment
 *  - socket.io: emit assignment:find
 * @param params 请求数据
 *   - auth {object} 权限
 *   - query {object} 请求的query
 *     - populate {boolean} 是否展开数据
 *     - count {boolean} 统计总数,需要额外的开销
 *     - filter {Object.<string, string>}
 *         - search {string} 全文检索
 *         - task {string}
 *         - publisher {string} 对于发布者,这个值只能是自己,建议以发布者身份搜索时永远设置这个值。
 *         - subscriber {string} 对于订阅者,这个值只能是自己,建议以订阅者身份搜索时永远设置这个值。
 *         - type {string}
 *         - status {string}
 *         - valid {boolean}
 *     - limit {number} 可选,小于等于50大于0数字,默认为10
 *     - lastId {string} 可选,请求的上一个Id
 * @param global
 * @return {Promise<object>}
 */
async function findAssignment(params, global) {
  const {assignments, users} = global;
  coreValidate(findAssignmentSchema, params.query);
  coreAssert(params.auth && (params.auth.role & (users.roleEnum.SUBSCRIBER | users.roleEnum.PUBLISHER)),
    errorsEnum.PERMISSION, 'Permission denied');
  const isSubscriber = !!(params.auth.role & users.roleEnum.SUBSCRIBER);
  const isPublisher = !!(params.auth.role & users.roleEnum.PUBLISHER);
  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({summary: {$regex: x}});
      });
      and.push({$or: or});
    }
  }
  if (params.query.valid !== undefined)
    params.query.valid = params.query.valid === 'true';
  if (params.query.filter.status !== undefined)
    params.query.filter.status = assignments.statusEnum[params.query.filter.status];
  if (isPublisher && isSubscriber) {
    const setSubscriber = params.query.filter.subscriber !== undefined;
    const setPublisher = params.query.filter.publisher !== undefined;
    if (setSubscriber && setPublisher)
      coreAssert(params.query.filter.subscriber === params.auth.uid ||
        params.query.filter.publisher === params.auth.uid,
        // eslint-disable-next-line
        errorsEnum.SCHEMA, 'Invalid subscriber or publisher');
    else if (setSubscriber) {
      if (params.query.filter.subscriber !== params.auth.uid)
        params.query.filter.publisher = params.auth.uid;
    } else if (setPublisher) {
      if (params.query.filter.publisher !== params.auth.uid)
        params.query.filter.subscriber = params.auth.uid;
    } else
      and.push({
        $or: [
          {subscriber: params.auth.uid},
          {publisher: params.auth.uid}
        ]
      });
  } else if (isPublisher) {
    coreAssert(params.query.filter.publisher === undefined ||
      params.query.filter.publisher === params.auth.uid,
      // eslint-disable-next-line
      errorsEnum.INVALID, 'Invalid publisher');
    params.query.filter.publisher = params.auth.uid;
  } else {
    coreAssert(params.query.filter.subscriber === undefined ||
      params.query.filter.subscriber === params.auth.uid,
      // eslint-disable-next-line
      errorsEnum.INVALID, 'Invalid subscriber');
    params.query.filter.subscriber = params.auth.uid;
  }
  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 assignments.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 assignments.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 assignments.count(params.query.filter).notDeleted();
  }
  return coreOkay({data: result});
}

/**
 * 删除作业。必须是任务的订阅者或者任务的提交者。
 * @param params
 * @param global
 * @return {Promise<void>}
 */
async function deleteAssignment(params, global) {
  const {assignments} = global;
  coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
  const assignment = await assignments.findById(params.id).notDeleted();
  coreAssert(assignment, errorsEnum.EXIST, 'Assignment does not exist');
  const isPublisher = params.auth && assignment.publisher.equals(params.auth.uid);
  const isSubscriber = params.auth && assignment.subscriber.equals(params.auth.uid);
  coreAssert(isPublisher || isSubscriber, errorsEnum.PERMISSION, 'Permission denied');
  await assignment.delete();
  return coreOkay();
}

/**
 * 上传作业数据,提交者必须为作业的订阅者,且作业处于`EDITING`状态
 * @param ctx {object} koa的context
 *   - params {object} 请求的数据
 *     - query {object} 请求的query
 *       - data {boolean} 是否返回数据,默认false
 * @return {Promise<void>}
 */
async function postAssignmentData(ctx) {
  const {params, global} = ctx;
  const {assignments, taskTemplates} = global;
  coreValidate(dataSchema, params.query);
  coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
  const assignment = await assignments.findById(params.id).notDeleted().select('+data');
  coreAssert(assignment, errorsEnum.EXIST, 'Assignment does not exist');
  coreAssert(assignment.type !== undefined && taskTemplates[assignment.type] !== undefined &&
    taskTemplates[assignment.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
  coreAssert(params.auth && assignment.subscriber.equals(params.auth.uid),
    errorsEnum.PERMISSION, 'Requires subscriber privilege');
  coreAssert(assignment.status === assignments.statusEnum.EDITING,
    errorsEnum.INVALID, 'Assignment is not at EDITING status');
  const taskType = taskTemplates[assignment.type];
  let data;
  const next = async () => {
    if (typeof taskType.postAssignmentData === 'function')
      data = await taskType.postAssignmentData(assignment, params, global);
  };
  if (typeof taskType.postAssignmentDataMiddleware === 'function')
    await taskType.postAssignmentDataMiddleware(ctx, next);
  else
    await next();
  if (data !== undefined)
    ctx.body = data;
  else if (params.query.data === 'true')
    ctx.body = coreOkay({
      data: (typeof taskType.assignmentDataToPlainObject === 'function' &&
        taskType.assignmentDataToPlainObject(assignment, params.auth)) || {}
    });
  else
    ctx.body = coreOkay();
}

/**
 * 获取作业数据,可以是作业的发布者,或订阅者
 * @param ctx {object} koa的context
 * @return {Promise<void>}
 */
async function getAssignmentData(ctx) {
  const {params, global} = ctx;
  const {assignments, taskTemplates} = global;
  coreAssert(params.id && idRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid id');
  const assignment = await assignments.findById(params.id).notDeleted().select('+data');
  coreAssert(assignment, errorsEnum.EXIST, 'Assignment does not exist');
  coreAssert(assignment.type !== undefined && taskTemplates[assignment.type] !== undefined &&
    taskTemplates[assignment.type].meta.enabled, errorsEnum.INVALID, 'Invalid task type');
  coreAssert(params.auth && (assignment.subscriber.equals(params.auth.uid) ||
    assignment.publisher.equals(params.auth.uid)),
    // eslint-disable-next-line
    errorsEnum.PERMISSION, 'Permission denied');
  const taskType = taskTemplates[assignment.type];
  let data;
  if (typeof taskType.getAssignmentData === 'function')
    data = await taskType.getAssignmentData(assignment, params, global);
  if (data !== undefined)
    ctx.body = data;
  else
    ctx.body = coreOkay({
      data: (typeof taskType.assignmentDataToPlainObject === 'function' &&
        taskType.assignmentDataToPlainObject(assignment, params.auth)) || {}
    });
}

module.exports = {
  createAssignment,
  getAssignment,
  patchAssignment,
  findAssignment,
  deleteAssignment,
  postAssignmentData,
  getAssignmentData
};