access-requests.js 5.72 KB
import { randomUUID } from 'node:crypto'

const TABLE = 'vk_access_requests'

/** 加入申请的全部状态;消费方 switch 必须穷举。 */
export const ACCESS_REQUEST_STATUSES = Object.freeze(['pending', 'approved', 'rejected'])

/**
 * @typedef {{
 *   id: string,
 *   user_id: string,
 *   email: string,
 *   note: string|null,
 *   status: 'pending'|'approved'|'rejected',
 *   created_at: Date,
 *   reviewed_by_user_id: string|null,
 *   reviewed_at: Date|null,
 *   claimed_agent_count: number,
 * }} AccessRequest
 */

/**
 * DB 行 → AccessRequest 的唯一构造点。按状态保证必有字段,非法即 throw。
 * @param {Record<string, any>} row
 * @returns {AccessRequest}
 */
export function accessRequestFromRow(row) {
  const id = requiredString(row.id, 'id', row.id)
  const status = row.status
  switch (status) {
    case 'pending':
      if (row.reviewed_at !== null || row.reviewed_by_user_id !== null) {
        throw new Error(`vk_access_requests pending row has review fields, id: ${id}`)
      }
      break
    case 'approved':
    case 'rejected':
      if (!(row.reviewed_at instanceof Date)) throw new Error(`vk_access_requests.reviewed_at missing, id: ${id}`)
      requiredString(row.reviewed_by_user_id, 'reviewed_by_user_id', id)
      break
    default:
      throw new Error(`Unhandled status: ${status}, id: ${id}`)
  }
  if (!(row.created_at instanceof Date)) throw new Error(`vk_access_requests.created_at missing, id: ${id}`)
  if (!Number.isInteger(row.claimed_agent_count)) throw new Error(`vk_access_requests.claimed_agent_count invalid, id: ${id}`)
  return {
    id,
    user_id: requiredString(row.user_id, 'user_id', id),
    email: requiredString(row.email, 'email', id),
    note: row.note === null ? null : requiredString(row.note, 'note', id),
    status,
    created_at: row.created_at,
    reviewed_by_user_id: row.reviewed_by_user_id,
    reviewed_at: row.reviewed_at,
    claimed_agent_count: row.claimed_agent_count,
  }
}

/**
 * @param {{
 *   db: import('knex').Knex,
 *   notifier: { sendApproved(ctx: object, input: { to_email: string }): Promise<void> },
 * }} deps
 */
export function createAccessRequests({ db, notifier }) {
  if (typeof db !== 'function') throw new Error('createAccessRequests: db is required')
  if (!notifier || typeof notifier.sendApproved !== 'function') throw new Error('createAccessRequests: notifier is required')

  /**
   * 提交申请。同一用户只有一条申请(user_id UNIQUE),重复提交返回已有申请。
   * @param {object} ctx
   * @param {{ user_id: string, email: string, note: string|null }} input
   * @returns {Promise<AccessRequest>}
   */
  async function submit(ctx, { user_id, email, note }) {
    await db(TABLE)
      .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null, claimed_agent_count: 0 })
      .onConflict('user_id')
      .ignore()
    return getForUser(ctx, { user_id })
  }

  /**
   * @param {object} _ctx
   * @param {{ user_id: string }} input
   * @returns {Promise<AccessRequest|null>}
   */
  async function getForUser(_ctx, { user_id }) {
    const row = await db(TABLE).where({ user_id }).first()
    return row === undefined ? null : accessRequestFromRow(row)
  }

  /**
   * @param {object} ctx
   * @param {{ user_id: string }} input
   * @returns {Promise<boolean>}
   */
  async function isApproved(ctx, { user_id }) {
    const request = await getForUser(ctx, { user_id })
    if (request === null) return false
    switch (request.status) {
      case 'approved':
        return true
      case 'pending':
      case 'rejected':
        return false
      default:
        throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
    }
  }

  /**
   * 按排队顺序列出申请。
   * @param {object} _ctx
   * @param {{ status: 'pending'|'approved'|'rejected', limit: number }} input
   * @returns {Promise<AccessRequest[]>}
   */
  async function list(_ctx, { status, limit }) {
    const rows = await db(TABLE).where({ status }).orderBy([{ column: 'created_at', order: 'asc' }, { column: 'id', order: 'asc' }]).limit(limit)
    return rows.map(accessRequestFromRow)
  }

  /**
   * 运营审核。只有 pending 能被审核;并发或重复审核返回 CONFLICT 业务错误。
   * 通过时在事务提交后发邮件通知。
   * @param {object} ctx
   * @param {{ request_id: string, reviewer_user_id: string, decision: 'approved'|'rejected' }} input
   * @returns {Promise<{ ok: true, request: AccessRequest } | { ok: false, code: 'NOT_FOUND'|'CONFLICT' }>}
   */
  async function review(ctx, { request_id, reviewer_user_id, decision }) {
    const reviewedAt = new Date()
    const updated = await db(TABLE)
      .where({ id: request_id, status: 'pending' })
      .update({ status: decision, reviewed_by_user_id: reviewer_user_id, reviewed_at: reviewedAt })
    const row = await db(TABLE).where({ id: request_id }).first()
    if (row === undefined) return { ok: false, code: 'NOT_FOUND' }
    if (updated === 0) return { ok: false, code: 'CONFLICT' }
    const request = accessRequestFromRow(row)
    switch (decision) {
      case 'approved':
        await notifier.sendApproved(ctx, { to_email: request.email })
        break
      case 'rejected':
        break
      default:
        throw new Error(`Unhandled decision: ${decision}, id: ${request_id}`)
    }
    return { ok: true, request }
  }

  return { submit, getForUser, isApproved, list, review }
}

/**
 * @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_access_requests.${field} invalid, id: ${String(id)}`)
}