You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 48a9d9d
Browse filesBrowse the repository at this point in the historyBrowse files
fix(app-shell): a member takes back their own comment reaction through the unreact door (#26384)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9TSbb9RiQLSUNmAoRYsMSCQ)_Fixes#26346
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么**:记录动态里点自己的回应撤回时,改走服务端新开的「撤回自己回应」专用接口 `DELETE
/api/v1/comments/reactions/:id`,不再走通用数据删除接口;撤回按钮的权限判断不再要求 `delete` 授权。
- **为什么改**:普通成员(只有全员默认授权、没有职位)能加回应却永远撤不回:通用删除要对象级 `delete` 授权,而全员授权集按
ADR-0090 D5 不能带它。服务端那一半已在 PR #26345 落地。
- **风险与回滚**:风险低,只动回应芯片的撤回一条写路径和地图里一行;别人的回应仍然不会出现撤回(点了是加自己的)。回滚即 revert 本
PR,旧路径回来,普通成员重新撤不回。
- **先看**:① `ui/packages/app-shell/src/views/RecordDetailView.tsx` 的
`handleToggleReaction` 里 `write` 的撤回分支;②
`ui/packages/core/src/utils/affordanceGrants.ts` 的 `feedReactionRemove`
行;③ `RecordDetailView.feedGrant-12082.test.tsx` 新增的 describe 块。
- **两分钟验证**:`pnpm --dir ui exec vitest run
packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx`,预期全绿,其中
"a member with no grant beyond create on sys_comment_reaction adds a
reaction and takes it back" 断言请求打到
`/api/v1/comments/reactions/new-row`、方法 DELETE。
- **需要你做的**:无,席位落地。
## What changes
- **The take-back's door.** On the records store, a click on the
caller's own reaction chip now removes the row through the unreact door,
`DELETE /api/v1/comments/reactions/:id` (mounted by
`@objectstack/plugin-audit`, server half in #26345), called through the
page's existing authenticated fetch and `VITE_SERVER_URL` base, the way
the record page already reaches non-data routes. No body and no query
parameter are sent. A non-2xx answer is a refused write: the existing
settle step puts the reaction back and raises the one "Your reaction was
not saved" toast. The generic `DELETE
/api/v1/data/sys_comment_reaction/:id` is no longer called from the
feed. No `@objectstack/client` method is added.
- **The affordance row.** `feedReactionRemove` in `AFFORDANCE_GRANTS`
changes value from `{ crud: 'delete', grant: 'delete' }` to `{ crud:
null, grant: 'create' }`. `crud: null` because the write no longer goes
through the object's data door (the same meaning the attachment rows
carry); `grant: 'create'` because the take-back is offered to whoever
may react. The row's TYPE keeps its shape (`AffordanceGrantRow`, same
two keys) and `ConsoleAffordance` keeps the same member set; no export
is added or removed.
- **Mine.** Which chip offers a take-back is unchanged: the click
handler already takes back only when the caller's id is among the
emoji's reactors, and removes only the row id the read or the create
recorded for the caller (`groupReactionRows` keys on the row's
server-stamped `user_id`). A click on another member's chip adds the
caller's own reaction.
- **Docs.** `ui/content/docs/plugins/plugin-detail.mdx` no longer says a
take-back needs the delete grant.
- **sdui record.** None. The per-package records were retired by #26367
while this PR was open. The main merge took the modify/delete with `git
rm`, and CI's Build & E2E UI regenerates the manifest
(`sdui.manifest.json` unchanged: 106 components).
## Tests
New regression cases (in `RecordDetailView.feedGrant-12082.test.tsx`,
describe "a take-back goes through the unreact door, and only on the
caller's own reaction"):
- a member with only the create grant on `sys_comment_reaction` adds 🎉
and clicks it again: one call to `/api/v1/comments/reactions/new-row`
with `{ method: 'DELETE', credentials: 'include' }`, no data-door
delete, and the chip goes away;
- another member's 👍 chip: the click creates the caller's own row, and
the unreact door is never called;
- the door answers 403: the reaction is put back (count 1) and
`toast.error` is raised once.
Forced test edits:
1.
`ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx`
— the authenticated-fetch mock now returns a hoisted mock (it returned a
bare `vi.fn()`); "a take-back asks the delete grant … writes nothing"
becomes "a take-back reads no delete grant … removes it through the
unreact door"; the full-grant CONTROL and the registered-schema case
assert the door's URL instead of
`dataSource.delete(sys_comment_reaction, r1)`.
2. `ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx` —
`EXPECTED_GRANT.feedReactionRemove` is `'create'`; the write-census
entry `RecordDetailView > handleToggleReaction > write :: delete` is
deleted, since that site no longer calls a `DataSource` write (the
census states it does not see a write through its own route, the same as
the attachment upload).
3.
`ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx`
— not named in the dispatch; forced because its fake server asserted the
take-back as `delete(sys_comment_reaction, id)`. The fake server now
also models the unreact door per member (removes the member's own row,
`403` on anyone else's), and the two take-back cases assert the removed
ids at the door and that `delete` is not called.
## Acceptance notes
- The door authorizes on the row's `created_by`; the chip decides "mine"
on the row's `user_id`. Both are stamped server-side from the reacting
session at create (the create hook stamps `user_id`, the engine stamps
`created_by`), so they name the same member for every row made through
the data door. Keying the chip on `created_by` was not done here (it
would move the read's grouping too); noted for the seat.
- The `feedReactionRemove` row now asks the same grant as
`feedReactionAdd`, and the reaction control is only handed over when
`feedReactionAdd` allows it, so on the records store the take-back is in
practice decided by ownership alone, as the card asks.
## Verification
<details><summary>Tests and ablation</summary>
Commands and readings are in the os-dev-report on #26346, taken at this
PR's head.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(app-shell): a member takes back their own comment reaction through the unreact door, with no object delete grant
7
+
8
+
A member whose grants are the every-member ones could add a reaction on the record feed and never take it back. The reaction chip removed through the generic data door's `DELETE /api/v1/data/sys_comment_reaction/:id`, which needs an object-level `delete` grant that no every-member set may carry (ADR-0090 D5), and the `feedReactionRemove` row of the affordance-to-grant map read that grant, so the chip offered no take-back at all.
9
+
10
+
Clause-②: no
11
+
Metadata-protocol: none
12
+
13
+
- The record page now removes the caller's own reaction row through the unreact door, `DELETE /api/v1/comments/reactions/:id`, which `@objectstack/plugin-audit` mounts. The door removes only the caller's own row; a refused take-back puts the reaction back on screen and says it was not saved, as before.
14
+
-`feedReactionRemove` in `@object-ui/core`'s `AFFORDANCE_GRANTS` now reads `{ crud: null, grant: 'create' }`: its write no longer goes through the data door, so no CRUD bit is read, and it asks the grant the reaction was added under. Whose reaction a chip shows still decides which chips offer the take-back: a click on another member's reaction adds the caller's own.
15
+
- A member who holds the `delete` grant sees the same behaviour: the take-back goes through the same door.
@@ -332,7 +361,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
332
361
expect(isOwn(again,'👍')).toBe(true);
333
362
});
334
363
335
-
it('a second click removes it: the clicker deletes their own row, by the id the create returned and by the id the read returned',async()=>{
364
+
it('a second click removes it: the clicker removes their own row through the unreact door, by the id the create returned and by the id the read returned',async()=>{
0 commit comments