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, * }} AccessRequest */ /** * DB 行 → AccessRequest 的唯一构造点。按状态保证必有字段,非法即 throw。 * @param {Record} 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}`) 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, } } /** * @param {{ * db: import('knex').Knex, * notifier: { sendApproved(ctx: object, input: { to_email: string }): Promise }, * }} 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} */ 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 }) .onConflict('user_id') .ignore() return getForUser(ctx, { user_id }) } /** * @param {object} _ctx * @param {{ user_id: string }} input * @returns {Promise} */ 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} */ 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} */ 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)}`) }