首页
/ Cal.com 取消原因必填配置(Cancellation Reason Requirement)功能全景与未来演进路线

Cal.com 取消原因必填配置(Cancellation Reason Requirement)功能全景与未来演进路线

2026-09-09 09:46:26作者:宗隆裙

本文基于 specs/cancellation-reason-requirement/ 目录下的设计、实现与未来工作文档,结合当前仓库的 Prisma 模型、迁移脚本、服务端校验逻辑、前端组件与测试用例,系统梳理取消原因必填配置的完整落地方式,并深入解读 future-work.md 中规划的功能增强、技术债与长期演进方向。读完本文,你将掌握该功能的数据库建模、四档枚举语义、前后端双层校验、数据流贯通方式,以及"重新安排原因必填、预置原因选项、原因分析看板、按用户覆盖、原因模板"五项未来能力的实现思路。

功能背景:为什么需要"取消原因必填"

在 Cal.com 的原始行为中,取消预约时的原因填写始终是可选项。无论主持人是想了解参会者为何取消,还是团队需要留存取消记录用于复盘,系统都无法强制任何人填写原因。specs/cancellation-reason-requirement/design.md 的问题陈述明确指出:

Currently, cancellation reasons are always optional. Hosts need the ability to require reasons for better tracking and accountability.

该功能的核心价值是让事件类型(Event Type)的拥有者在高级设置中配置"何时要求取消原因",覆盖三个用户故事:

  • 作为主持人,要求参会者填写取消原因,以便理解预约为何被取消;
  • 作为主持人,要求团队成员提供取消原因,以便留存取消记录;
  • 作为主持人,在不需要原因时保持原因可选,避免增加操作摩擦。

整个规格由四份文档构成,形成完整的"规划—决策—落地—演进"闭环:

  • design.md:需求分析、技术设计、数据流与边界情况;
  • decisions.md:关键架构决策记录(ADR-001);
  • implementation.md:实现状态与逐项完成清单;
  • future-work.md:从初始实现中延后的增强项、技术债与远期设想,即本文重点解读的路线图。

数据模型:四档枚举与数据库迁移

枚举定义

packages/prisma/schema.prisma 中定义了 CancellationReasonRequirement 枚举,共四个取值:

enum CancellationReasonRequirement {
  MANDATORY_BOTH
  MANDATORY_HOST_ONLY
  MANDATORY_ATTENDEE_ONLY
  OPTIONAL_BOTH
}

各取值语义如下(与 docs/README.md 中的配置选项一一对应):

枚举值 UI 文案 语义
MANDATORY_BOTH Mandatory for both 主持人与参会者取消时都必须填写原因
MANDATORY_HOST_ONLY Mandatory for host only(默认) 仅主持人取消时必须填写原因
MANDATORY_ATTENDEE_ONLY Mandatory for attendee only 仅参会者取消时必须填写原因
OPTIONAL_BOTH Optional for both 取消原因对所有人均为可选

列与默认值

EventType 模型新增 requiresCancellationReason 列,默认值为 MANDATORY_HOST_ONLY。规格中指明该列应放在 disableCancelling / disableRescheduling 附近,因为它是同类别的核心预约流程设置。

对应的数据库迁移脚本 packages/prisma/migrations/20260115111819_add_cancellation_reason_require/migration.sql 内容如下:

-- CreateEnum
CREATE TYPE "public"."CancellationReasonRequirement" AS ENUM ('MANDATORY_BOTH', 'MANDATORY_HOST_ONLY', 'MANDATORY_ATTENDEE_ONLY', 'OPTIONAL_BOTH');

-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN     "requiresCancellationReason" "public"."CancellationReasonRequirement" DEFAULT 'MANDATORY_HOST_ONLY';

迁移脚本明确了两个关键行为:枚举类型直接映射到 PostgreSQL 原生 ENUM,新列带数据库级默认值,因此存量事件类型行在迁移后会自然回退到"仅主持人必填"的保守行为。

架构决策:为什么用列而非 Metadata JSON

decisions.md 记录了 ADR-001:在"新增带枚举的数据库列"与"Metadata JSON 字段"之间选择前者。决策理由包括:

  • 这是核心预约流程设置,与 disableCancellingrequiresConfirmation 同级;
  • 数据库层面类型安全,查询取消校验逻辑时更干净;
  • 与同类设置(disableCancellingdisableRescheduling)的存储方式保持一致。

代价是需要一次数据库迁移。从源码看,packages/prisma/zod-utils.ts 中已包含该字段的 schema 处理,说明列方案与现有 Prisma 工具链无缝衔接。

核心校验逻辑:isCancellationReasonRequired

服务端校验

取消原因是否必填的判断逻辑被抽离为独立纯函数 packages/features/bookings/lib/cancellationReason.ts,输入为枚举值与"取消者是否为主持人",输出为布尔值:

import { CancellationReasonRequirement } from "@calcom/prisma/enums";

export function isCancellationReasonRequired(
  setting: CancellationReasonRequirement | null | undefined,
  isHost: boolean
): boolean {
  const requirement = setting ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY;

  switch (requirement) {
    case CancellationReasonRequirement.OPTIONAL_BOTH:
      return false;
    case CancellationReasonRequirement.MANDATORY_BOTH:
      return true;
    case CancellationReasonRequirement.MANDATORY_HOST_ONLY:
      return isHost;
    case CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY:
      return !isHost;
    default:
      return false;
  }
}

这个函数的语义一目了然:

  • OPTIONAL_BOTH 恒为 false
  • MANDATORY_BOTH 恒为 true
  • MANDATORY_HOST_ONLY 仅在 isHost 为真时为 true
  • MANDATORY_ATTENDEE_ONLY 仅在 isHost 为假时为 true
  • null/undefined(列值为空)回退到默认值 MANDATORY_HOST_ONLY,对应 design.md 中"Null column value: Default to MANDATORY_HOST_ONLY behavior"的边界处理。

取消流程中的调用点

packages/features/bookings/lib/handleCancelBooking.ts 中,服务端先判定取消者身份,再据此执行必填校验:

const isCancellationUserHost =
  bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;

const isReasonRequired = isCancellationReasonRequired(
  bookingToDelete.eventType?.requiresCancellationReason,
  isCancellationUserHost
);

if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
  throw new HttpError({
    statusCode: 400,
    message: "Cancellation reason is required",
  });
}

要点解读:

  • 取消者身份通过"预约属主 userId 匹配"或"属主邮箱匹配 cancelledBy"判定;
  • 原因为空(!cancellationReason?.trim())且必填时抛出 400 HttpError
  • skipCancellationReasonValidation 提供显式跳过通道;
  • platformClientId 存在时跳过该校验,对应 design.md 中"Platform users: Should respect the setting"的边界讨论——实现上对平台客户端走跳过路径,这一点在阅读时需结合平台调用上下文理解。

前端校验

前端取消组件 apps/web/components/booking/CancelBooking.tsx 接收 requiresCancellationReason prop(见第 113 行),并在第 164-178 行复用同一套判断逻辑:

const isCancellationUserHost =
  props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;

const isReasonRequired = isCancellationReasonRequired(
  props.requiresCancellationReason,
  isCancellationUserHost
);

const missingRequiredReason = isReasonRequired && !cancellationReason?.trim();
const canCancel =
  !missingRequiredReason && !hostMissingInternalNote && !cancellationNoShowFeeNotAcknowledged;

missingRequiredReason 为真时 canCancel 为假,按钮被禁用,同时文本域会展示必填提示。值得注意的是,前端与服务端使用同一个 isCancellationReasonRequired 函数,从根上避免了"前后端判定不一致"的经典问题。

UI 配置入口:Event Type 高级设置下拉框

apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx 中,在 Booking Questions 区块之后、RequiresConfirmationController 之前,以 Controller 包裹一个 Select 下拉框:

  • 标签文案:"Require cancellation reason";
  • 描述文案:"Ask for a reason when someone cancels a booking";
  • 默认值:eventType.requiresCancellationReason ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY
  • 四个选项分别映射 mandatory_for_bothmandatory_for_host_onlymandatory_for_attendee_onlyoptional_for_both 四个 i18n key。

四个选项的文案定义在英文语言包 packages/i18n/locales/en/common.json(如 "require_cancellation_reason": "Require cancellation reason""mandatory_for_both": "Mandatory for both"),遵循"Follow existing translation patterns"的规范要求。

注意该配置区块被 !isPlatform 条件包裹,即平台(Platform)租户的事件类型在 UI 上不展示该下拉框,与服务端跳过校验的 platformClientId 逻辑形成呼应。

数据流与 Prop 贯通

design.md 中的数据流设计如下,与 implementation.md 的完成清单一一对应:

  1. EventTyperequiresCancellationReason 存入数据库;
  2. apps/web/lib/booking.tsgetEventTypesFromDB 将该字段加入 select(requiresCancellationReason: true);
  3. 值经页面 props 流入预约视图;
  4. CancelBooking 组件用于校验。

需要贯通 prop 的文件(均已在实现中完成):

此外,implementation.md 还提到将 requiresCancellationReason 加入 getBookingToDelete 的 select(packages/features/bookings/lib/getBookingToDelete.ts),确保服务端取消时能拿到事件类型设置;并修复了动态标签逻辑:仅当 isReasonRequiredForUser() 返回 false 时才显示 "(optional)" 后缀。

测试覆盖:四档枚举 × 两种取消者身份

服务端测试 packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts 对四档枚举与主持人/参会者两种身份做了矩阵式覆盖:

测试场景 枚举值 预期
主持人无原因取消被拦截 MANDATORY_BOTH 400 错误
参会者无原因取消被拦截 MANDATORY_BOTH 400 错误
参会者无原因取消被拦截 MANDATORY_ATTENDEE_ONLY 400 错误
主持人无原因取消放行 MANDATORY_ATTENDEE_ONLY 允许
主持人无原因取消放行 OPTIONAL_BOTH 允许
参会者无原因取消放行 OPTIONAL_BOTH 允许
参会者无原因取消放行 MANDATORY_HOST_ONLY 允许
主持人无原因取消被拦截 null(默认回退) 400 错误
参会者无原因取消放行 null(默认回退) 允许

最后两组用例直接验证了"列值为 null 时回退到 MANDATORY_HOST_ONLY"的边界行为,与 isCancellationReasonRequired 中的默认值逻辑互相印证。

未来工作路线图:从 future-work.md 看演进方向

future-work.md 将初始实现中延后的工作划分为"增强项(Enhancements)""技术债(Technical Debt)"与"锦上添花(Nice to Have)"三档,这是本文的绝对核心。结合 design.md 的 Out of Scope 声明与现有实现,可以清晰看出每条路线的落点。

增强项一:重新安排原因必填(Reschedule reason requirement)

Same pattern, separate setting

即复用 requiresCancellationReason同一套模式,但作为独立设置实现"重新安排(Reschedule)时是否必须填写原因"。

现有实现的启示:取消侧的模式是"数据库枚举列 + 独立纯函数 + 前后端同一判定函数 + EventType 高级设置下拉框"。重新安排侧完全可以照搬:

  • 数据库层面新增 requiresRescheduleReason 列(或独立枚举),复用 CancellationReasonRequirement 的四档语义;
  • isCancellationReasonRequired 泛化为接受"角色 + 设置值"的通用判定(如 isReasonRequiredForRole),取消与重排两处共用;
  • 前端在 EventAdvancedTab 中新增并列下拉框。

design.md 明确将 "Reschedule reason configuration (separate feature)" 列为 Out of Scope,而 future-work.md 确认这是首个优先级最高的增强项。仓库中已存在 apps/web/playwright/reschedule.e2e.ts 的端到端测试基础,未来实现重排原因必填时可在此之上扩展断言。

增强项二:自定义预置原因选项(Custom predefined reason options)

Dropdown instead of free text

当前取消原因文本域是自由文本(free text)。该增强项希望改为下拉选择,让主持人预置常用原因(如"时间冲突""费用问题""改期到其他时间"等)。

实现上的关键设计点:

  • 存储方案可参照 ADR-001 的思路:预置原因列表属于"每事件类型一份"的配置,可在 Metadata JSON 中存放,也可参照 internalNotePresets 的模式(apps/web/components/booking/CancelBooking.tsx 中已存在 props.internalNotePresets 的先例,说明"预置文本列表 + 取消弹窗内选择"的组合在本代码库已有成熟形态);
  • 与必填配置的交互:当 isCancellationReasonRequired 为真且启用下拉模式时,校验逻辑应从"cancellationReason?.trim() 非空"升级为"必须选中某个预置项";
  • 需考虑"预置项 + 自由补充"的混合形态,避免强制下拉降低采集信息的丰富度。

增强项三:原因分析看板(Reason analytics dashboard)

Reason analytics/reporting

对收集到的取消原因做统计分析。design.md 将 "Reason analytics/reporting" 列为 Out of Scope,未来实现时可依托:

  • 取消原因已随预约数据持久化,handleCancelBooking 写入的 cancellationReason 字段是分析的事实表基础;
  • 仓库中的 booking-audit 能力(packages/features/booking-audit)可作为审计/报表的参考模式;
  • 看板可按事件类型、时间段、原因文案(经分类或关键词聚合)维度统计,输出"取消原因 Top N"视图。

注意:当前仓库没有可确证的现成分析看板组件,此条目属于从文档出发的规划性描述。

技术债(Technical Debt)

future-work.md 中 Technical Debt 章节目前为。结合 implementation.md 的 Next Steps 可以推断,初始实现收尾阶段的技术债主要集中验证层面:

  • 端到端测试该功能(Test the feature end-to-end);
  • 验证四个下拉选项均工作正常;
  • 验证动态标签 "(optional)" 仅在 isReasonRequiredForUser() 返回 false 时展示。

这些"待验证"项一旦发现问题(如平台租户 UI 与服务端跳过逻辑不一致、团队预约场景下组织者身份判定偏差),便会沉淀为技术债条目。此外,design.md 中"Team bookings: Setting applies regardless of team context"的边界也值得持续回归。

锦上添花:按用户覆盖与原因模板

Per-user reason requirement overrides(按用户覆盖):允许在事件类型默认配置之上,针对特定用户(如团队中的某个主持人)单独覆盖必填规则。这需要引入"事件类型 × 用户"的覆盖表或覆盖字段,在 isCancellationReasonRequired 的输入侧增加"用户级设置优先于事件类型级设置"的解析顺序,是对现有纯函数的最小侵入式扩展。

Reason templates(原因模板):与"预置原因选项"关联但定位不同——模板更偏向于"组织级或团队级"复用的原因文案集合,可被多个事件类型引用,而非每个事件类型各自维护一份。实现时可参考现有 internalNotePresets 的数据形态,将其抽象为可复用的模板资源。

边界情况与兼容性要点回顾

design.md 列出的边界情况已全部有对应实现或明确策略,未来任何演进都必须保持这些语义不被破坏:

边界情况 处理策略
Platform 用户 服务端跳过原因校验(platformClientId),UI 不展示下拉框
团队预约 设置与团队上下文无关,一律生效
列值为 null 回退 MANDATORY_HOST_ONLY(数据库默认值 + 函数默认值双保险)
无 eventTypeId 的默认事件类型 使用默认 MANDATORY_HOST_ONLY

小结

取消原因必填配置是 Cal.com 预约流程中"小而完整"的典型功能:数据库枚举 + 迁移脚本 + 前后端共用判定函数 + 高级设置下拉框 + 矩阵化测试,构成了可复制的功能落地范式。而 future-work.md 则指明了四条清晰的演进路径——重排原因必填(同模式独立设置)、预置原因选项(从自由文本到结构化)、原因分析看板(从采集到洞察)、以及按用户覆盖与原因模板(从事件类型级到用户级/模板级)。这些规划不仅服务于取消场景本身,其"枚举列 + 纯函数判定 + 双端复用"的模式,也将成为未来任何"按角色强制输入"类需求的标准答案。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
603
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
397
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525