Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/22566-comment-reaction-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-audit': minor
'@objectstack/spec': minor
---

`sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user

Clause-②: yes (widening)

- **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write.
- **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody.
- **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row.
- **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`.
- **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row.
- **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it.
- **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package.
- **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and reactions stored in it are not converted into records.
3 changes: 2 additions & 1 deletion packages/plugins/plugin-audit/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec';
import { SysAuditLog } from '../src/objects/sys-audit-log.object.js';
import { SysActivity } from '../src/objects/sys-activity.object.js';
import { SysComment } from '../src/objects/sys-comment.object.js';
import { SysCommentReaction } from '../src/objects/sys-comment-reaction.object.js';
import { enObjects } from '../src/translations/en.objects.generated.js';
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
Expand All @@ -60,7 +61,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
* precision: the annotated type is the function's own return type.
*/
const config: ObjectStackDefinition = defineStack({
objects: [SysAuditLog, SysActivity, SysComment] as any,
objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction] as any,
translations: [
{ en: { objects: enObjects } },
{ 'zh-CN': { objects: zhCNObjects } },
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/plugin-audit/src/audit-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => {
sys_audit_log: 'telemetry',
sys_activity: 'telemetry',
sys_comment: 'sqlite',
sys_comment_reaction: 'sqlite',
});
const { ctx, logs, fireReady } = makeCtx(engine);
const plugin = new AuditPlugin();
Expand All @@ -159,6 +160,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => {
expect(placement).toContain('sys_audit_log→telemetry');
expect(placement).toContain('sys_activity→telemetry');
expect(placement).toContain('sys_comment→sqlite');
expect(placement).toContain('sys_comment_reaction→sqlite');

// …and the split itself is called out, because "absent from the database I
// am looking at" is not "never created".
Expand All @@ -176,6 +178,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => {
sys_audit_log: 'sqlite',
sys_activity: 'sqlite',
sys_comment: 'sqlite',
sys_comment_reaction: 'sqlite',
});
const { ctx, logs, fireReady } = makeCtx(engine);
const plugin = new AuditPlugin();
Expand All @@ -196,6 +199,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => {
sys_audit_log: undefined,
sys_activity: 'sqlite',
sys_comment: 'sqlite',
sys_comment_reaction: 'sqlite',
});
const { ctx, logs, fireReady } = makeCtx(engine);
const plugin = new AuditPlugin();
Expand Down
21 changes: 13 additions & 8 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveLocalizationContext } from '@objectstack/core';
import type { IDataEngine, II18nService, ISecurityService, ISharingService } from '@objectstack/spec/contracts';
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
import { SysAuditLog, SysActivity, SysComment, SysCommentReaction } from './objects/index.js';
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
// It has landed, so the contribution moved to @objectstack/service-messaging —
// the service that writes the row on every `emit()` (#4154). This plugin never
Expand Down Expand Up @@ -141,7 +141,8 @@ function createHostLocaleReader(
/**
* AuditPlugin
*
* Registers the sys_audit_log / sys_activity / sys_comment system objects
* Registers the sys_audit_log / sys_activity / sys_comment / sys_comment_reaction
* system objects
* and installs ObjectQL hook subscribers that automatically write audit
* trail + activity stream rows on every data mutation.
*
Expand Down Expand Up @@ -180,7 +181,7 @@ export class AuditPlugin implements Plugin {
scope: 'system',
defaultDatasource: 'cloud',
namespace: 'sys',
objects: [SysAuditLog, SysActivity, SysComment],
objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction],
// ADR-0029 D7 — contribute the Audit Logs entries into the Setup app's
// `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so both
// doors onto it live and die with this plugin and need no item gate.
Expand Down Expand Up @@ -349,6 +350,9 @@ export class AuditPlugin implements Plugin {
// to `enforceFeedsCapability` above, which gates `enable.feeds`, not
// access. The sharing service resolves lazily so plugin order doesn't
// matter; without it the edit checks degrade to parent read visibility.
// [#22566] The same two installers gate `sys_comment_reaction`: a
// reaction is readable exactly when its comment is, and created only on a
// comment the caller can read (`comment-access-hooks.ts`, module header).
if (typeof (engine as any).registerHook === 'function') {
installCommentAccessHooks(
engine as any,
Expand Down Expand Up @@ -388,11 +392,11 @@ export class AuditPlugin implements Plugin {
installCommentReadVisibility(engine as any, ctx.logger);
} else {
ctx.logger.warn(
'AuditPlugin: engine has no middleware seam — sys_comment READ visibility NOT installed ' +
'(comments on records the caller cannot read would be listable)',
'AuditPlugin: engine has no middleware seam — sys_comment and sys_comment_reaction READ visibility NOT ' +
'installed (comments on records the caller cannot read, and their reactions, would be listable)',
);
}
ctx.logger.info('AuditPlugin: sys_comment record-level access gates installed');
ctx.logger.info('AuditPlugin: sys_comment and sys_comment_reaction record-level access gates installed');
}

// sys_activity READ visibility — an activity row is readable when the
Expand Down Expand Up @@ -515,7 +519,8 @@ export class AuditPlugin implements Plugin {
// wholesale" and "provisioning ran fine" produced identical logs. Name
// the consequence, not just the condition.
ctx.logger.warn(
'AuditPlugin: this engine exposes no syncObjectSchema() — sys_audit_log / sys_activity / sys_comment were NOT ' +
'AuditPlugin: this engine exposes no syncObjectSchema() — sys_audit_log / sys_activity / sys_comment / ' +
'sys_comment_reaction were NOT ' +
'provisioned up-front and stay lazy-created on first WRITE. An env that READS one first (the home page ' +
'activity feed queries sys_activity before any mutation) will log "no such table" until something writes to it.',
);
Expand All @@ -533,7 +538,7 @@ export class AuditPlugin implements Plugin {

const placements: string[] = [];
const offDefault: string[] = [];
for (const obj of [SysAuditLog, SysActivity, SysComment]) {
for (const obj of [SysAuditLog, SysActivity, SysComment, SysCommentReaction]) {
try {
await sync.call(engine, obj.name);
} catch (err) {
Expand Down
Loading
Loading