feat(activity): first-class events, per-object call logging, and real interaction recency (#592) - #670
Merged
Merged
Conversation
… 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This was referenced Aug 4, 2026
Contributor
Author
|
Dogfood 浏览器验证记录(PM 循环,会话 通过项(实测,17.0.0-rc.2,
|
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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。status的planned→held拆分是有意的:只有held才会重置客户的接触时点。给下个季度排一个占位会议不是"联系过",让它去刷新时点正是「风险客户」报表学会说谎的方式。owner按 PM 裁定沿用 origin/main 现行约定(app 自建的sys_userlookup,和crm_task一样),这样 #548 的迁移可以把它和其他对象一起统一扫掉,而不是留一个手工特例。crm_event_attendee(junction,而不是多值 lookup)验收标准是「参会人是可查询的记录,不是 JSON 字符串」。选 junction 而非多值 lookup,理由有三条:
sys_user)、既有客户联系人(crm_contact)和潜在客户(crm_lead)。Field.lookup(..., { multiple: true })只能指向一个对象,多值方案得并排三个多值 lookup,而且跨三者无法去重、无法排序。response(接受 / 拒绝 / 待定)和is_organizer属于「人 × 会议」这个配对,不属于任何一边。多值 lookup 存的是一个裸 id 数组,没有地方挂它们——这正是当初整份名单被塞进 JSON 字符串的原因。find();对着多值列里的 id 数组,这些在 ObjectQL 里根本表达不出来。形态完全对齐 app 里既有的 junction
crm_campaign_member:autonumber 作nameField,controlled_by_parentOWD + 必填父 lookup(ADR-0055 的关系解析接受必填 lookup 作父,不需要转 master-detail)。参会人行因此永远不会比它所属的会议更可见。动作:每个销售对象上都能记录
log_call、log_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_meeting写planned事件,因此不触发接触时点,也不盖crm_case.first_response_date——排一个会不等于客户收到了回复。记录头上必须显式列出(返工第 1 轮补上)
lead / opportunity / account 三个详情页是自定义页,而自定义页会替换掉合成的记录头——它没点名的对象级动作在记录页上就是不可达的。返工前这三个页面的
actions:数组从没扩展过,于是记录头上一个活动动作都没有,唯一入口是列表行的 ⋮ 菜单。现在三个页面都显式列出log_call/log_meeting/schedule_meeting。case_detail.page.ts的动作集是一份有意的裁剪,保持不变。schedule_meeting的开始时间:平台缺陷 objectstack-ai/objectstack#5061 的绕行start原本声明为type: 'datetime',而这样的动作参数在控制台上根本无法提交:控制台把它渲染成一个天然无时区的datetime-local输入框并原样 POST("2026-08-10T15:00"),运行时的动作参数校验器则要求带时区的 ISO instant,于是恒定 400expected 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-DD和HH: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 量出来的:
stripReadonlyFields在if (!opCtx.context?.isSystem)下,把调用方提供的每一个 readonly 键从 payload 里删掉,记一条 warning 然后继续。而 hook 的ctx.api是buildHookApi(execCtx)→new ScopedContext(execCtx, this),跑在发起写入的那个用户的执行上下文里,不是 system 上下文。所以这个 app 历史上执行过的每一次冒泡都被扔掉了,只留下没人看的一行 warning。迁移说明:
crm_account.last_activity_date和crm_lead.last_contacted_date去掉readonly。和crm_campaign_member.added_date、crm_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_datetime是Field.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(),两边都是 TEXTYYYY-MM-DD,和customer_churn_signals里已经在用的形状一致。is_past/is_upcoming布尔字段。 写入时刻的快照过了那个时间点就是错的,这个坑crm_task.is_overdue已经踩过(见overdue_tasks视图 label 的注释)。「即将开始 / 已发生」由不会衰减的status表达,再按start_datetime排序。enable.files。 附件是一份已评审的集合(test/collaboration-capabilities.test.ts里的 ledger),crm_event跟随crm_task——它是活动记录,不是文档归属地。要开是一个独立的决定,不该搭这个 PR 的便车。按 PM 的范围裁定,本 PR 不碰
src/data/。后果是诚实的:Sales Activity 仪表盘在演示库里会全是 0,crm_event的日历和互动历史视图会是空的,at_risk_accounts/customer_churn_signals也要等到有真实(或种子)事件之后才会变得有意义。所有 widget 的绑定、数据集维度/度量和轴都通过了pnpm validate的 ADR-0021 校验,缺的只是行。已单独立案:种子 #671、用户文档 #672、sys_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: planned、start_datetime: 2026-08-10T15:00:00.000Z(UTC 拼接)、end_datetime: 2026-08-10T15:45:00.000Z(由event_schedule_derive推导)、duration_minutes: 45、location: Zoom、related_to_opportunity正确;crm_event_attendee有 organizer 行;sys_activity带source_object: crm_event/source_id下钻。last_activity_date保持2026-07-27未动。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.tsevent.hook.ts就没有 hook 会跑src/profiles/*.profile.tsauthorization-coverage.test.ts:任何权限集都没授权的对象,对所有用户(含 admin)403src/apps/crm.app.tsmetadata-references.test.ts:「没有导航入口的用户可见对象」不允许存在content/docs/administration/sharing-and-security.mdxsharing-coverage.test.ts:related-list 表格必须覆盖 ledger 里的每一个 account child(加了 1 行)验证
更新的守卫(都是行为真的变了,不是为了变绿):
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_typetest/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-coverage的RUNTIME_TEST_FILES):两个 event hook 的运行时用例、两份重复冒泡 body 的同表驱动对拍(body-only 沙箱决定了它们必须是重复的,那就必须有东西比对),以及那条 readonly 剥离的回归证明。🤖 Generated with Claude Code
https://claude.ai/code/session_01Rvtsew6XgsSjxVa59HRPRK
Generated by Claude Code