@orthacms/activity-server 0.0.0-reserve.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +16 -0
  6. package/dist/lib/activity/activity-filter.d.ts +14 -0
  7. package/dist/lib/activity/activity-filter.d.ts.map +1 -0
  8. package/dist/lib/activity/activity-filter.js +25 -0
  9. package/dist/lib/activity/activity.constants.d.ts +18 -0
  10. package/dist/lib/activity/activity.constants.d.ts.map +1 -0
  11. package/dist/lib/activity/activity.constants.js +16 -0
  12. package/dist/lib/activity/controllers/list-activity.controller.d.ts +14 -0
  13. package/dist/lib/activity/controllers/list-activity.controller.d.ts.map +1 -0
  14. package/dist/lib/activity/controllers/list-activity.controller.js +36 -0
  15. package/dist/lib/activity/dto/list-activity-query.dto.d.ts +46 -0
  16. package/dist/lib/activity/dto/list-activity-query.dto.d.ts.map +1 -0
  17. package/dist/lib/activity/dto/list-activity-query.dto.js +197 -0
  18. package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts +60 -0
  19. package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts.map +1 -0
  20. package/dist/lib/activity/infrastructure/audit-event-mapping.js +402 -0
  21. package/dist/lib/activity/infrastructure/audit-event.subscriber.d.ts +29 -0
  22. package/dist/lib/activity/infrastructure/audit-event.subscriber.d.ts.map +1 -0
  23. package/dist/lib/activity/infrastructure/audit-event.subscriber.js +54 -0
  24. package/dist/lib/activity/services/activity.service.d.ts +55 -0
  25. package/dist/lib/activity/services/activity.service.d.ts.map +1 -0
  26. package/dist/lib/activity/services/activity.service.js +147 -0
  27. package/dist/lib/activity/types/activity-view.d.ts +37 -0
  28. package/dist/lib/activity/types/activity-view.d.ts.map +1 -0
  29. package/dist/lib/activity/types/activity-view.js +2 -0
  30. package/dist/lib/activity.module.d.ts +20 -0
  31. package/dist/lib/activity.module.d.ts.map +1 -0
  32. package/dist/lib/activity.module.js +49 -0
  33. package/dist/lib/copilot/activity-tool.provider.d.ts +40 -0
  34. package/dist/lib/copilot/activity-tool.provider.d.ts.map +1 -0
  35. package/dist/lib/copilot/activity-tool.provider.js +157 -0
  36. package/dist/lib/schema/activity-events.d.ts +178 -0
  37. package/dist/lib/schema/activity-events.d.ts.map +1 -0
  38. package/dist/lib/schema/activity-events.js +38 -0
  39. package/dist/lib/schema/index.d.ts +2 -0
  40. package/dist/lib/schema/index.d.ts.map +1 -0
  41. package/dist/lib/schema/index.js +5 -0
  42. package/dist/lib/utils/activity-plugin.d.ts +26 -0
  43. package/dist/lib/utils/activity-plugin.d.ts.map +1 -0
  44. package/dist/lib/utils/activity-plugin.js +38 -0
  45. package/migrations/0000_init.sql +15 -0
  46. package/migrations/meta/0000_snapshot.json +159 -0
  47. package/migrations/meta/_journal.json +13 -0
  48. package/package.json +46 -0
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
4
+ exports.toAuditRow = toAuditRow;
5
+ const identity_server_1 = require("@orthacms/identity-server");
6
+ /**
7
+ * The **user.\*** audit kinds owned by the users context
8
+ * (`USER_ACTIVITY_KINDS` in `@orthacms/users-server`). Duplicated here as
9
+ * literals rather than imported, so the audit sink stays decoupled from every
10
+ * producer (activity depends on identity only); the parity unit test pins these
11
+ * strings against the exact rows the old in-band recorder wrote.
12
+ */
13
+ const USER_AUDIT_KINDS = {
14
+ INVITED: 'user.invited',
15
+ INVITE_RESENT: 'user.invite_resent',
16
+ /**
17
+ * An admin minted a one-time password-reset link for a member. Recorded at
18
+ * **issue** time, not at redemption: handing someone a link that can take
19
+ * over an account is an administrative act in its own right, and it needs
20
+ * to be attributed to the admin who performed it even if the link is never
21
+ * used. The redemption shows up separately as `user.password_changed`,
22
+ * actored by the account holder.
23
+ */
24
+ PASSWORD_RESET_ISSUED: 'user.password_reset_issued',
25
+ INVITE_REVOKED: 'user.invite_revoked',
26
+ PROFILE_UPDATED: 'user.profile_updated',
27
+ ROLE_CHANGED: 'user.role_changed',
28
+ SUSPENDED: 'user.suspended',
29
+ REACTIVATED: 'user.reactivated',
30
+ /**
31
+ * A credential rotation. Unlike the kinds above this one has no in-band
32
+ * predecessor to stay bug-compatible with — `user.password_changed` was
33
+ * raised on the outbox by identity's aggregate but nothing mapped it, so
34
+ * the log recorded nothing at all when a password changed
35
+ * (BUG-identity-server-05).
36
+ */
37
+ PASSWORD_CHANGED: 'user.password_changed',
38
+ /**
39
+ * An invited account accepted its invite: set a first credential and went
40
+ * `pending` → `active`. Identity's aggregate has raised `user.activated`
41
+ * since the invite flow landed and `accept-invite.use-case.ts` appends it,
42
+ * but nothing mapped it — so the trail showed the invite, then a sign-in,
43
+ * and never the moment the account became usable. Verified against a live
44
+ * stack under ORT-42: one `POST /auth/invite/accept` appended both
45
+ * `user.activated` and `auth.signed_in`, both were stamped dispatched, and
46
+ * only the sign-in produced a row.
47
+ */
48
+ ACTIVATED: 'user.activated'
49
+ };
50
+ /**
51
+ * The **media.\*** audit kinds. Media's aggregates have raised these on the
52
+ * outbox since the asset/folder aggregates were introduced — their own comment
53
+ * calls the outbox "the post-commit audit + blob-GC seam" — but nothing
54
+ * consumed them, so **every** media write was unaudited: uploading, renaming,
55
+ * re-foldering, duplicating and deleting an asset, and creating, renaming and
56
+ * deleting a folder, all left the log completely silent. Verified live under
57
+ * ORT-42 (eight write paths, eight dispatched outbox rows, zero audit rows).
58
+ *
59
+ * The event kind is the audit kind: unlike `member.*` → `user.*` there is no
60
+ * pre-existing audit catalogue for media to stay bug-compatible with, so
61
+ * inventing a second set of names would only add a mapping to remember.
62
+ */
63
+ const MEDIA_AUDIT_KINDS = {
64
+ ASSET_UPLOADED: 'media.asset.uploaded',
65
+ ASSET_UPDATED: 'media.asset.updated',
66
+ ASSET_MOVED: 'media.asset.moved',
67
+ ASSET_DELETED: 'media.asset.deleted',
68
+ FOLDER_CREATED: 'media.folder.created',
69
+ FOLDER_RENAMED: 'media.folder.renamed',
70
+ FOLDER_DELETED: 'media.folder.deleted'
71
+ };
72
+ /** Reads a payload field as a string (or `null` when absent/nullish). */
73
+ function nullableString(value) {
74
+ return typeof value === 'string' ? value : null;
75
+ }
76
+ /** A `'user'`-subject facet whose subject is the event's aggregate. */
77
+ function userSubject(event, kind, meta) {
78
+ return { kind, subjectType: 'user', subjectId: event.aggregateId, meta };
79
+ }
80
+ /** A `'workspace'`-subject facet whose subject is the event's aggregate. */
81
+ function workspaceSubject(event, meta) {
82
+ // Workspace audit kinds are identical strings to their event kinds
83
+ // (identity owns them via IDENTITY_ACTIVITY_KINDS), so the event kind is the
84
+ // audit kind.
85
+ return {
86
+ kind: event.kind,
87
+ subjectType: 'workspace',
88
+ subjectId: event.aggregateId,
89
+ meta
90
+ };
91
+ }
92
+ /**
93
+ * A `'content_entry'`-subject facet. The audit kind mirrors the event kind
94
+ * (`entry.created`, `entry.published`, `entry.deleted`, …), and the entry's
95
+ * content type rides in `meta` so the log can name *what* happened to without
96
+ * joining anything.
97
+ *
98
+ * `extra` carries the few facts one kind has and the others do not — the
99
+ * changed `fields` on an update, and whether a delete was a recoverable
100
+ * tombstone (`soft`) or the row leaving the table. Everything else about an
101
+ * entry event is the same shape, which is why one mapper serves all seven.
102
+ */
103
+ function entrySubject(event, extra = {}) {
104
+ return {
105
+ kind: event.kind,
106
+ subjectType: 'content_entry',
107
+ subjectId: event.aggregateId,
108
+ meta: {
109
+ contentType: nullableString(event.payload.contentType),
110
+ ...extra
111
+ }
112
+ };
113
+ }
114
+ /**
115
+ * An `'api_token'`-subject facet. The subject is the token, and the acting
116
+ * admin arrives separately as the actor.
117
+ *
118
+ * `meta` records what a reviewer needs to reason about the credential — its
119
+ * label, its scope, its workspace bucket, and the non-secret `lookupPrefix`
120
+ * that identifies it in the admin list. It deliberately carries **neither the
121
+ * plaintext nor the hash**: `api_tokens` stores only a SHA-256 precisely so a
122
+ * read of another table yields nothing usable, and the audit log is another
123
+ * table.
124
+ */
125
+ function apiTokenSubject(event, auditKind) {
126
+ const payload = event.payload;
127
+ return {
128
+ kind: auditKind,
129
+ subjectType: 'api_token',
130
+ subjectId: event.aggregateId,
131
+ meta: {
132
+ name: nullableString(payload.name),
133
+ scope: nullableString(payload.scope),
134
+ workspaceIds: payload.workspaceIds ?? [],
135
+ lookupPrefix: nullableString(payload.lookupPrefix)
136
+ }
137
+ };
138
+ }
139
+ /**
140
+ * Thrown when an event cannot be mapped to a row that identifies its subject.
141
+ *
142
+ * Refusing is the point. `subject_id` is the **only** handle an audit row keeps
143
+ * on the entity it is about — there is no FK, no denormalised name, and
144
+ * `actor_email` belongs to the actor — so a row written with an empty subject
145
+ * is not a degraded record, it is an unreadable one, and nothing downstream can
146
+ * repair it. Throwing leaves the outbox row undispatched, so the dispatcher
147
+ * retries it with backoff and parks it at `MAX_DELIVERY_ATTEMPTS` where the
148
+ * dead-letter query (`dispatched_at IS NULL AND attempts >= 15`) surfaces it.
149
+ * The gap becomes loud instead of becoming a row nobody can read.
150
+ */
151
+ class UnmappableAuditEventError extends Error {
152
+ constructor(event, reason) {
153
+ super(`Cannot map ${event.kind} (event ${event.eventId}) to an audit row: ${reason}`);
154
+ this.name = 'UnmappableAuditEventError';
155
+ }
156
+ }
157
+ exports.UnmappableAuditEventError = UnmappableAuditEventError;
158
+ /**
159
+ * A workspace membership facet — subject is the affected **user**, not the
160
+ * workspace.
161
+ *
162
+ * A payload with no `userId` is refused rather than defaulted. It used to write
163
+ * `subjectId: '' `, which inserts cleanly against `subject_id text NOT NULL`
164
+ * and produces a row naming an action, a workspace and an actor but no subject
165
+ * — silent corruption of the trail, and (as ♿ A11Y-activity-server-01 records)
166
+ * a row whose Subject cell has no accessible name that no client can repair.
167
+ */
168
+ function membershipSubject(event, auditKind) {
169
+ const payload = event.payload;
170
+ const userId = nullableString(payload.userId);
171
+ if (!userId) {
172
+ throw new UnmappableAuditEventError(event, 'payload.userId is missing, and the membership subject is the affected user');
173
+ }
174
+ return {
175
+ kind: auditKind,
176
+ subjectType: 'user',
177
+ subjectId: userId,
178
+ meta: {
179
+ workspaceId: event.aggregateId,
180
+ email: nullableString(payload.email)
181
+ }
182
+ };
183
+ }
184
+ /**
185
+ * A `'media_asset'`-subject facet. `meta` passes the event payload's own
186
+ * descriptive fields through — media's payloads differ per kind (`name`/`kind`
187
+ * on upload, the changed field on update, `folderId` on move, the storage key
188
+ * on delete) and each is exactly what a reviewer asking "what happened to this
189
+ * asset" wants, so the facet forwards the payload minus the actor rather than
190
+ * flattening every kind into one shape.
191
+ */
192
+ function mediaAssetSubject(event) {
193
+ return {
194
+ kind: event.kind,
195
+ subjectType: 'media_asset',
196
+ subjectId: event.aggregateId,
197
+ meta: payloadWithoutActor(event)
198
+ };
199
+ }
200
+ /** A `'media_folder'`-subject facet. Same payload-passthrough as the asset one. */
201
+ function mediaFolderSubject(event) {
202
+ return {
203
+ kind: event.kind,
204
+ subjectType: 'media_folder',
205
+ subjectId: event.aggregateId,
206
+ meta: payloadWithoutActor(event)
207
+ };
208
+ }
209
+ /**
210
+ * The event payload with `attachActor`'s `actor` key removed — the actor is
211
+ * lifted onto the row's own `actorId`/`actorEmail` columns by
212
+ * {@link toAuditRow}, so repeating it inside `meta` would only duplicate it.
213
+ */
214
+ function payloadWithoutActor(event) {
215
+ const { actor: _actor, ...rest } = event.payload;
216
+ return rest;
217
+ }
218
+ /**
219
+ * The event-kind → audit-facet table. Each entry reproduces **exactly** the row
220
+ * the old in-band `recorder.record(...)` wrote for that action:
221
+ *
222
+ * | domain event | audit kind | subject / meta |
223
+ * | -------------------------- | ------------------------- | ------------------------------------------------ |
224
+ * | `workspace.created` | `workspace.created` | workspace / `{ name, slug }` |
225
+ * | `workspace.updated` | `workspace.updated` | workspace / `{ fields }` |
226
+ * | `workspace.archived` | `workspace.archived` | workspace / `{}` |
227
+ * | `workspace.unarchived` | `workspace.unarchived` | workspace / `{}` |
228
+ * | `workspace.deleted` | `workspace.deleted` | workspace / `{ name, slug }` |
229
+ * | `workspace.member_added` | `workspace.member_added` | **user** / `{ workspaceId, email }` |
230
+ * | `workspace.member_removed` | `workspace.member_removed`| **user** / `{ workspaceId, email }` |
231
+ * | `workspace.content_granted`| `workspace.content_granted`| workspace / `{ slug, kind }` |
232
+ * | `workspace.content_revoked`| `workspace.content_revoked`| workspace / `{ slug }` |
233
+ * | `member.invited` | `user.invited` | user / `{ email }` |
234
+ * | `member.invite_resent` | `user.invite_resent` | user / `{ email }` |
235
+ * | `member.password_reset_issued` | `user.password_reset_issued` | user / `{ email }` |
236
+ * | `member.removed` | `user.invite_revoked` | user / `{ email }` |
237
+ * | `member.profile_updated` | `user.profile_updated` | user / `{ name: { from, to } }` |
238
+ * | `member.role_changed` | `user.role_changed` | user / `{ from, to }` |
239
+ * | `member.disabled` | `user.suspended` | user / `null` |
240
+ * | `member.reactivated` | `user.reactivated` | user / `null` |
241
+ * | `user.password_changed` | `user.password_changed` | user / `{ sessionsRevoked }` |
242
+ * | `user.activated` | `user.activated` | user / `null` |
243
+ * | `api_token.created` | `token.created` | api_token / `{ name, scope, workspaceIds, lookupPrefix }` |
244
+ * | `api_token.revoked` | `token.revoked` | api_token / same shape |
245
+ * | `auth.signed_in` | `user.signed_in` | user / `null` |
246
+ * | `auth.signed_out` | `user.signed_out` | user / `null` |
247
+ * | `entry.created` | `entry.created` | content_entry / `{ contentType }` |
248
+ * | `entry.updated` | `entry.updated` | content_entry / `{ contentType, fields }` |
249
+ * | `entry.published` | `entry.published` | content_entry / `{ contentType }` |
250
+ * | `entry.unpublished` | `entry.unpublished` | content_entry / `{ contentType }` |
251
+ * | `entry.deleted` | `entry.deleted` | content_entry / `{ contentType, soft }` |
252
+ * | `entry.restored` | `entry.restored` | content_entry / `{ contentType }` |
253
+ * | `entry.purged` | `entry.purged` | content_entry / `{ contentType }` |
254
+ * | `media.asset.uploaded` | `media.asset.uploaded` | media_asset / payload minus `actor` |
255
+ * | `media.asset.updated` | `media.asset.updated` | media_asset / payload minus `actor` |
256
+ * | `media.asset.moved` | `media.asset.moved` | media_asset / payload minus `actor` |
257
+ * | `media.asset.deleted` | `media.asset.deleted` | media_asset / payload minus `actor` |
258
+ * | `media.folder.created` | `media.folder.created` | media_folder / payload minus `actor` |
259
+ * | `media.folder.renamed` | `media.folder.renamed` | media_folder / payload minus `actor` |
260
+ * | `media.folder.deleted` | `media.folder.deleted` | media_folder / payload minus `actor` |
261
+ *
262
+ * The actor (`actorId`/`actorEmail`) is not here — it rides on the event payload
263
+ * (`attachActor`) and is read uniformly by {@link toAuditRow}.
264
+ */
265
+ const FACET_MAPPERS = {
266
+ 'workspace.created': (e) => workspaceSubject(e, {
267
+ name: nullableString(e.payload.name),
268
+ slug: nullableString(e.payload.slug)
269
+ }),
270
+ 'workspace.updated': (e) => workspaceSubject(e, { fields: e.payload.fields ?? [] }),
271
+ 'workspace.archived': (e) => workspaceSubject(e, {}),
272
+ 'workspace.unarchived': (e) => workspaceSubject(e, {}),
273
+ 'workspace.deleted': (e) => workspaceSubject(e, {
274
+ name: nullableString(e.payload.name),
275
+ slug: nullableString(e.payload.slug)
276
+ }),
277
+ 'workspace.member_added': (e) => membershipSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.WORKSPACE_MEMBER_ADDED),
278
+ 'workspace.member_removed': (e) => membershipSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.WORKSPACE_MEMBER_REMOVED),
279
+ 'workspace.content_granted': (e) => workspaceSubject(e, {
280
+ slug: nullableString(e.payload.slug),
281
+ kind: nullableString(e.payload.kind)
282
+ }),
283
+ 'workspace.content_revoked': (e) => workspaceSubject(e, { slug: nullableString(e.payload.slug) }),
284
+ 'member.invited': (e) => userSubject(e, USER_AUDIT_KINDS.INVITED, {
285
+ email: nullableString(e.payload.email)
286
+ }),
287
+ 'member.invite_resent': (e) => userSubject(e, USER_AUDIT_KINDS.INVITE_RESENT, {
288
+ email: nullableString(e.payload.email)
289
+ }),
290
+ 'member.password_reset_issued': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_RESET_ISSUED, {
291
+ email: nullableString(e.payload.email)
292
+ }),
293
+ 'member.removed': (e) => userSubject(e, USER_AUDIT_KINDS.INVITE_REVOKED, {
294
+ email: nullableString(e.payload.email)
295
+ }),
296
+ 'member.profile_updated': (e) => userSubject(e, USER_AUDIT_KINDS.PROFILE_UPDATED, {
297
+ name: e.payload.name ?? null
298
+ }),
299
+ 'member.role_changed': (e) => userSubject(e, USER_AUDIT_KINDS.ROLE_CHANGED, {
300
+ from: nullableString(e.payload.from),
301
+ to: nullableString(e.payload.to)
302
+ }),
303
+ 'member.disabled': (e) => userSubject(e, USER_AUDIT_KINDS.SUSPENDED, null),
304
+ 'member.reactivated': (e) => userSubject(e, USER_AUDIT_KINDS.REACTIVATED, null),
305
+ // Credential rotation. The kind is passed straight through (identity's
306
+ // event kind and the audit kind are the same string), and `meta` records
307
+ // how many live sessions the change evicted — the number a security review
308
+ // actually wants: "the password changed AND N devices were signed out".
309
+ 'user.password_changed': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_CHANGED, {
310
+ sessionsRevoked: e.payload.sessionsRevoked ?? null
311
+ }),
312
+ // Invite acceptance. `accept-invite.use-case.ts` appends this alongside
313
+ // `auth.signed_in`, and only the sign-in was ever mapped — so the trail
314
+ // recorded that an invited person signed in, but never that the account
315
+ // itself went from `pending` to `active` and gained a credential. Those are
316
+ // different facts and a security review wants the first one.
317
+ 'user.activated': (e) => userSubject(e, USER_AUDIT_KINDS.ACTIVATED, null),
318
+ 'auth.signed_in': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_IN, null),
319
+ 'auth.signed_out': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_OUT, null),
320
+ // External-API bearer tokens. Nothing mapped these before, so minting and
321
+ // revoking a long-lived key to workspace content left the log completely
322
+ // silent (BUG-identity-server-01) — the audit trail could not answer "who
323
+ // issued this credential, when, and scoped to what".
324
+ 'api_token.created': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_CREATED),
325
+ 'api_token.revoked': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_REVOKED),
326
+ // Content publish lifecycle. `content-server` has raised these on the outbox
327
+ // since the entry aggregate was introduced — its own comment anticipated
328
+ // this subscriber — but nothing consumed them, so the log carried no content
329
+ // activity at all.
330
+ 'entry.published': entrySubject,
331
+ 'entry.unpublished': entrySubject,
332
+ // …and the ordinary editing lifecycle, which raised nothing at all until
333
+ // the entry writes moved onto the unit of work. Publishing was the only
334
+ // content action the log could answer for, so an editor could create,
335
+ // rewrite and delete every entry in the product and it stayed silent about
336
+ // the single most frequent action in a CMS.
337
+ 'entry.created': (e) => entrySubject(e),
338
+ // `fields` names what actually changed — the `workspace.updated` shape, and
339
+ // what makes the row a review rather than a bare "someone saved this".
340
+ 'entry.updated': (e) => entrySubject(e, { fields: e.payload.fields ?? [] }),
341
+ // `soft` is the difference between a tombstone the trash can restore and a
342
+ // row that left the table when this committed.
343
+ 'entry.deleted': (e) => entrySubject(e, { soft: e.payload.soft === true }),
344
+ 'entry.restored': (e) => entrySubject(e),
345
+ // The one content action with nothing left behind to inspect afterwards,
346
+ // and therefore the one this row is the only remaining record of.
347
+ 'entry.purged': (e) => entrySubject(e),
348
+ // The media library. Every one of these was raised on the outbox and
349
+ // dropped on the floor: the dispatcher found no subscriber for the kind,
350
+ // stamped the row dispatched, and the audit log stayed silent about every
351
+ // upload, rename, move and deletion in the asset store.
352
+ [MEDIA_AUDIT_KINDS.ASSET_UPLOADED]: mediaAssetSubject,
353
+ [MEDIA_AUDIT_KINDS.ASSET_UPDATED]: mediaAssetSubject,
354
+ [MEDIA_AUDIT_KINDS.ASSET_MOVED]: mediaAssetSubject,
355
+ [MEDIA_AUDIT_KINDS.ASSET_DELETED]: mediaAssetSubject,
356
+ [MEDIA_AUDIT_KINDS.FOLDER_CREATED]: mediaFolderSubject,
357
+ [MEDIA_AUDIT_KINDS.FOLDER_RENAMED]: mediaFolderSubject,
358
+ [MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject
359
+ };
360
+ /**
361
+ * Every event kind the activity subscriber audits — the dispatcher delivers
362
+ * only these to it.
363
+ */
364
+ exports.AUDITED_EVENT_KINDS = Object.keys(FACET_MAPPERS);
365
+ /** Reads the acting user off the event payload (`attachActor`'s `actor` key). */
366
+ function readActor(payload) {
367
+ const actor = payload.actor;
368
+ return {
369
+ id: nullableString(actor?.id),
370
+ email: nullableString(actor?.email)
371
+ };
372
+ }
373
+ /**
374
+ * Maps a domain event to the audit row it should produce, or `null` when the
375
+ * kind is not audited. Pure and DB-free — the safety-net unit test asserts each
376
+ * kind's row equals what the old in-band recorder wrote.
377
+ *
378
+ * Throws {@link UnmappableAuditEventError} when the kind **is** audited but the
379
+ * payload cannot identify its subject. `null` and a throw mean different things
380
+ * on purpose: `null` is "not our event, skip it" and the dispatcher marks the
381
+ * row delivered; a throw is "this should have been audited and cannot be", so
382
+ * the row stays undispatched, is retried with backoff, and finally parks as a
383
+ * dead letter rather than becoming a row nobody can read.
384
+ */
385
+ function toAuditRow(event) {
386
+ const mapper = FACET_MAPPERS[event.kind];
387
+ if (!mapper) {
388
+ return null;
389
+ }
390
+ const facet = mapper(event);
391
+ const actor = readActor(event.payload);
392
+ return {
393
+ id: event.eventId,
394
+ kind: facet.kind,
395
+ subjectType: facet.subjectType,
396
+ subjectId: facet.subjectId,
397
+ actorId: actor.id,
398
+ actorEmail: actor.email,
399
+ meta: facet.meta,
400
+ at: event.occurredAt
401
+ };
402
+ }
@@ -0,0 +1,29 @@
1
+ import { type OnApplicationBootstrap } from '@nestjs/common';
2
+ import { OutboxDispatcher, type Database, type DomainEvent, type DomainEventSubscriber } from '@orthacms/database';
3
+ /**
4
+ * The activity log's outbox subscriber — the audit trail's single live writer.
5
+ * On bootstrap it registers itself with the {@link OutboxDispatcher}, which then
6
+ * delivers every audited domain event (see {@link AUDITED_EVENT_KINDS}) here;
7
+ * {@link handle} maps each to the same `activity_events` row the old in-band
8
+ * `ACTIVITY_RECORDER` wrote.
9
+ *
10
+ * **Idempotent** (delivery is at-least-once): the row's primary key is the
11
+ * source event's id and the insert is `ON CONFLICT DO NOTHING`, so a
12
+ * re-delivered event never double-records.
13
+ */
14
+ export declare class AuditEventSubscriber implements DomainEventSubscriber, OnApplicationBootstrap {
15
+ private readonly db;
16
+ private readonly dispatcher;
17
+ /** The audited event kinds — the dispatcher's delivery allow-list. */
18
+ readonly kinds: readonly string[];
19
+ constructor(db: Database, dispatcher: OutboxDispatcher);
20
+ /** Registers with the dispatcher once the app is up. */
21
+ onApplicationBootstrap(): void;
22
+ /**
23
+ * Maps one delivered event to its audit row and appends it. A kind with no
24
+ * mapping is ignored (defensive — the dispatcher only delivers our
25
+ * {@link kinds}); the `ON CONFLICT DO NOTHING` keeps redelivery a no-op.
26
+ */
27
+ handle(event: DomainEvent): Promise<void>;
28
+ }
29
+ //# sourceMappingURL=audit-event.subscriber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-event.subscriber.d.ts","sourceRoot":"","sources":["../../../../src/lib/activity/infrastructure/audit-event.subscriber.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAEH,gBAAgB,EAChB,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC7B,MAAM,oBAAoB,CAAC;AAI5B;;;;;;;;;;GAUG;AACH,qBACa,oBACT,YAAW,qBAAqB,EAAE,sBAAsB;IAMlC,OAAO,CAAC,QAAQ,CAAC,EAAE;IACrC,OAAO,CAAC,QAAQ,CAAC,UAAU;IAL/B,sEAAsE;IACtE,QAAQ,CAAC,KAAK,oBAAuB;gBAGE,EAAE,EAAE,QAAQ,EAC9B,UAAU,EAAE,gBAAgB;IAGjD,wDAAwD;IACxD,sBAAsB,IAAI,IAAI;IAI9B;;;;OAIG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAUlD"}
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuditEventSubscriber = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const database_1 = require("@orthacms/database");
7
+ const schema_1 = require("../../schema");
8
+ const audit_event_mapping_1 = require("./audit-event-mapping");
9
+ /**
10
+ * The activity log's outbox subscriber — the audit trail's single live writer.
11
+ * On bootstrap it registers itself with the {@link OutboxDispatcher}, which then
12
+ * delivers every audited domain event (see {@link AUDITED_EVENT_KINDS}) here;
13
+ * {@link handle} maps each to the same `activity_events` row the old in-band
14
+ * `ACTIVITY_RECORDER` wrote.
15
+ *
16
+ * **Idempotent** (delivery is at-least-once): the row's primary key is the
17
+ * source event's id and the insert is `ON CONFLICT DO NOTHING`, so a
18
+ * re-delivered event never double-records.
19
+ */
20
+ let AuditEventSubscriber = class AuditEventSubscriber {
21
+ db;
22
+ dispatcher;
23
+ /** The audited event kinds — the dispatcher's delivery allow-list. */
24
+ kinds = audit_event_mapping_1.AUDITED_EVENT_KINDS;
25
+ constructor(db, dispatcher) {
26
+ this.db = db;
27
+ this.dispatcher = dispatcher;
28
+ }
29
+ /** Registers with the dispatcher once the app is up. */
30
+ onApplicationBootstrap() {
31
+ this.dispatcher.register(this);
32
+ }
33
+ /**
34
+ * Maps one delivered event to its audit row and appends it. A kind with no
35
+ * mapping is ignored (defensive — the dispatcher only delivers our
36
+ * {@link kinds}); the `ON CONFLICT DO NOTHING` keeps redelivery a no-op.
37
+ */
38
+ async handle(event) {
39
+ const row = (0, audit_event_mapping_1.toAuditRow)(event);
40
+ if (!row) {
41
+ return;
42
+ }
43
+ await this.db
44
+ .insert(schema_1.activityEvents)
45
+ .values(row)
46
+ .onConflictDoNothing({ target: schema_1.activityEvents.id });
47
+ }
48
+ };
49
+ exports.AuditEventSubscriber = AuditEventSubscriber;
50
+ exports.AuditEventSubscriber = AuditEventSubscriber = tslib_1.__decorate([
51
+ (0, common_1.Injectable)(),
52
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
53
+ tslib_1.__metadata("design:paramtypes", [Object, database_1.OutboxDispatcher])
54
+ ], AuditEventSubscriber);
@@ -0,0 +1,55 @@
1
+ import { type Database } from '@orthacms/database';
2
+ import type { ActivityExecutor, ActivityRecorder, ActivityRecordInput } from '@orthacms/identity-server';
3
+ import type { ListActivityQueryDto } from '../dto/list-activity-query.dto';
4
+ import type { ActivityListView } from '../types/activity-view';
5
+ /**
6
+ * Owns the audit trail: appends events (`record`) and serves the paginated
7
+ * read API (`list`). Implements {@link ActivityRecorder} so foundational
8
+ * plugins can record via the `ACTIVITY_RECORDER` token without depending on
9
+ * this package. Uses the shared Drizzle client directly (no repository
10
+ * wrapper, by repo convention).
11
+ */
12
+ export declare class ActivityService implements ActivityRecorder {
13
+ private readonly db;
14
+ constructor(db: Database);
15
+ /**
16
+ * Appends one audit row. Pass `executor = tx` to record **in-band** with a
17
+ * mutation, so the audit row commits iff the mutation does; defaults to the
18
+ * root client. `at` defaults to now; `actorId`/`actorEmail`/`meta` are
19
+ * nullable.
20
+ *
21
+ * @deprecated Wave 3 moved auditing onto the outbox `AuditEventSubscriber`,
22
+ * the single live writer. This method (and the `ACTIVITY_RECORDER` binding)
23
+ * is retained only for a stable public surface — no caller writes through it
24
+ * anymore. Emit a domain event and let the subscriber record it instead.
25
+ */
26
+ record(input: ActivityRecordInput, executor?: ActivityExecutor): Promise<void>;
27
+ /**
28
+ * One page of events matching the filters, with a stable order: the
29
+ * whitelisted sort column (default `at desc`) plus an `id` tiebreaker so
30
+ * paging is deterministic across equal timestamps. `created_at` is never
31
+ * selected — the immutable write time stays off the wire.
32
+ */
33
+ list(query: ListActivityQueryDto): Promise<ActivityListView>;
34
+ /**
35
+ * The full `where` for the list: the structured params (`listPredicate`)
36
+ * AND-ed with the optional query-builder `?filter=` tree. The filter is
37
+ * parsed and translated against {@link ACTIVITY_FILTER_SCHEMA}; a malformed
38
+ * filter throws a `FilterException` (HTTP 400). `and(undefined, …)`
39
+ * collapses cleanly, so an unfiltered list still scans everything.
40
+ */
41
+ private listWhere;
42
+ /**
43
+ * The structured-param `where` for the list: every supplied filter
44
+ * intersected (AND). `and(undefined, …)` collapses to no filter, so an
45
+ * unfiltered list scans everything.
46
+ */
47
+ private listPredicate;
48
+ /**
49
+ * Case-insensitive substring match on the actor email snapshot, or
50
+ * `undefined` for no filter. LIKE metacharacters in the needle are escaped
51
+ * so a literal `%`/`_` search behaves literally.
52
+ */
53
+ private actorEmailPredicate;
54
+ }
55
+ //# sourceMappingURL=activity.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity.service.d.ts","sourceRoot":"","sources":["../../../../src/lib/activity/services/activity.service.ts"],"names":[],"mappings":"AAYA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEnE,OAAO,KAAK,EACR,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACtB,MAAM,2BAA2B,CAAC;AAInC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,KAAK,EAER,gBAAgB,EACnB,MAAM,wBAAwB,CAAC;AAQhC;;;;;;GAMG;AACH,qBACa,eAAgB,YAAW,gBAAgB;IACtB,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,QAAQ;IAE3D;;;;;;;;;;OAUG;IACG,MAAM,CACR,KAAK,EAAE,mBAAmB,EAC1B,QAAQ,GAAE,gBAA0B,GACrC,OAAO,CAAC,IAAI,CAAC;IAYhB;;;;;OAKG;IACG,IAAI,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8ClE;;;;;;OAMG;YACW,SAAS;IAWvB;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;CAQ9B"}