task.js 5.79 KB
/** 任务的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_tasks_status` 一致。 */
export const TASK_STATUSES = Object.freeze([
  'pending_approval',
  'open',
  'claimed',
  'in_contest',
  'submitted',
  'reviewing',
  'rework',
  'disputed',
  'settled',
  'cancelled',
  'refunded',
])

export const TASK_TEMPLATES = Object.freeze(['piecework', 'contest'])

/**
 * @typedef {'pending_approval'|'open'|'claimed'|'in_contest'|'submitted'|'reviewing'|'rework'|'disputed'|'settled'|'cancelled'|'refunded'} TaskStatus
 *
 * @typedef {{
 *   id: string,
 *   guild_id: string,
 *   publisher_agent_id: string,
 *   template: 'piecework'|'contest',
 *   title: string,
 *   spec: string,
 *   reward: number,
 *   rarity: number,
 *   deadline_at: Date,
 *   status: TaskStatus,
 *   client_request_key: string,
 *   holder_agent_id: string|null,
 *   next_checkpoint_due_at: Date|null,
 *   missed_checkpoints: number,
 *   rework_count: number,
 *   review_due_at: Date|null,
 *   settled_at: Date|null,
 *   created_at: Date,
 *   updated_at: Date|null,
 * }} Task
 */

/**
 * DB 行 → Task 的唯一构造点。按状态保证必有字段,非法即 throw;下游读取零判空。
 * @param {Record<string, any>} row
 * @returns {Task}
 */
export function taskFromRow(row) {
  const id = requiredString(row.id, 'id', row.id)
  if (!TASK_TEMPLATES.includes(row.template)) throw new Error(`vk_tasks.template invalid: ${row.template}, id: ${id}`)
  const holder = row.holder_agent_id === null ? null : requiredString(row.holder_agent_id, 'holder_agent_id', id)
  const nextCheckpointDue = nullableDate(row.next_checkpoint_due_at, 'next_checkpoint_due_at', id)
  const reviewDue = nullableDate(row.review_due_at, 'review_due_at', id)
  const settledAt = nullableDate(row.settled_at, 'settled_at', id)

  switch (row.status) {
    case 'claimed':
      requirePiecework(row, id)
      if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for claimed, id: ${id}`)
      if (nextCheckpointDue === null) throw new Error(`vk_tasks.next_checkpoint_due_at missing for claimed, id: ${id}`)
      break
    case 'submitted':
      requirePiecework(row, id)
      if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for submitted, id: ${id}`)
      if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for submitted, id: ${id}`)
      break
    case 'rework':
      requirePiecework(row, id)
      if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for rework, id: ${id}`)
      break
    case 'in_contest':
      requireContest(row, id)
      break
    case 'reviewing':
      requireContest(row, id)
      if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for reviewing, id: ${id}`)
      break
    case 'settled':
      if (settledAt === null) throw new Error(`vk_tasks.settled_at missing for settled, id: ${id}`)
      break
    case 'pending_approval':
    case 'open':
    case 'disputed':
    case 'cancelled':
    case 'refunded':
      break
    default:
      throw new Error(`Unhandled status: ${row.status}, id: ${id}`)
  }
  if (row.template === 'contest' && holder !== null) throw new Error(`vk_tasks contest has holder, id: ${id}`)

  return {
    id,
    guild_id: requiredString(row.guild_id, 'guild_id', id),
    publisher_agent_id: requiredString(row.publisher_agent_id, 'publisher_agent_id', id),
    template: row.template,
    title: requiredString(row.title, 'title', id),
    spec: requiredString(row.spec, 'spec', id),
    reward: integerInRange(row.reward, 1, Number.MAX_SAFE_INTEGER, 'reward', id),
    rarity: integerInRange(row.rarity, 1, 10, 'rarity', id),
    deadline_at: requiredDate(row.deadline_at, 'deadline_at', id),
    status: row.status,
    client_request_key: requiredString(row.client_request_key, 'client_request_key', id),
    holder_agent_id: holder,
    next_checkpoint_due_at: nextCheckpointDue,
    missed_checkpoints: integerInRange(row.missed_checkpoints, 0, 1, 'missed_checkpoints', id),
    rework_count: integerInRange(row.rework_count, 0, 2, 'rework_count', id),
    review_due_at: reviewDue,
    settled_at: settledAt,
    created_at: requiredDate(row.created_at, 'created_at', id),
    updated_at: nullableDate(row.updated_at, 'updated_at', id),
  }
}

/**
 * @param {Record<string, any>} row
 * @param {string} id
 */
function requirePiecework(row, id) {
  if (row.template !== 'piecework') throw new Error(`vk_tasks status ${row.status} requires piecework, id: ${id}`)
}

/**
 * @param {Record<string, any>} row
 * @param {string} id
 */
function requireContest(row, id) {
  if (row.template !== 'contest') throw new Error(`vk_tasks status ${row.status} requires contest, id: ${id}`)
}

/**
 * @param {unknown} value
 * @param {string} field
 * @param {unknown} id
 * @returns {string}
 */
function requiredString(value, field, id) {
  if (typeof value === 'string' && value.length > 0) return value
  throw new Error(`vk_tasks.${field} invalid, id: ${String(id)}`)
}

/**
 * @param {unknown} value
 * @param {string} field
 * @param {string} id
 * @returns {Date}
 */
function requiredDate(value, field, id) {
  if (value instanceof Date) return value
  throw new Error(`vk_tasks.${field} invalid, id: ${id}`)
}

/**
 * @param {unknown} value
 * @param {string} field
 * @param {string} id
 * @returns {Date|null}
 */
function nullableDate(value, field, id) {
  return value === null ? null : requiredDate(value, field, id)
}

/**
 * @param {unknown} value
 * @param {number} min
 * @param {number} max
 * @param {string} field
 * @param {string} id
 * @returns {number}
 */
function integerInRange(value, min, max, field, id) {
  if (Number.isSafeInteger(value) && /** @type {number} */ (value) >= min && /** @type {number} */ (value) <= max) return /** @type {number} */ (value)
  throw new Error(`vk_tasks.${field} invalid: ${String(value)}, id: ${id}`)
}