Skip to content

Commit 817edf5

Browse files
hotlongCopilot
andcommitted
feat(audit): sys_notification inbox + assignment/mention writers (M10.8)
Adds a per-user notification inbox so users see actionable events (record assignments, @mentions) without polling individual records. - New `sys_notification` platform object (recipient_id, type, title, body, source_object/id, actor_id, is_read, read_at). Indexed on (recipient_id, is_read, created_at) for the bell unread query. - AuditPlugin registers sys_notification alongside the other sys_* audit objects. - Two writers in plugin-audit's audit-writers.ts: 1. writeAssignmentNotifications — on afterInsert/afterUpdate of any non-system object, fires when owner_id / assigned_to / assignee_id / owner / assignee is newly set or reassigned. Self-assignment is silent. 2. writeCommentMentions — on sys_comment afterInsert, parses the mentions JSON and drops one notification per mentioned user with a 240-char body preview. Author is never notified of their own mention. Both are best-effort: failures are logged via the engine logger and never bubble into the user-facing CRUD path. End-to-end verified via curl: creating a lead with owner = u_test_owner_1 produced exactly one sys_notification row (type=assignment, correct source_object / source_id linkage). Frontend bell wiring lives in objectui/app-shell (separate commit). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91008c0 commit 817edf5

4 files changed

Lines changed: 279 additions & 2 deletions

File tree

packages/platform-objects/src/audit/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { SysPresence } from './sys-presence.object.js';
99
export { SysActivity } from './sys-activity.object.js';
1010
export { SysComment } from './sys-comment.object.js';
1111
export { SysAttachment } from './sys-attachment.object.js';
12+
export { SysNotification } from './sys-notification.object.js';
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_notification — Per-User Inbox Notification
7+
*
8+
* Personal, unread-trackable notifications. Distinct from
9+
* `sys_activity` (per-record, append-only narrative) and
10+
* `sys_audit_log` (compliance-grade structured diff). Each row
11+
* targets exactly one user (`recipient_id`) and is the source of
12+
* truth for the header bell badge.
13+
*
14+
* Typical writers: comment mention, record assignment, lead-convert
15+
* completion, flow notifications. Typical readers: header bell,
16+
* notification center.
17+
*
18+
* @namespace sys
19+
*/
20+
export const SysNotification = ObjectSchema.create({
21+
name: 'sys_notification',
22+
label: 'Notification',
23+
pluralLabel: 'Notifications',
24+
icon: 'bell',
25+
isSystem: true,
26+
description: 'Per-user notification inbox entries',
27+
displayNameField: 'title',
28+
titleFormat: '{title}',
29+
compactLayout: ['title', 'type', 'is_read', 'created_at'],
30+
31+
fields: {
32+
id: Field.text({
33+
label: 'Notification ID',
34+
required: true,
35+
readonly: true,
36+
group: 'System',
37+
}),
38+
39+
// ── Routing ──────────────────────────────────────────────────
40+
recipient_id: Field.lookup('sys_user', {
41+
label: 'Recipient',
42+
required: true,
43+
searchable: true,
44+
description: 'User the notification is delivered to',
45+
group: 'Routing',
46+
}),
47+
48+
// ── Content ──────────────────────────────────────────────────
49+
type: Field.select(
50+
['mention', 'assignment', 'comment_reply', 'lead_converted', 'task_due', 'system'],
51+
{
52+
label: 'Type',
53+
required: true,
54+
defaultValue: 'system',
55+
description: 'Notification category — drives icon + sort priority',
56+
group: 'Content',
57+
},
58+
),
59+
60+
title: Field.text({
61+
label: 'Title',
62+
required: true,
63+
maxLength: 255,
64+
searchable: true,
65+
group: 'Content',
66+
}),
67+
68+
body: Field.textarea({
69+
label: 'Body',
70+
required: false,
71+
description: 'Optional secondary text (one-line summary)',
72+
group: 'Content',
73+
}),
74+
75+
// ── Source linkage ───────────────────────────────────────────
76+
source_object: Field.text({
77+
label: 'Source Object',
78+
required: false,
79+
maxLength: 100,
80+
description: 'Object name of the related record (e.g. lead, opportunity)',
81+
group: 'Source',
82+
}),
83+
84+
source_id: Field.text({
85+
label: 'Source Record',
86+
required: false,
87+
maxLength: 100,
88+
description: 'Record id within source_object',
89+
group: 'Source',
90+
}),
91+
92+
url: Field.url({
93+
label: 'Deep Link',
94+
required: false,
95+
description: 'Optional URL to navigate to when clicked',
96+
group: 'Source',
97+
}),
98+
99+
actor_id: Field.lookup('sys_user', {
100+
label: 'Actor',
101+
required: false,
102+
description: 'User who caused the notification (mentioner, assigner)',
103+
group: 'Source',
104+
}),
105+
106+
actor_name: Field.text({
107+
label: 'Actor Name',
108+
required: false,
109+
group: 'Source',
110+
}),
111+
112+
// ── Read state ───────────────────────────────────────────────
113+
is_read: Field.boolean({
114+
label: 'Read',
115+
defaultValue: false,
116+
description: 'True once recipient acknowledges',
117+
group: 'State',
118+
}),
119+
120+
read_at: Field.datetime({
121+
label: 'Read At',
122+
required: false,
123+
group: 'State',
124+
}),
125+
126+
// ── Lifecycle ────────────────────────────────────────────────
127+
created_at: Field.datetime({
128+
label: 'Created At',
129+
required: true,
130+
defaultValue: 'NOW()',
131+
readonly: true,
132+
group: 'System',
133+
}),
134+
135+
updated_at: Field.datetime({
136+
label: 'Updated At',
137+
required: false,
138+
group: 'System',
139+
}),
140+
},
141+
142+
indexes: [
143+
{ fields: ['recipient_id', 'is_read', 'created_at'] },
144+
{ fields: ['recipient_id', 'created_at'] },
145+
{ fields: ['source_object', 'source_id'] },
146+
],
147+
});

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { IDataEngine } from '@objectstack/spec/contracts';
5-
import { SysAuditLog, SysActivity, SysComment, SysAttachment } from '@objectstack/platform-objects/audit';
5+
import { SysAuditLog, SysActivity, SysComment, SysAttachment, SysNotification } from '@objectstack/platform-objects/audit';
66
import { installAuditWriters } from './audit-writers.js';
77

88
/**
@@ -30,7 +30,7 @@ export class AuditPlugin implements Plugin {
3030
scope: 'system',
3131
defaultDatasource: 'cloud',
3232
namespace: 'sys',
33-
objects: [SysAuditLog, SysActivity, SysComment, SysAttachment],
33+
objects: [SysAuditLog, SysActivity, SysComment, SysAttachment, SysNotification],
3434
});
3535

3636
ctx.logger.info('Audit Plugin initialized');

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,25 @@ export function installAuditWriters(engine: any, packageId = 'com.objectstack.au
204204
const sys = api.sudo();
205205
await sys.object('sys_audit_log').create(auditRow);
206206
await sys.object('sys_activity').create(activityRow);
207+
// M10.8: write per-user inbox notifications. Best-effort; never
208+
// throws into the user-facing CRUD path. Covers two common cases:
209+
//
210+
// 1. Assignment — if owner_id / assigned_to was newly set (or
211+
// changed to a different user) on a non-system record, drop
212+
// a notification into the recipient's inbox so they can see
213+
// "Lead X was assigned to you" without polling the record.
214+
//
215+
// 2. (Comment mentions are handled separately by the sys_comment
216+
// hook below since SKIP_OBJECTS excludes it from this writer.)
217+
await writeAssignmentNotifications(sys, {
218+
object: ctx.object,
219+
recordId: recordId ?? null,
220+
label,
221+
action,
222+
before,
223+
after,
224+
actorId: userId ?? null,
225+
});
207226
} catch (err) {
208227
// Log via engine logger if available, but never throw.
209228
try { (engine as any).logger?.warn?.('Audit write failed', { object: ctx.object, action, err: String((err as any)?.message ?? err) }); } catch {}
@@ -213,6 +232,116 @@ export function installAuditWriters(engine: any, packageId = 'com.objectstack.au
213232
engine.registerHook('afterInsert', writeAudit, { packageId });
214233
engine.registerHook('afterUpdate', writeAudit, { packageId });
215234
engine.registerHook('afterDelete', writeAudit, { packageId });
235+
236+
/**
237+
* M10.8: Dedicated hook on `sys_comment` afterInsert that parses the
238+
* `mentions` JSON field and writes one sys_notification per mentioned
239+
* user. Lives outside `writeAudit` because sys_comment is in
240+
* SKIP_OBJECTS (we don't want audit/activity rows for comments —
241+
* those have their own first-class feed).
242+
*/
243+
const writeCommentMentions = async (ctx: HookContext) => {
244+
if (ctx.object !== 'sys_comment') return;
245+
if (ctx.event !== 'afterInsert') return;
246+
const api: any = (ctx as any).api;
247+
if (!api?.sudo) return;
248+
const row: any = ctx.result;
249+
if (!row || typeof row !== 'object') return;
250+
251+
// mentions is a JSON-string textarea on sys_comment. Accept either
252+
// a raw array of user-ids ["u1","u2"] or an array of objects
253+
// [{ id: "u1" }, ...]; tolerate parse failures silently.
254+
let mentions: any = row.mentions;
255+
if (typeof mentions === 'string') {
256+
try { mentions = JSON.parse(mentions); } catch { mentions = null; }
257+
}
258+
if (!Array.isArray(mentions) || mentions.length === 0) return;
259+
260+
const userIds = mentions
261+
.map((m: any) => (typeof m === 'string' ? m : m?.id))
262+
.filter((id: any) => typeof id === 'string' && id.length > 0);
263+
if (userIds.length === 0) return;
264+
265+
const [source_object, source_id] = String(row.thread_id ?? '').split(':');
266+
const actorId = row.author_id ?? null;
267+
const actorName = row.author_name ?? null;
268+
const bodyPreview = String(row.body ?? '').slice(0, 240);
269+
270+
const sys = api.sudo();
271+
for (const uid of userIds) {
272+
if (uid === actorId) continue; // don't notify the mention author
273+
try {
274+
await sys.object('sys_notification').create({
275+
recipient_id: uid,
276+
type: 'mention',
277+
title: actorName ? `${actorName} mentioned you` : 'You were mentioned',
278+
body: bodyPreview,
279+
source_object: source_object || null,
280+
source_id: source_id || null,
281+
actor_id: actorId,
282+
actor_name: actorName,
283+
is_read: false,
284+
});
285+
} catch (err) {
286+
try { (engine as any).logger?.warn?.('Mention notification write failed', { uid, err: String((err as any)?.message ?? err) }); } catch {}
287+
}
288+
}
289+
};
290+
engine.registerHook('afterInsert', writeCommentMentions, { packageId });
291+
}
292+
293+
/**
294+
* Identify the assignee/owner field of a record. We accept several
295+
* conventional names so this works across CRM-style objects (owner_id,
296+
* assigned_to) and platform objects (recipient_id is handled separately).
297+
*/
298+
const OWNER_FIELDS = ['owner_id', 'assigned_to', 'assignee_id', 'owner', 'assignee'];
299+
300+
function pickOwner(rec: any): string | null {
301+
if (!rec || typeof rec !== 'object') return null;
302+
for (const f of OWNER_FIELDS) {
303+
const v = rec[f];
304+
if (typeof v === 'string' && v.length > 0) return v;
305+
}
306+
return null;
307+
}
308+
309+
async function writeAssignmentNotifications(
310+
sys: any,
311+
params: {
312+
object: string;
313+
recordId: string | null;
314+
label: string;
315+
action: 'create' | 'update' | 'delete';
316+
before: any;
317+
after: any;
318+
actorId: string | null;
319+
},
320+
): Promise<void> {
321+
if (params.action === 'delete') return;
322+
if (!params.recordId) return;
323+
324+
const newOwner = pickOwner(params.after);
325+
const oldOwner = pickOwner(params.before);
326+
if (!newOwner) return;
327+
if (params.action === 'update' && newOwner === oldOwner) return;
328+
if (newOwner === params.actorId) return; // self-assignment is silent
329+
330+
try {
331+
await sys.object('sys_notification').create({
332+
recipient_id: newOwner,
333+
type: 'assignment',
334+
title: `${params.object} "${params.label}" assigned to you`,
335+
body: null,
336+
source_object: params.object,
337+
source_id: params.recordId,
338+
actor_id: params.actorId,
339+
actor_name: null,
340+
is_read: false,
341+
});
342+
} catch {
343+
// best-effort; never throw into CRUD path
344+
}
216345
}
217346

218347
// Re-export for convenience.

0 commit comments

Comments
 (0)