forked from EduCraft/curriculum-project-hub
Merge branch 'maoyuanyang-main'
# Conflicts: # hub/filelib-web/src/lib/GrantsPanel.svelte # hub/filelib-web/src/lib/OverviewPanel.svelte # hub/filelib-web/src/lib/types.ts # hub/src/database/filelib/grantService.ts
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
|
||||
since deleted; recoverable from git history) which introduced the per-project
|
||||
"独立权限" (independent permission) switch.
|
||||
|
||||
## Context
|
||||
|
||||
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
|
||||
default off). While off, project-level non-creator grants were **frozen** — present in
|
||||
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
|
||||
auto-grant were unaffected. The intent was to support two workflows: "project follows the
|
||||
folder's ACL" (off) vs "project has its own ACL" (on).
|
||||
|
||||
In practice the toggle surprised operators twice: grants appeared to "not work" until
|
||||
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
|
||||
was indistinguishable from missing grants in the UI. The product decision is that
|
||||
project-level grants should simply always be live.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
|
||||
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
|
||||
carries `independentPermissionsEnabled`.
|
||||
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
|
||||
`grantService.setIndependentPermission`, the `independentPermission` field in the node
|
||||
detail DTO, and the 概览 tab switch in `filelib-web`.
|
||||
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
|
||||
ignored, no data migration); new projects no longer get a default row. It may be dropped
|
||||
in a future migration once nothing references it.
|
||||
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
|
||||
reading historical audit entries; no new entries are produced.
|
||||
|
||||
Behavior change for existing deployments: projects whose toggle was off now have their
|
||||
project-level grants effective immediately — this is the intended effect of the decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪
|
||||
ancestors for user ∪ resolved groups)`, no exceptions by node kind.
|
||||
- One less state dimension in tests and in the admin UI.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
|
||||
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
|
||||
|
||||
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
|
||||
a node is invisible when any ancestor is deleted) but never defined listing, restore,
|
||||
or permanent deletion.
|
||||
- **最近打开 (recent visits)**: nothing tracks opens.
|
||||
|
||||
## Decision
|
||||
|
||||
### Recycle bin
|
||||
|
||||
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
|
||||
(the topmost deleted node per branch; descendants of a deleted node are represented
|
||||
by it and not listed separately).
|
||||
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
|
||||
(b) any actor holding an active MANAGE grant **on the deleted node itself**
|
||||
(grants stay live through soft delete, so this is a plain grant query — no chain
|
||||
walk, no inheritance; the bin is a management surface, not a browsing surface).
|
||||
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
|
||||
node, restore unmarks one node). The subtree becomes visible again immediately.
|
||||
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
|
||||
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
|
||||
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
|
||||
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
|
||||
transaction, with one audit entry (`node_purge`, detail carries removed count).
|
||||
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
|
||||
explicitly.
|
||||
|
||||
### Recent visits
|
||||
|
||||
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
|
||||
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
|
||||
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
|
||||
visit is the node itself (drill into folder/project); non-empty means a file preview
|
||||
inside that project.
|
||||
- **Recording is client-driven**: the teacher app POSTs after a successful open
|
||||
(folder drill, project open, file preview). The endpoint requires VIEW on the node
|
||||
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
|
||||
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
|
||||
mutation.
|
||||
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
|
||||
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
|
||||
on every surface). Names are read live from `FileLibNode` (no denormalization).
|
||||
|
||||
## Consequences
|
||||
|
||||
- No change to existing permission algebra; both features are additive surfaces.
|
||||
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
|
||||
if real usage demands it, that is a new decision.
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0032: Remove The Recent-Visit Module
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
|
||||
is unaffected and remains in force).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
|
||||
visit recording, and a rail entry in the teacher app. After seeing it live, the product
|
||||
call is that the module is not wanted — it adds a tracking surface, a table, and rail
|
||||
noise without a compelling teacher workflow behind it.
|
||||
|
||||
## Decision
|
||||
|
||||
The recent-visit module is removed end-to-end:
|
||||
|
||||
- `FileLibRecentVisit` is dropped (hand-written migration
|
||||
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
|
||||
held no production data).
|
||||
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
|
||||
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
|
||||
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
|
||||
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
|
||||
cheap, additive field on an existing API and independent of the removed module.
|
||||
|
||||
If recent-visit tracking comes back as a requirement, it is a new decision (and
|
||||
should then define why client-driven tracking is worth its surface) rather than a
|
||||
revival of this one.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
|
||||
the case where a same-name sibling was created **after** the deletion: D14's partial
|
||||
unique index (active siblings, case-insensitive) then rejects the restore with a 409
|
||||
`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
|
||||
non-admin users (who cannot purge) and cryptic for admins.
|
||||
|
||||
## Decision
|
||||
|
||||
Restore never fails on a name conflict. Before clearing `deletedAt`, the service
|
||||
checks active siblings; if the node's name is taken, it restores as
|
||||
`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
|
||||
in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
|
||||
the same transaction and is recorded in the restore audit entry as
|
||||
`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
|
||||
|
||||
Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
|
||||
trap, not a safeguard. Users who care about the name can rename afterwards (they have
|
||||
MANAGE by definition of bin visibility).
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
|
||||
website-administrator only".
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
|
||||
precaution. The product call is that this is inconsistent with the rest of the
|
||||
permission model: soft delete already requires only MANAGE on the node, and a
|
||||
MANAGE holder who can delete a node into the bin should also be able to purge it —
|
||||
the authority that grants deletion grants destruction. Admin-only purge strands
|
||||
non-admin managers with bins they cannot empty.
|
||||
|
||||
## Decision
|
||||
|
||||
Permanent delete uses **the same visibility rule as the bin entry itself**: website
|
||||
administrator, or an actor with an active MANAGE grant on the deleted node (direct
|
||||
grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
|
||||
in the UI and the `node.purge` audit entry are unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
|
||||
- The operation remains irreversible and audited; no new capability is granted to
|
||||
anyone who could not already delete the node (soft) and see it in the bin.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR 0035: Restore Keeps The Original Name; Conflict Is A Clear Error
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes ADR-0033** (restore de-duplicates the node name on sibling
|
||||
conflict).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0033 made restore auto-rename to `原名(已恢复)` on sibling name conflict so
|
||||
restore never fails. In practice the suffix is unwanted noise - operators expect the
|
||||
original name back and prefer to resolve conflicts themselves.
|
||||
|
||||
## Decision
|
||||
|
||||
Restore clears `deletedAt` and keeps the node's **original name**. If an active
|
||||
sibling now occupies the same name (D14 partial unique index), the service throws a
|
||||
`409 name_conflict_on_restore` with a human-readable message ("同名节点已存在,请先
|
||||
重命名现有节点再恢复") - no silent renaming, no suffix. The restore audit records
|
||||
the original name only.
|
||||
Reference in New Issue
Block a user