diff --git a/.changeset/30036-activity-feed-default-view.md b/.changeset/30036-activity-feed-default-view.md new file mode 100644 index 00000000000..414811137e6 --- /dev/null +++ b/.changeset/30036-activity-feed-default-view.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): the activity list opens newest first, without the platform's own rows, under a plain name and description + +Clause-②: no +Metadata-protocol: none + +- **`sys_activity` declares two list views.** `recent` comes first, so it is the list Home's "view all activity" and the bell's Activity tab open (both name no view): newest first, and only rows a person wrote (`actor_id` set) about something other than the platform's own objects: the metadata store, the data-migration ledger, and every identity object `@objectstack/platform-objects/identity` declares (users, members, organizations, sessions, keys, SCIM): every sign-in stamps the last-login columns on `sys_user` under the signing-in person's own name, the signing key in `sys_jwks` is minted under the first sign-in's session, and adding a person writes `sys_user` and `sys_member` rows that person can read. Sign-in stamps, migrations, seeds and metadata history no longer sit between the business rows; an administrator's edits to users are in `all`. `all` lists every row the reader may read, platform rows included, newest first. Nothing is removed from storage and nothing changes in what is written; every excluded row still has its `sys_audit_log` entry. +- **Label and description.** The object reads `Activity Feed` (zh-CN `动态`) instead of `Activity`, which collided with an app's own activity object (a CRM's calls and meetings), and its description says what the page is instead of `Recent activity stream entries (lightweight, denormalized)`. 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 34bc4a358e4..b204eba7f44 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts @@ -1,6 +1,58 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field, SYS_ACTIVITY_BUILTIN_TYPES } from '@objectstack/spec/data'; +import { SysMigration, SysMigrationJournal } from '@objectstack/platform-objects/system'; +import * as IdentityObjects from '@objectstack/platform-objects/identity'; +import { SystemObjectName } from '@objectstack/spec/system'; + +/** Every object the identity domain declares (users, members, organizations, sessions, keys, SCIM, …). */ +const IDENTITY_OBJECT_NAMES: readonly string[] = (Object.values(IdentityObjects) as unknown[]) + .filter((value): value is { name: string; fields: object } => + !!value && typeof value === 'object' && typeof (value as { name?: unknown }).name === 'string' + && typeof (value as { fields?: unknown }).fields === 'object') + .map((object) => object.name) + .sort(); + +/** + * The platform objects whose rows the default `recent` view leaves out, even + * when a person's session wrote them: + * + * - the metadata store (published items, drafts, history, commits, the + * store's own audit): publishing in Studio writes these under the + * publisher's name; + * - the data-migration ledger; + * - the identity domain, every object `@objectstack/platform-objects/identity` + * declares. Measured on a fresh boot, all under a person's own id: every + * sign-in stamps `last_login_at` / `last_login_ip` on `sys_user`; the + * token-signing key in `sys_jwks` is minted under the first sign-in's + * session; adding a person writes a `sys_user` and a `sys_member` row, which + * that person can then read. The only column that tells a sign-in stamp from + * a profile edit is `changed_fields`, which a filter may not name for every + * reader (`parent-field-query-guard.ts`), so the cut is by object. + * + * Their writes are mirrored like any other object's, and those rows stay + * stored and readable: the `all` view below lists them, and each one has its + * compliance row in `sys_audit_log`. + * + * Read from the definitions and the protocol's name constants where this file + * may import them. The three metadata-store names are spelled out: an object + * file is loaded from source on its own by `check:platform-object-tenancy-census`, + * and `@objectstack/metadata-core` publishes its definitions only from its root + * entry, whose graph that loader cannot evaluate. + * `sys-activity-default-view.integration.test.ts` holds the list equal to the + * definitions' own names, so a rename reds there. Deliberately not a name + * prefix: `sys_*` also names objects whose rows are people's work (a file + * someone uploads). + */ +export const ACTIVITY_FEED_EXCLUDED_OBJECTS: readonly string[] = Object.freeze([ + SystemObjectName.METADATA, + 'sys_metadata_history', + 'sys_metadata_commit', + 'sys_metadata_audit', + SysMigration.name, + SysMigrationJournal.name, + ...IDENTITY_OBJECT_NAMES, +]); /** * sys_activity — Lightweight Activity Stream @@ -18,8 +70,10 @@ import { ObjectSchema, Field, SYS_ACTIVITY_BUILTIN_TYPES } from '@objectstack/sp */ export const SysActivity = ObjectSchema.create({ name: 'sys_activity', - label: 'Activity', - pluralLabel: 'Activities', + // Not "Activity": an app's own object is often called that (a CRM's calls + // and meetings), and this page sat beside it under the same word. + label: 'Activity Feed', + pluralLabel: 'Activity Feed', icon: 'activity', isSystem: true, managedBy: 'append-only', @@ -32,11 +86,54 @@ export const SysActivity = ObjectSchema.create({ storage: { strategy: 'rotation', shards: 14, unit: 'day' }, reclaim: true, }, - description: 'Recent activity stream entries (lightweight, denormalized)', + description: 'What people did to records recently, newest first: who created, changed or deleted what. Entries are kept for 14 days.', displayNameField: 'summary', nameField: 'summary', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) highlightFields: ['timestamp', 'type', 'actor_name', 'summary'], + // The list a reader lands on with no view named (Home's "view all activity" + // and the bell's Activity tab both open the bare object route) is the FIRST + // view declared here, so `recent` stays first. + // + // `recent` is the cut Home's activity card makes, as far as a query column + // can say it: a person acted (`actor_id` set; a system write leaves it null, + // ADR-0118 D1: a migration, a seed, a platform setting written at boot), and + // the row is not about one of the platform objects + // `ACTIVITY_FEED_EXCLUDED_OBJECTS` names. It names no value-bearing column + // (`parent-field-query-guard.ts`), so every reader may run it. + // + // `all` is every row the reader may read, platform rows included: where an + // administrator still finds what `recent` leaves out. Nothing is removed from + // storage and nothing changes in what is written. + listViews: { + recent: { + type: 'grid', + name: 'recent', + label: 'Recent activity', + data: { provider: 'object', object: 'sys_activity' }, + columns: ['timestamp', 'actor_name', 'type', 'summary'], + filter: [ + { field: 'actor_id', operator: 'is_not_null' }, + { field: 'object_name', operator: 'not_in', value: [...ACTIVITY_FEED_EXCLUDED_OBJECTS] }, + ], + sort: [{ field: 'timestamp', order: 'desc' }], + pagination: { pageSize: 50 }, + emptyState: { + title: 'No recent activity', + message: 'Changes people make to records appear here, newest first.', + }, + }, + all: { + type: 'grid', + name: 'all', + label: 'All entries', + data: { provider: 'object', object: 'sys_activity' }, + columns: ['timestamp', 'actor_name', 'type', 'summary'], + sort: [{ field: 'timestamp', order: 'desc' }], + pagination: { pageSize: 100 }, + }, + }, + fields: { id: Field.text({ label: 'Activity ID', diff --git a/packages/plugins/plugin-audit/src/sys-activity-default-view.integration.test.ts b/packages/plugins/plugin-audit/src/sys-activity-default-view.integration.test.ts new file mode 100644 index 00000000000..6f4f45af96c --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-activity-default-view.integration.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_activity`'s default list: what a reader sees when Home's "view all + * activity" or the bell's Activity tab opens the bare object route. + * + * Both doors name no view, so the Console opens the FIRST declared list view. + * That view (`recent`) must answer newest first, and must leave out the rows + * about the platform's own objects (the metadata store, the migration ledger, + * and `sys_user`, whose sign-in stamp is written under the signing-in person's + * own name) and the rows no person wrote (a migration, a seed: the system actor + * is `null`, ADR-0118 D1). The second view (`all`) still lists every row, which + * is where an administrator finds the rest. + * + * ## Why a real engine and a real driver + * + * The view is a filter and a sort the Console sends to the server. Whether + * that filter selects the right rows is a question about the Filter Protocol + * as a SQL driver executes it (`$null`, `$nin`), so the view's own rules are + * lowered the way the Console lowers a view filter (one `[field, operator, + * value]` node per rule under `and`) and parsed by the server's own + * `parseFilterAST`, then run through the real engine on SQLite. The human and + * system rows on a business object are written by the real audit writer, so + * the "system write leaves `actor_id` null" premise is the writer's, not the + * fixture's. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { parseFilterAST } from '@objectstack/spec/data'; +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataCommitObject, + SysMetadataAuditObject, +} from '@objectstack/metadata-core'; +import { SysMigration, SysMigrationJournal } from '@objectstack/platform-objects/system'; +import * as IdentityObjects from '@objectstack/platform-objects/identity'; +import { SystemObjectName } from '@objectstack/spec/system'; + +import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; +import { SysActivity } from './objects/index.js'; +import { ACTIVITY_FEED_EXCLUDED_OBJECTS } from './objects/sys-activity.object.js'; + +const ACTIVITY = 'sys_activity'; +const DEAL = 'act_deal'; +const HARNESS_PACKAGE = 'com.objectstack.audit.test.activity-default-view'; + +const SYS = { isSystem: true } as const; +const ADMIN = { userId: 'u_admin', tenantId: 'org_default', positions: ['org_admin'] }; + +const dealObject = { + name: DEAL, + label: 'Deal', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +type Row = Record; +type ViewRule = { field: string; operator: string; value?: unknown }; +type ListView = { filter?: ViewRule[]; sort?: Array<{ field: string; order: 'asc' | 'desc' }> }; + +/** Every object the identity domain exports, read the same way the declaration reads it. */ +const identityNames = Object.values(IdentityObjects) + .filter((v: any) => v && typeof v === 'object' && typeof v.name === 'string' && typeof v.fields === 'object') + .map((v: any) => v.name as string); + +const views = (SysActivity as unknown as { listViews: Record }).listViews; + +/** The Console's lowering of a view filter: one node per rule, under `and`. */ +function whereOf(view: ListView): Record | undefined { + const rules = view.filter ?? []; + if (rules.length === 0) return undefined; + return parseFilterAST(['and', ...rules.map((r) => [r.field, r.operator, r.value])]) as Record; +} + +describe('sys_activity default list view — the declaration', () => { + it('the first declared view is `recent`, the one a door with no view name opens', () => { + expect(Object.keys(views)[0]).toBe('recent'); + }); + + it('both views read newest first', () => { + expect(views.recent.sort).toEqual([{ field: 'timestamp', order: 'desc' }]); + expect(views.all.sort).toEqual([{ field: 'timestamp', order: 'desc' }]); + }); + + it('the excluded objects are read from the definitions they name', () => { + expect([...ACTIVITY_FEED_EXCLUDED_OBJECTS].sort()).toEqual( + [ + SysMetadataObject.name, + SysMetadataHistoryObject.name, + SysMetadataCommitObject.name, + SysMetadataAuditObject.name, + SysMigration.name, + SysMigrationJournal.name, + ...identityNames, + ].sort(), + ); + }); + + it('the identity objects measured writing under a person\'s id are among the excluded', () => { + for (const object of [ + IdentityObjects.SysUser, + IdentityObjects.SysMember, + IdentityObjects.SysJwks, + IdentityObjects.SysOrganization, + IdentityObjects.SysInvitation, + ]) { + expect(ACTIVITY_FEED_EXCLUDED_OBJECTS).toContain(object.name); + } + }); + + it('`all` carries no filter, so every readable row stays one click away', () => { + expect(views.all.filter).toBeUndefined(); + }); + + it('the description is not the developer sentence the page used to show', () => { + expect(SysActivity.description).not.toMatch(/denormalized/i); + expect(SysActivity.label).not.toBe('Activity'); + }); +}); + +describe('sys_activity default list view — the rows it serves', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + const summaries = (rows: Row[]) => rows.map((r) => r.summary); + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(dealObject as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + // The real CRUD mirror: a person's write and a system write on a business object. + await engine.insert(DEAL, { name: 'person deal' }, { context: ADMIN }); + await engine.insert(DEAL, { name: 'seeded deal' }, { context: SYS }); + + // Rows in the shapes the platform's own writers leave, at known instants. + const at = (minute: number) => new Date(Date.UTC(2026, 10, 4, 16, minute)).toISOString(); + const rows: Row[] = [ + { summary: 'person edits a deal (16:44)', object_name: DEAL, record_id: 'd1', actor_id: 'u_admin', timestamp: at(44) }, + { summary: 'person creates a deal (16:34)', object_name: DEAL, record_id: 'd2', actor_id: 'u_admin', timestamp: at(34) }, + { summary: 'person deletes a deal (16:40)', object_name: DEAL, record_id: 'd3', actor_id: 'u_admin', timestamp: at(40) }, + // Measured on a fresh `dev:crm` boot: the sign-in stamp carries the person who signed in. + { summary: 'sign-in stamp', object_name: 'sys_user', record_id: 'u_admin', actor_id: 'u_admin', timestamp: at(45) }, + { summary: 'metadata publish', object_name: SysMetadataObject.name, record_id: 'm1', actor_id: 'u_admin', timestamp: at(46) }, + { summary: 'metadata history', object_name: SysMetadataHistoryObject.name, record_id: 'h1', actor_id: 'u_admin', timestamp: at(47) }, + { summary: 'member added', object_name: IdentityObjects.SysMember.name, record_id: 'mb1', actor_id: 'u_admin', timestamp: at(50) }, + { summary: 'signing key minted', object_name: SystemObjectName.JWKS, record_id: 'k1', actor_id: 'u_admin', timestamp: at(49) }, + { summary: 'migration', object_name: SysMigration.name, record_id: 'g1', actor_id: null, timestamp: at(48) }, + ]; + for (const row of rows) { + await engine.insert(ACTIVITY, { type: 'updated', ...row }, { context: SYS }); + } + }); + + afterAll(async () => { + await kernel?.shutdown?.(); + }); + + it('a system write on a business object leaves actor_id null; a person\'s carries the person', async () => { + const mirrored = (await engine.find(ACTIVITY, { + where: { object_name: DEAL, record_id: { $nin: ['d1', 'd2', 'd3'] } }, + context: SYS, + })) as Row[]; + const byActor = mirrored.map((r) => r.actor_id ?? null).sort(); + expect(byActor).toEqual(['u_admin', null].sort()); + }); + + it('`recent` serves only what people did to records, newest first', async () => { + const recent = (await engine.find(ACTIVITY, { + where: whereOf(views.recent), + orderBy: views.recent.sort, + context: SYS, + })) as Row[]; + const fixed = summaries(recent).filter((s) => / \(16:\d\d\)$/.test(s)); + expect(fixed).toEqual([ + 'person edits a deal (16:44)', + 'person deletes a deal (16:40)', + 'person creates a deal (16:34)', + ]); + // The mirrored person row is in, the seeded one is not. + expect(recent.filter((r) => r.object_name === DEAL && !/ \(16:/.test(r.summary))).toHaveLength(1); + for (const row of recent) { + expect(row.actor_id).toBeTruthy(); + expect(ACTIVITY_FEED_EXCLUDED_OBJECTS).not.toContain(row.object_name); + } + const stamps = recent.map((r) => Date.parse(r.timestamp)); + expect(stamps).toEqual([...stamps].sort((a, b) => b - a)); + }); + + it('`all` still lists the rows `recent` leaves out', async () => { + const all = summaries((await engine.find(ACTIVITY, { + where: whereOf(views.all), + orderBy: views.all.sort, + context: SYS, + })) as Row[]); + for (const left of ['sign-in stamp', 'metadata publish', 'metadata history', 'signing key minted', 'member added', 'migration']) { + expect(all).toContain(left); + } + }); +}); diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index a978e8ddc22..810dfa59d2f 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -113,9 +113,9 @@ export const enObjects: NonNullable = { } }, sys_activity: { - label: "Activity", - pluralLabel: "Activities", - description: "Recent activity stream entries (lightweight, denormalized)", + label: "Activity Feed", + pluralLabel: "Activity Feed", + description: "What people did to records recently, newest first: who created, changed or deleted what. Entries are kept for 14 days.", fields: { id: { label: "Activity ID" @@ -193,6 +193,18 @@ export const enObjects: NonNullable = { label: "Milestone Key", help: "For a fired activity milestone, one sorted, comma-joined key of every field the object's activityMilestones declaration watched when it fired; NULL for every other row. Stamped by the platform at write time and never served: the read rule compares it with the current declaration." } + }, + _views: { + recent: { + label: "Recent activity", + emptyState: { + title: "No recent activity", + message: "Changes people make to records appear here, newest first." + } + }, + all: { + label: "All entries" + } } }, sys_comment: { diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index fc324f7d0ab..74682c682fa 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -113,9 +113,9 @@ export const esESObjects: NonNullable = { } }, sys_activity: { - label: "Actividad", - pluralLabel: "Actividades", - description: "Entradas recientes del flujo de actividad (ligeras y desnormalizadas).", + label: "Feed de actividad", + pluralLabel: "Feed de actividad", + description: "Lo que las personas hicieron recientemente en los registros, de lo más nuevo a lo más antiguo: quién creó, cambió o eliminó qué. Las entradas se conservan 14 días.", fields: { id: { label: "ID de actividad" @@ -193,6 +193,18 @@ export const esESObjects: NonNullable = { label: "Clave de hito", help: "Para un hito de actividad disparado, una clave ordenada y separada por comas de todos los campos que la declaración activityMilestones del objeto vigilaba al dispararse; NULL para cualquier otra fila. La plataforma la marca al escribir y nunca se sirve: la regla de lectura la compara con la declaración actual." } + }, + _views: { + recent: { + label: "Actividad reciente", + emptyState: { + title: "Sin actividad reciente", + message: "Los cambios que las personas hacen en los registros aparecen aquí, de lo más nuevo a lo más antiguo." + } + }, + all: { + label: "Todas las entradas" + } } }, sys_comment: { diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 345f3ff150f..67ac92a1168 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -113,9 +113,9 @@ export const jaJPObjects: NonNullable = { } }, sys_activity: { - label: "アクティビティ", - pluralLabel: "アクティビティ", - description: "最近のアクティビティストリームエントリ(軽量、非正規化)", + label: "アクティビティフィード", + pluralLabel: "アクティビティフィード", + description: "最近レコードに対して行われた操作を新しい順に表示します:誰が何を作成、変更、削除したか。エントリは 14 日間保持されます。", fields: { id: { label: "アクティビティ ID" @@ -193,6 +193,18 @@ export const jaJPObjects: NonNullable = { label: "マイルストーンキー", help: "発火したアクティビティマイルストーンについて、発火時にオブジェクトの activityMilestones 宣言が監視していた全フィールドをソートしてカンマで連結したキー。その他の行では NULL。書き込み時にプラットフォームが付与し、決して返されません。読み取りルールが現在の宣言と比較します。" } + }, + _views: { + recent: { + label: "最近のアクティビティ", + emptyState: { + title: "最近のアクティビティはありません", + message: "レコードへの変更がここに新しい順で表示されます。" + } + }, + all: { + label: "すべてのエントリ" + } } }, sys_comment: { diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 9763ec148ca..7908b7faf63 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -113,9 +113,9 @@ export const zhCNObjects: NonNullable = { } }, sys_activity: { - label: "活动", - pluralLabel: "活动", - description: "最近活动流条目(轻量、去规范化)", + label: "动态", + pluralLabel: "动态", + description: "最近大家对记录做了什么,按时间从新到旧:谁新建、修改或删除了什么。条目保留 14 天。", fields: { id: { label: "活动 ID" @@ -193,6 +193,18 @@ export const zhCNObjects: NonNullable = { label: "里程碑键", help: "对于已触发的活动里程碑,为该对象 activityMilestones 声明在触发时所监视的全部字段的排序、逗号连接的键;其他行为 NULL。由平台在写入时标记且从不返回:读取规则会将其与当前声明进行比较。" } + }, + _views: { + recent: { + label: "最近动态", + emptyState: { + title: "暂无最近动态", + message: "大家对记录所做的更改会按时间从新到旧显示在这里。" + } + }, + all: { + label: "全部条目" + } } }, sys_comment: {