cal.diy 事件类型取消原因必填配置(Cancellation Reason Requirement)设计与实现全解析 cal.diy 事件类型取消原因必填配置Cancellation Reason Requirement设计与实现全解析【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy取消预订Cancel Booking场景中取消原因cancellation reason在默认情况下始终是可选的这给主办方追踪取消动因、沉淀运营记录带来了不便。本文基于 cal.diy 仓库中的 取消原因必填需求设计文档 及配套的 实现记录、决策记录完整梳理取消原因必填Cancellation Reason Requirement这一事件类型高级设置从数据库、服务端校验到前端 UI 的端到端设计与落地实现。读完本文你将掌握四个必填策略双端必填 / 仅主办方 / 仅参与者 / 双端可选的取值语义与判定规则、Prisma 枚举与数据库迁移的写法、设置项在 Event Advanced Tab 中的 UI 实现位置以及取消流程中前端与后端双重校验的完整调用链。功能背景与问题陈述在 cal.diy 中每当一个预订被取消系统都会引导取消方填写一段取消原因文本。但在这项功能落地之前取消原因始终是可选的——填与不填都不会阻止取消操作本身。这在三类场景下带来痛点主办方无法理解取消动因参与者取消预订却不留原因主办方只能猜测是时间冲突、定价问题还是流程繁琐团队协作缺乏记录团队成员host取消预订时若不留内部备注与原因团队无法沉淀为什么这些预订被取消的历史数据策略无法按需调整有的业务希望强制收集原因有的业务如内部测试账号、低敏感场景则希望保持轻量。设计文档通过三条 User Story 明确了需求边界作为主办方host希望要求参与者attendee提供取消原因以便理解预订为何被取消作为主办方希望要求团队成员在取消时提供原因以便留存取消记录作为主办方希望在不需要时保持取消原因可选。一句话概括给事件类型EventType新增一个高级设置项让主办方可以按需配置取消原因在什么情况下是必填的该设置同时作用于主办方host与参与者attendee两个取消主体。设计决策为什么用数据库列而非 Metadata JSON在正式动手前需求方先做了一个关键的技术选型并被记录在 decisions.md 中ADR-001候选方案优点缺点独立数据库列 Prisma 枚举需迁移但类型安全、查询简洁需要写 migrationMetadata JSON 字段无需迁移、改动小对核心设置不够类型安全最终选择使用独立数据库列 Prisma 枚举CancellationReasonRequirement理由是这是核心预订流程设置与disableCancelling、requiresConfirmation同级别在数据库层即可保证类型安全取消校验逻辑中可以直接查询列无需 JSON 解析与同类设置disableCancelling、disableRescheduling的存储方式保持一致。这一决策的直接后果是需要生成一次数据库迁移枚举值具备数据库级类型安全查询时可直接取列值。数据库层Prisma 枚举与 EventType 新列枚举定义在 packages/prisma/schema.prisma 中新增了枚举CancellationReasonRequirement包含四个取值enum CancellationReasonRequirement { MANDATORY_BOTH MANDATORY_HOST_ONLY MANDATORY_ATTENDEE_ONLY OPTIONAL_BOTH }四个取值的语义枚举值含义取消原因必填对象MANDATORY_BOTH双端必填主办方与参与者都必须填MANDATORY_HOST_ONLY仅主办方必填只有主办方必须填参与者可选MANDATORY_ATTENDEE_ONLY仅参与者必填只有参与者必须填主办方可选OPTIONAL_BOTH双端可选主办方与参与者都可选EventType 新列在 EventType 模型 中新增字段紧邻rrHostSubsetEnabled、enablePerHostLocations附近requiresCancellationReason CancellationReasonRequirement? default(MANDATORY_HOST_ONLY)注意三个细节可空列 非空默认值列本身允许NULL但新建记录默认取MANDATORY_HOST_ONLY。这样的设计让老数据没有显式设置时也能回退到最保守的仅主办方必填行为详见下文边界情况默认值是MANDATORY_HOST_ONLY而非OPTIONAL_BOTH这是为了保持向后兼容——在功能上线前取消原因虽可选但主办方取消时系统原本就带有内部备注/原因相关逻辑因此将默认策略定为主办方必填、参与者可选是行为变化最小的一种选择该列与disableCancelling、disableReschedulingschema.prisma一样属于事件类型级别的预订策略开关设计文档中明确其应放在disableCancelling/disableRescheduling附近。数据库迁移 SQL仓库中已存在对应迁移文件 20260115111819_add_cancellation_reason_require/migration.sql内容是标准的 Prisma 枚举 加列迁移-- 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;从实现记录看该枚举与列在规划阶段已先行写入 schema迁移也已完成创建后续工作集中在 UI、校验与 props 透传上。核心判定逻辑isCancellationReasonRequired必填与否的判断被抽成了独立工具函数 packages/features/bookings/lib/cancellationReason.ts前端CancelBooking组件与服务端handleCancelBooking都复用它避免了两端判定逻辑分叉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——不要求MANDATORY_BOTH——要求MANDATORY_HOST_ONLY是—要求MANDATORY_HOST_ONLY否—不要求MANDATORY_ATTENDEE_ONLY—是要求MANDATORY_ATTENDEE_ONLY—否不要求NULL未设置——按MANDATORY_HOST_ONLY处理关键点当列为 NULL 时函数会先回退到MANDATORY_HOST_ONLY再判定这一行??是边界情况老数据、默认事件类型得以正确工作的根基。服务端校验handleCancelBooking服务端入口位于 packages/features/bookings/lib/handleCancelBooking.ts取消请求/api/cancel最终都会走到这里。首先通过getBookingToDelete查询预订时select 中已包含requiresCancellationReason这是实现记录第 10 条Added requiresCancellationReason to getBookingToDelete select确保能拿到事件类型上的设置值。接着在取消校验段落中handleCancelBooking.tsconst 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, }); }这段校验揭示了几条重要实现事实谁是主办方isCancellationUserHost通过取消者是否为预订的 userId 或预订关联用户邮箱来判断即主办方取消自己名下的预订必填但未填 → 400 拒绝当校验要求取消原因、且提交的cancellationReason为空trim()后时直接抛出HttpError(400, Cancellation reason is required)防止绕过前端直接调 API 的场景平台 API 客户端豁免!platformClientId表示该校验对平台PlatformAPI 调用放行——设计文档Platform users: Should respect the setting的边界在实现中被收敛为平台客户端调用不受此校验拦截但设置仍然会生效于前端 UI 展示显式跳过开关skipCancellationReasonValidation提供逃生通道供需要临时跳过该校验的内部流程使用。服务端校验的存在意味着即使参与者手工构造请求、绕过前端表单也无法在不填原因的情况下取消一个必填的预订。前端 UI事件类型高级设置下拉框Event Advanced Tab 中的设置项设置项位于 apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx紧随 Booking Questions表单构建器之后、RequiresConfirmationController之前通过react-hook-form的Controller渲染{!isPlatform ( Controller namerequiresCancellationReason defaultValue{eventType.requiresCancellationReason ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY} render{({ field: { value, onChange } }) { const cancellationReasonOptions [ { value: CancellationReasonRequirement.MANDATORY_BOTH, label: t(mandatory_for_both) }, { value: CancellationReasonRequirement.MANDATORY_HOST_ONLY, label: t(mandatory_for_host_only), }, { value: CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY, label: t(mandatory_for_attendee_only), }, { value: CancellationReasonRequirement.OPTIONAL_BOTH, label: t(optional_for_both) }, ]; return ( div classNameborder-subtle rounded-lg border px-4 py-6 sm:px-6 div classNameflex items-center justify-between div p classNametext-default text-sm font-semibold{t(require_cancellation_reason)}/p p classNametext-default text-sm{t(require_cancellation_reason_description)}/p /div Select value{cancellationReasonOptions.find( (opt) opt.value (value || CancellationReasonRequirement.MANDATORY_HOST_ONLY) )} options{cancellationReasonOptions} onChange{(selected) onChange(selected?.value)} classNamew-52 / /div /div ); }} / )}与设计文档逐条对照位置位于 Booking Questions 之后、RequiresConfirmationController之前 ✅标签文案require_cancellation_reasonRequire cancellation reason与require_cancellation_reason_descriptionAsk for a reason when someone cancels a booking✅四个选项Mandatory for both / Mandatory for host only默认/ Mandatory for attendee only / Optional for both ✅平台账号不渲染{!isPlatform ...}表明该设置项在 Platform 场景下不展示与设计文档Platform users: Should respect the setting的边界相呼应设置虽不展示但服务端逻辑仍然生效。四组文案均来自英文翻译文件 packages/i18n/locales/en/common.jsonrequire_cancellation_reason: Require cancellation reason, require_cancellation_reason_description: Ask for a reason when someone cancels a booking, mandatory_for_both: Mandatory for both, mandatory_for_host_only: Mandatory for host only, mandatory_for_attendee_only: Mandatory for attendee only, optional_for_both: Optional for bothCLAUDE.md 中要求Follow RequiresConfirmationController pattern for settings UI从实现看该设置项同样采用Controller Select的标准表单模式并复用了isPlatform判断来决定是否展示——这是 cal.diy 高级设置 tab 的统一形态。取消界面 CancelBooking取消界面组件 apps/web/components/booking/CancelBooking.tsx 接收requiresCancellationReason?: CancellationReasonRequirement | null作为 prop第 113 行并通过useLocale动态渲染标签。必填判定前端CancelBooking.tsxconst isCancellationUserHost props.isHost || bookingCancelledEventProps.organizer.email currentUserEmail; const isReasonRequired isCancellationReasonRequired( props.requiresCancellationReason, isCancellationUserHost ); const missingRequiredReason isReasonRequired !cancellationReason?.trim(); const canCancel !missingRequiredReason !hostMissingInternalNote !cancellationNoShowFeeNotAcknowledged;动态标签CancelBooking.tsxLabel{t(isReasonRequired ? cancellation_reason : cancellation_reason_optional_label)}/Label当取消原因必填时标签显示cancellation_reason当可选时标签切换为cancellation_reason_optional_label英文值为Reason for cancellation (optional)见 common.json提示用户可选填。这正是实现记录第 11 条所描述的动态标签仅在isReasonRequiredForUser()返回 false 时显示(optional)。表单提交TextAreadata-testidcancel_reason输入的原因通过POST /api/cancel提交cancellationReason字段按钮在!canCancel时处于disabled状态即必填原因 未填写时取消按钮不可用。前端禁用 服务端 400 校验形成双重防线。数据流与 Props 透传设计文档第 4 节给出了完整数据流实现中按序落地存储EventType 的requiresCancellationReason列持久化于数据库schema.prisma查询getEventTypesFromDB的 select 中加入该字段apps/web/lib/booking.tsrequiresCancellationReason: true与disableCancelling、disableRescheduling并列页面透传值经页面 props 流入预订视图——bookings-single-view.tsx中bookings-single-view.tsx将eventType.requiresCancellationReason传入CancelBooking对话框场景下CancelBookingDialog.tsx先接收可选 propCancelBookingDialog.tsx再转发给CancelBookingCancelBookingDialog.tsx消费CancelBooking组件内调用isCancellationReasonRequired完成前端校验cancellationReason.ts。涉及透传的文件清单与设计文档完全一致apps/web/lib/booking.tsapps/web/modules/bookings/views/bookings-single-view.tsxapps/web/components/dialog/CancelBookingDialog.tsx边界情况与设计取舍设计文档Edge Cases一节的四类边界在实现中的落点边界情况处理方式实现依据Platform 用户前端不展示设置项服务端校验对平台客户端platformClientId放行EventAdvancedTab.tsx、handleCancelBooking.ts团队预订设置直接作用于事件类型与团队上下文无关校验仅读取eventType?.requiresCancellationReason列值为 NULL老数据判定函数回退到MANDATORY_HOST_ONLYcancellationReason.ts默认事件类型无 eventTypeId同样走?? MANDATORY_HOST_ONLY回退同上同时设计文档明确列出Out of Scope不在本次范围内改期reschedule原因配置——属于独立功能自定义原因下拉选项原因统计分析/报表。这些方向被列入 future-work.md 作为后续增强改期原因必填、预设原因选项、原因分析面板、按用户覆盖、原因模板当前实现保持聚焦、不越界。实现进度小结与阅读建议根据 implementation.md该功能状态为complete已完成的 11 项工作与本仓库源码一一对应CancellationReasonRequirement枚举写入 schema.prismarequiresCancellationReason列写入 EventType 模型schema.prisma数据库迁移 20260115111819_add_cancellation_reason_require/migration.sql英文翻译键写入 common.json高级设置下拉框EventAdvancedTab.tsxgetEventTypesFromDBselect 补充booking.tsprops 透传bookings-single-view.tsx与CancelBookingDialog.tsx→CancelBookingCancelBooking组件 Props 与校验逻辑更新CancelBooking.tsxhandleCancelBooking服务端校验handleCancelBooking.tsgetBookingToDeleteselect 补充动态标签仅在可选时显示(optional)CancelBooking.tsx。剩余 Next Steps 是端到端测试与选项验证。若你想在本地体验该功能可按仓库 README.md 的方式启动 web 应用进入事件类型 → Advanced 设置页在 Booking Questions 与 Requires Confirmation 之间即可看到 Require cancellation reason 下拉框随后以主办方/参与者身份分别取消预订即可观察到必填校验与(optional)动态标签的行为差异。【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考