Skip to content

feat(activity): first-class events, per-object call logging, and real interaction recency (#592) - #670

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-592-activity-model
Aug 4, 2026
Merged

feat(activity): first-class events, per-object call logging, and real interaction recency (#592)#670
os-zhuang merged 2 commits into
mainfrom
claude/issue-592-activity-model

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #592

背景

这个 issue 问的是一个业务问题:"我们和这个客户之间发生过什么,什么时候发生的?" 现状是答不上来。crm_task.type 里有个 meeting 选项,但没有开始时间、结束时间、地点和参会人;log_meeting 把参会人塞进 sys_activity.metadata 的 JSON 字符串里,视图筛不了、数据集分不了组、报表也数不出来;两个活动动作都被钉死在 crm_case 上(#509 的绕行),销售在线索、联系人、客户、商机上都记不了一通电话。

而最要命的一条是在做这个 PR 的过程中量出来的:crm_account.last_activity_date 根本没有任何有效写入者,详见下面「两个叠在一起的缺陷」。

新增对象

crm_event

一次占用日历时段的互动 = 一行记录。字段:subject / type(会议、电话、演示、线上研讨会、上门拜访)/ status / start_datetime / end_datetime / all_day / duration_minutes / location / owner,加上与 crm_task 完全一致的五个 related_to_* 多态 lookup。

statusplannedheld 拆分是有意的:只有 held 才会重置客户的接触时点。给下个季度排一个占位会议不是"联系过",让它去刷新时点正是「风险客户」报表学会说谎的方式。

owner 按 PM 裁定沿用 origin/main 现行约定(app 自建的 sys_user lookup,和 crm_task 一样),这样 #548 的迁移可以把它和其他对象一起统一扫掉,而不是留一个手工特例。

crm_event_attendee(junction,而不是多值 lookup)

验收标准是「参会人是可查询的记录,不是 JSON 字符串」。选 junction 而非多值 lookup,理由有三条:

  1. 参会人是异构的。 一场客户会议里有内部同事(sys_user)、既有客户联系人(crm_contact)和潜在客户(crm_lead)。Field.lookup(..., { multiple: true }) 只能指向一个对象,多值方案得并排三个多值 lookup,而且跨三者无法去重、无法排序。
  2. 参会人自己带属性。 response(接受 / 拒绝 / 待定)和 is_organizer 属于「人 × 会议」这个配对,不属于任何一边。多值 lookup 存的是一个裸 id 数组,没有地方挂它们——这正是当初整份名单被塞进 JSON 字符串的原因。
  3. junction 才是 issue 要的那种可查询。 「这位销售参加过哪些会议」「本季度拒绝过两次的联系人」「关键决策人 90 天没出席过任何会议的客户」都是这个对象上的普通 find();对着多值列里的 id 数组,这些在 ObjectQL 里根本表达不出来。

形态完全对齐 app 里既有的 junction crm_campaign_member:autonumber 作 nameField,controlled_by_parent OWD + 必填父 lookup(ADR-0055 的关系解析接受必填 lookup 作父,不需要转 master-detail)。参会人行因此永远不会比它所属的会议更可见。

动作:每个销售对象上都能记录

log_calllog_meeting 和新增的 schedule_meeting 现在按 (种类 × 对象) 生成,覆盖 lead / contact / account / opportunity / case,共 15 个注册。运行时的动作注册表键是 objectName 冒号 action.name(源码里写作 registerAction(objectKey, action.name, …)),派发器先探该对象名、再探 *——所以按对象生成这一族就是绕过 #509 而不必等平台的办法(无 objectName 的 body 动作会落到 global 这个从不被探测的键上)。

每个动作现在写入:真实的 crm_event + 真实的 crm_event_attendee 行,sys_activity 保留为统一时间线的指针,并带上 ADR-0052 的 source_object/source_id 下钻到事件本身(和 send_email 指向它的 sys_email 是同一套做法)。metadata 里只剩 attendee_count 这个展示提示,参会人本体是记录。

schedule_meetingplanned 事件,因此触发接触时点,也crm_case.first_response_date——排一个会不等于客户收到了回复。

记录头上必须显式列出(返工第 1 轮补上)

lead / opportunity / account 三个详情页是自定义页,而自定义页会替换掉合成的记录头——它没点名的对象级动作在记录页上就是不可达的。返工前这三个页面的 actions: 数组从没扩展过,于是记录头上一个活动动作都没有,唯一入口是列表行的 ⋮ 菜单。现在三个页面都显式列出 log_call / log_meeting / schedule_meetingcase_detail.page.ts 的动作集是一份有意的裁剪,保持不变。

⚠️ schedule_meeting 的开始时间:平台缺陷 objectstack-ai/objectstack#5061 的绕行

start 原本声明为 type: 'datetime',而这样的动作参数在控制台上根本无法提交:控制台把它渲染成一个天然无时区的 datetime-local 输入框并原样 POST("2026-08-10T15:00"),运行时的动作参数校验器则要求带时区的 ISO instant,于是恒定 400 expected an ISO-8601 instant with explicit zone。渲染器产出的形状与校验器接受的形状不相交,没有任何用户输入能通过。两个入口(列表行菜单、记录头)都复现。这是平台缺陷,已上报 objectstack-ai/objectstack#5061

绕行:开始时间改为收日历日 + 墙上时钟两个参数(start_date / start_time),在 body 内拼成 instant。选这两个类型不是随手挑的——它们是渲染器与校验器确实相交的那两个:控制台对 date / time 渲染原生 input[type=date] / input[type=time](ui-components 的 DateField / TimeField,都原样回传 e.target.value),而校验器的 CalendarDateValueSchema / ClockTimeValueSchema 接受的正是 YYYY-MM-DDHH:MM。两个原生选择器的 UX 也好过唯一另一个能提交的形状(一个自由文本框)。

时区:墙上时钟按 UTC 解释,两个参数的 label 都写明了这一点。UTC 是这个 body 能确定性地应用的唯一时区——沙箱上下文里没有用户或组织时区(ctx.user 只有 id / name / email),读服务器本地时区会让同一份输入在不同主机和测试里表示不同的时刻。时区写在 label 而不是 helpText 里,是因为控制台的动作参数表单只把 name/label/type/required/placeholder/options/multiple/accept/maxSize 传给字段控件,helpText 根本到不了用户眼前。

#5061 落地(控制台按浏览器时区序列化 datetime-local)后,这里应回退成单个 type: 'datetime' 参数,时区随之变成用户自己的时区——回退指令写在 src/actions/global.actions.ts 的参数列表旁边。

接触时点:此前完全没有写入者

at_risk_accounts 视图和 customer_churn_signals 报表整个建立在 crm_account.last_activity_date 上,而这一列始终是 null。两个互相独立的缺陷叠在一起,必须都修:

其一,冒泡只到被点名的那条记录。 销售把任务/活动挂在商机联系人上,几乎从不挂在客户行上,所以客户的时钟整个销售周期都不动。现在两个冒泡都会从 contact / opportunity / case 向上走到它上面的客户(三者的父字段都叫 crm_account,一个循环覆盖)。

其二,即使直接写客户也会被悄悄丢掉。 这条是本 PR 量出来的:

WARN Field 'last_activity_date' is read-only — ignoring incoming change (#2948)

stripReadonlyFieldsif (!opCtx.context?.isSystem) 下,把调用方提供的每一个 readonly 键从 payload 里删掉,记一条 warning 然后继续。而 hook 的 ctx.apibuildHookApi(execCtx)new ScopedContext(execCtx, this),跑在发起写入的那个用户的执行上下文里,不是 system 上下文。所以这个 app 历史上执行过的每一次冒泡都被扔掉了,只留下没人看的一行 warning。

迁移说明: crm_account.last_activity_datecrm_lead.last_contacted_date 去掉 readonly。和 crm_campaign_member.added_datecrm_case.is_sla_violated 是同一条推理、同一个修法:hook 或 flow 必须写的字段不能是 readonly 它们依然不出现在任何表单 section 里——那才是真正起作用的保护。test/activity-recency.test.ts 用真实 ObjectQL 在非 system 上下文下证明写入落地,并且在 readonly 回来时变红(已实测:加回 readonly: true 后该文件 2 个用例失败)。

crm_contact 新增 last_contacted_date(销售真正打电话、发邮件的对象此前没有自己的时点),send_email 现在也会盖它以及收件人上方的客户。

task_activity_bubble 另外改了一点:不再依赖 related_to_type。那是个销售可以留空的展示提示,留空时一条填好了 related_to_account 的任务哪儿都不冒泡。

分析

新增 event_metrics 数据集(活动数 / 分钟数 / 平均时长,按负责人、类型、周、关联对象类型),以及新的 Sales Activity 仪表盘:已记录互动、已预约会议、客户接触分钟数、按销售代表的活动、每周活动量、活动构成、活动落点、商机上的互动,以及沉默 30 / 60 / 90 天的客户。这也是 app 里第一个使用 task_metrics 的 widget——那个数据集此前上线后一个消费者都没有。

几处刻意的"没做",以及理由

  • 仪表盘不带 dateRange crm_event.start_datetimeField.datetime(),正是把 Service 仪表盘清零的那种列形状(Service dashboard renders empty by default — dateRange defaults to last_30_days but seed cases are older #460):driver-sql 把 datetime 过滤边界强转成 epoch 毫秒 INTEGER,而库里的 datetime 全是 ISO TEXT,SQLite 把所有 INTEGER 排在所有 TEXT 前面,于是 $gte 命中全部行、$lte 一行不中。上游 #3912 在 17.0 车上,但 #3777(datetime 列上裸 YYYY-MM-DD 上界会静默丢掉当天 00:00 之后的记录)是另一个仍未关闭的缺陷。两个都落地并在浏览器里验过之前,这里加一个日期选择器等于加一个会说谎的控件。时间维度改由数据集维度承担(start_datetime 声明 dateGranularity: 'week',17 已支持),把趋势放到上而不是过滤器里。三个流失率 tile 确实按日期开窗,但开的是 crm_account.last_activity_date —— Field.date(),两边都是 TEXT YYYY-MM-DD,和 customer_churn_signals 里已经在用的形状一致。
  • 没有 is_past / is_upcoming 布尔字段。 写入时刻的快照过了那个时间点就是错的,这个坑 crm_task.is_overdue 已经踩过(见 overdue_tasks 视图 label 的注释)。「即将开始 / 已发生」由不会衰减的 status 表达,再按 start_datetime 排序。
  • 「每个开放商机的活动数」拆成两个诚实的数字,而不是一个编出来的比值。 语义层没有跨数据集的计算度量,单个 "1.8 activities/deal" tile 只能由渲染层跨两个独立查询算出来,而目前没有任何东西这么做。经理看那一对数字;一个拼出来的商会被当成量出来的。建议另开 issue。
  • 没有 enable.files 附件是一份已评审的集合(test/collaboration-capabilities.test.ts 里的 ledger),crm_event 跟随 crm_task——它是活动记录,不是文档归属地。要开是一个独立的决定,不该搭这个 PR 的便车。

⚠️ 演示种子数据不在本轮范围(PM 裁定)

按 PM 的范围裁定,本 PR 不碰 src/data/。后果是诚实的:Sales Activity 仪表盘在演示库里会全是 0,crm_event 的日历和互动历史视图会是空的,at_risk_accounts / customer_churn_signals 也要等到有真实(或种子)事件之后才会变得有意义。所有 widget 的绑定、数据集维度/度量和轴都通过了 pnpm validate 的 ADR-0021 校验,缺的只是行。已单独立案:种子 #671、用户文档 #672sys_activity.actor_name 写入裸 id #673

✅ 浏览器验证(dogfood)

已做。 第一轮 dogfood 记录见本 PR 的评论:#670 (comment) —— 结论是 partial:线索上 Log a Call、商机上 Log a Meeting、向上冒泡、Sales Activity 仪表盘全部实测通过,type: 'lookup', multiple: true 参会人选择器确认可用;schedule_meeting 被判为阻断项(即上面 #5061 那一节)。

返工后在真实浏览器 + 真实 dev server 上复验(17.0.0-rc.2,Chromium):

  • schedule_meeting 对话框可提交了。 表单渲染 Start Date (UTC)(input[type=date])与 Start Time (UTC)(input[type=time]);POST /api/v1/actions/crm_opportunity/schedule_meeting 携带 {"start_date":"2026-08-10","start_time":"15:00",…} 返回 200(此前恒 400),响应体 {"success":true,"data":{"eventId":…,"activityId":…,"attendeeIds":[…]}}
  • 落库正确。 crm_event:status: plannedstart_datetime: 2026-08-10T15:00:00.000Z(UTC 拼接)、end_datetime: 2026-08-10T15:45:00.000Z(由 event_schedule_derive 推导)、duration_minutes: 45location: Zoomrelated_to_opportunity 正确;crm_event_attendee 有 organizer 行;sys_activitysource_object: crm_event / source_id 下钻。
  • booking 仍然不刷新接触时点。 父客户 Globex Industries 的 last_activity_date 保持 2026-07-27 未动。
  • 记录头可达性。 lead / account / opportunity 三个记录头上 Log a Call / Log a Meeting / Schedule a Meeting 均可达(部分落在 ⋮ 溢出菜单里,这是记录头自身的溢出行为);crm_case 仍只有 Log a Call,符合其有意的裁剪。

超出既定文件面的文件(说明)

派单里声明的文件面是 src/objects/src/actions/src/dashboards/src/views/src/datasets/src/translations/test/.changeset/;返工轮另行授权了 src/pages/ 下的三个记录页。新增一个用户可见对象在本仓库的 CI 门禁下机械地要求另外四处,都是纯增量:

文件 为什么非改不可
src/hooks/index.ts hooks barrel — 不登记 event.hook.ts 就没有 hook 会跑
src/profiles/*.profile.ts authorization-coverage.test.ts:任何权限集都没授权的对象,对所有用户(含 admin)403
src/apps/crm.app.ts metadata-references.test.ts:「没有导航入口的用户可见对象」不允许存在
content/docs/administration/sharing-and-security.mdx sharing-coverage.test.ts:related-list 表格必须覆盖 ledger 里的每一个 account child(加了 1 行)

验证

pnpm verify   # validate → typecheck → lint → hygiene → build → test
✓ Validation passed (1065ms)
✓ Build complete   Data: 17 Objects  344 Fields   UI: 5 Dashboards  26 Actions
Test Files  49 passed (49)
     Tests  1247 passed | 1 skipped (1248)

更新的守卫(都是行为真的变了,不是为了变绿):

  • test/global-actions.test.ts — 整份重写:一族按对象的动作、参会人是行、booking ≠ interaction;返工新增一整个 schedule_meeting is submittable from the Console (objectstack#5061) 块,用控制台真实产出的那个 bag 跑真实的 validateActionParams(每个对象一例,零 issue),证明被替换掉的 datetime 形状仍然会被拒(#5061 修好后这一条会变红,提示可以回退),再把同一个 bag 送进真实 QuickJS body,断言 planned 事件落在拼接出的 UTC instant 上、参会人成行,最后把 body 写出的事件文档喂给真实的 event_activity_bubble,证明 booking 不刷新接触时点。
  • test/action-sandbox.test.ts — 覆盖率改按运行时注册表键(对象名 + 动作名)计,15 个活动 body 是 15 个用例而不是 3 个
  • test/case-first-response.test.ts — 用 lead 版动作证明 SLA 戳不会跟着这一族跑到别的对象上;新增「仅仅预约会议不算首次响应」
  • test/hooks-runtime-service.test.ts — task 冒泡:完成态才触发、向上走到客户、不再依赖 related_to_type
  • test/hook-write-shape.test.ts — 新增 event_activity_bubble 的调用形状用例
  • test/dataset-granularity.test.ts / test/sharing-coverage.test.ts / test/metadata-references.test.ts — ledger 各记一笔(week 桶、crm_event: 'own_only'、junction 免导航)

新增 test/activity-recency.test.ts(已加入 runtime-coverageRUNTIME_TEST_FILES):两个 event hook 的运行时用例、两份重复冒泡 body 的同表驱动对拍(body-only 沙箱决定了它们必须是重复的,那就必须有东西比对),以及那条 readonly 剥离的回归证明。


🤖 Generated with Claude Code

https://claude.ai/code/session_01Rvtsew6XgsSjxVa59HRPRK


Generated by Claude Code

… interaction recency (#592)

The CRM could not answer "what happened with this customer, and when?".
`crm_task.type` offered a `meeting` value with no start time, no end time and no
attendees; `log_meeting` stuffed the attendee list into a JSON string inside
`sys_activity.metadata`; both activity actions were pinned to `crm_case` as a
workaround for the upstream dispatcher key mismatch (#509), so a rep could not
log a call on anything they actually sell to; and the `last_activity_date`
signal that `at_risk_accounts` and `customer_churn_signals` are entirely built
on was written by nothing at all.

New objects
- `crm_event` — one row per interaction that occupies a calendar slot: subject,
  start/end, duration, location, type, status, owner, and the same five
  polymorphic `related_to_*` lookups `crm_task` carries. `status` separates a
  booking from an interaction, which is what keeps the churn signal honest.
- `crm_event_attendee` — a junction, not a multi-value lookup: attendees are
  heterogeneous (contact / lead / user / external guest), carry their own
  response and organiser flag, and have to be queryable. Mirrors
  `crm_campaign_member` down to the autonumber nameField and the
  `controlled_by_parent` OWD.

Actions
- `log_call`, `log_meeting` and the new `schedule_meeting` are generated per
  object on lead / contact / account / opportunity / case. The runtime keys its
  registry on `<objectName>:<name>`, so generating the family sidesteps #509
  without waiting for the platform.
- Each writes a real `crm_event` plus its attendee rows; the `sys_activity` row
  survives as the timeline pointer, now with ADR-0052 `source_object`/
  `source_id` drilling to the event.

Interaction recency, which had no working writer
- Both bubbles now walk UP from a contact / opportunity / case to the account
  above it. A rep names the deal, never the account, so bubbling to the named
  record alone left the account clock untouched through a whole sales cycle.
- `crm_account.last_activity_date` and `crm_lead.last_contacted_date` were
  `readonly`, and `stripReadonlyFields` deletes a readonly key from every
  non-system write whose caller supplied it (#2948). A hook's `ctx.api` is a
  `ScopedContext` over the acting USER's context, so every bubble the app ever
  performed was silently discarded. Both are now writable metadata; they stay
  off every form section. `test/activity-recency.test.ts` proves the write lands
  against a real ObjectQL and fails if the flag returns.
- `crm_contact` gains `last_contacted_date`; `send_email` stamps it and the
  account above the recipient.

Analytics
- `event_metrics` dataset (activities / minutes / avg duration, by rep, type,
  week and related record) and a Sales Activity dashboard: interactions logged,
  meetings booked, customer minutes, activity by rep, weekly volume, activity
  mix, interactions on deals, and accounts quiet 30/60/90 days. Also the first
  consumer of `task_metrics`, which had shipped with no widget at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvtsew6XgsSjxVa59HRPRK
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 4, 2026 1:44am

Request Review

Copy link
Copy Markdown
Contributor Author

Dogfood 浏览器验证记录(PM 循环,会话 session_01Rvtsew6XgsSjxVa59HRPRK;PR 正文自标的「未做浏览器验证」缺口由本次补上)。结论:partial → REWORK(第 1 轮),阻断项修复后本 PR 方可入队。

通过项(实测,17.0.0-rc.2,objectstack dev,Chromium)

  • A. 线索上 Log a Call 端到端通过:表单渲染正确;PR 自标的未知项 —— type: 'lookup', multiple: true 参会人选择器 —— 确认可用(搜索、多选 chips、与 organizer 去重)。REST 复核:crm_event(type=call,status=held)+ 4 行 crm_event_attendee 全部落库,lead last_contacted_date 被盖,sys_activity 指针带 ADR-0052 下钻、record_label 正确,线索 Activity 时间线可见。
  • C. 商机上 Log a Meeting(held)通过:事件 + 参会人落库,向上冒泡实测生效(父客户 last_activity_date 2026-07-14 → 2026-08-04)。
  • D. Sales Activity 仪表盘无错渲染,tile 值与本次创建的事件一致(Interactions 2 / Meetings Booked 1 / Customer Minutes 70)。
  • planned 语义正确:经 REST 插入的 planned 事件刷新 recency,与 PR 声明一致。

阻断项(必须返工)

B. schedule_meeting 在 UI 上无法提交:start(type: 'datetime')被 Console 渲染为无时区的 datetime-local 并原样 POST,运行时校验器要求带时区 ISO instant → 恒 400(两个入口复现)。没有任何用户输入能通过 —— 验收标准后半句不成立。平台侧缺陷已上报 objectstack-ai/objectstack#5061;本仓需要一个显式标注的绕行(参数形状改为 Console 能产出的,body 内归一化,#5061 落地后回退)。

次级(一并返工)

  • lead / opportunity / account 三个自定义详情页的 actions: 数组未扩展 —— 记录头上没有任何活动动作,目前唯一入口是列表行菜单(src/pages/lead_detail.page.ts:67opportunity_detail.page.ts:48account_detail.page.ts)。
  • sys_activity.actor_name 写入裸 user id(时间线显示不透明 id)—— 不在本次返工范围,已单独立案(见 issue 列表),疑似共享 logActivityAction helper 的既有行为。

其余观察(seed 期 flow runAs 报错、owner 默认值 Unknown variable: os#620、favicon 404)均为既有问题,与本 PR 无关。


Generated by Claude Code

… put the activity actions on record headers (#592)

Two defects the dogfood browser verification of #670 found, neither visible
to a metadata check or a body test.

1. `schedule_meeting` was unusable from the UI. `start` was declared
   `type: 'datetime'`; the Console renders that as a zone-less
   `<input type="datetime-local">` and POSTs its raw value
   ("2026-08-10T15:00"), and the runtime's action-param validator answers
   400 `expected an ISO-8601 instant with explicit zone`. The renderer's
   output shape and the validator's accepted shape do not intersect, so no
   user input could submit the action. Filed upstream as
   objectstack-ai/objectstack#5061.

   The start is now collected as a calendar day plus a wall clock
   (`start_date` / `start_time`) and joined in the body. Those are the two
   param types where renderer and validator DO agree: the Console renders
   native date/time pickers emitting `YYYY-MM-DD` and `HH:MM`, exactly what
   `CalendarDateValueSchema` / `ClockTimeValueSchema` accept. The wall clock
   is read as UTC — the only zone the sandbox can apply deterministically,
   since its context carries no user or org timezone — and both labels say
   so, because the Console's action-param form does not forward `helpText`.
   The revert instruction for when #5061 lands is in the source.

2. The lead, opportunity and account detail pages are custom pages, and a
   custom page replaces the synthesized record header — so an object-scoped
   action it does not name is unreachable from the record. All three now
   list `log_call`, `log_meeting` and `schedule_meeting`. `case_detail`'s
   action set is a deliberate curation and is unchanged.

Verified in a real browser against a booted dev server: the dialog renders
`Start Date (UTC)` / `Start Time (UTC)` as native pickers, the POST returns
200 (was 400), a `planned` crm_event lands at 2026-08-10T15:00:00.000Z with
its attendee row and the ADR-0052 sys_activity pointer, and the parent
account's `last_activity_date` does not move — a booking is still not
contact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvtsew6XgsSjxVa59HRPRK
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 01:47
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 5a78f88 Aug 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Activity model: first-class meetings/events + call logging on every sales object

2 participants