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:
2026-08-03 15:35:44 +08:00
37 changed files with 1926 additions and 273 deletions
@@ -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).
+28
View File
@@ -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.