From ec281ea99296c02cc22b435362dff0f74e8bc0f5 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 16:54:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20sys=5Factivity=20source=20pointe?= =?UTF-8?q?r=20=E2=80=94=20the=20ActivityPointer=20model=20(ADR-0052=20?= =?UTF-8?q?=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `source_object` / `source_id` to `sys_activity`. `object_name`/`record_id` say WHICH record an activity is about (the "regarding" record); the new pair points to the RICH ENTITY it was derived from — the email row in `sys_email`, the call/meeting task, the `sys_comment` — so the timeline drills from a one-line summary to the full record, and apps can query "all activities sourced from X". This completes `sys_activity` as a proper ActivityPointer base (cf. Dataverse ActivityPointer → Email/PhoneCall/Appointment; Salesforce ActivityTimeline → EmailMessage/Task/Event): one materialized, indexed timeline that references — never duplicates — the rich, separately-tabled communication entities. The structured, queryable replacement for an id buried in `metadata`. Deliberately NOT done (per the design review): extending the `type` enum with domain verbs (email/call/meeting) — that couples a shared platform primitive to one vertical's vocabulary. `type` stays neutral; domain kind rides in `metadata.kind`; the rich entity lives in its own table. - sys-activity.object.ts: + source_object, source_id (text, searchable, optional) - ADR-0052 §5 updated to the ActivityPointer model - Verified in the showcase: a source-pointed activity round-trips and is queryable by source_object; plugin-audit 18/18 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0052-audit-is-not-the-activity-feed.md | 31 +++++++++++++++---- .../src/objects/sys-activity.object.ts | 30 ++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/adr/0052-audit-is-not-the-activity-feed.md b/docs/adr/0052-audit-is-not-the-activity-feed.md index 0405fd5f1f..42447de818 100644 --- a/docs/adr/0052-audit-is-not-the-activity-feed.md +++ b/docs/adr/0052-audit-is-not-the-activity-feed.md @@ -178,12 +178,31 @@ timeline. But weighing it against the implementation reality reversed that lean: | threads/mentions/reactions | ✅ fields already declared (`parent_id`, `reply_count`, `mentions`, `reactions`) | ✅ (but unreachable) | Picking the durable, default, UI-wired system reaches "one backend" **now**, at -near-zero risk. `service-feed`'s only real edge — one unified *typed* stream — -is obtained on the chosen family by treating **`sys_activity` as the unified -typed timeline** (its `type` enum already carries the event kinds; extend it to -`email | call | event | note` as needed). The two remaining UI niceties -(reactions, threaded replies) are a render of fields `sys_comment` **already** -has — an objectui enhancement, not a backend change. +near-zero risk. `service-feed`'s only real edge — one unified *typed* stream — is +obtained on the chosen family by treating **`sys_activity` as the unified +timeline base** — the **ActivityPointer** model (cf. Dataverse `ActivityPointer` +→ `Email`/`PhoneCall`/`Appointment` subtypes; Salesforce ActivityTimeline → +`EmailMessage`/`Task`/`Event`): + +- **`type` stays domain-NEUTRAL** — the platform-produced verbs (`created`, + `updated`, `commented`, `completed`, …). It is **not** extended with one + vertical's vocabulary (`email`/`call`/`meeting`); every domain has its own + (`interview`, `site_visit`, `inspection`, …) and a closed enum would be an + endless treadmill. Domain kind rides in `metadata.kind`. +- **Rich communication entities are their own tables** — an email belongs in + `sys_email` (already exists), a call/meeting in a task/activity object — never + crammed into a generic activity blob (they have structured headers, threading, + attachments, mutable delivery status that must be queryable). +- **`sys_activity` carries a structured pointer to that source entity** via + `source_object` / `source_id` (added in this PR) — distinct from + `object_name`/`record_id` (the *regarding* record). The timeline drills from a + one-line summary to the full email/call record, and apps can query "all + activities sourced from `sys_email`". This is the queryable equivalent of an id + buried in `metadata`. + +The two remaining UI niceties (reactions, threaded replies) are a render of +fields `sys_comment` **already** has — an objectui enhancement, not a backend +change. Rejected alternative — invest in `service-feed`: building a DB adapter + mounting the REST route + repointing ChatterPanel + migrating `sys_comment` rows is weeks diff --git a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts index e6f5a49f3b..93543f0811 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts @@ -128,6 +128,36 @@ export const SysActivity = ObjectSchema.create({ group: 'Target', }), + // ── Source pointer (ADR-0052 §5 — ActivityPointer model) ───────── + // `object_name`/`record_id` say WHICH record this activity belongs to (the + // "regarding" record, e.g. the contact). `source_object`/`source_id` point + // to the RICH ENTITY this activity was derived from — the email row in + // `sys_email`, the call/meeting in a task object, the `sys_comment` — so the + // timeline can drill from a one-line summary to the full record. This is the + // queryable, structured equivalent of cramming an id into `metadata` + // (cf. Dataverse ActivityPointer → Email/PhoneCall/Appointment subtypes, + // Salesforce ActivityTimeline → EmailMessage/Task/Event). Optional: most + // CRUD activities have no distinct source (the record IS the source). + source_object: Field.text({ + label: 'Source Object', + required: false, + readonly: true, + searchable: true, + maxLength: 255, + description: 'Object name of the rich source entity this activity was derived from (e.g. "sys_email"). Null when the activity is about the target record itself.', + group: 'Target', + }), + + source_id: Field.text({ + label: 'Source ID', + required: false, + readonly: true, + searchable: true, + maxLength: 255, + description: 'Record id of the rich source entity (paired with source_object) — lets the timeline drill to the full email/call/meeting record.', + group: 'Target', + }), + url: Field.url({ label: 'URL', required: false,