feat(admin): GET /admin/projects — authoritative project inventory

Expose the full `(workspace, project)` list (with page counts + last-updated,
reusing `list_projects_with_stats`) as a read-only admin endpoint.

Useful to any external consumer that needs the live project inventory — a
dashboard, an export, or a backup/mirror that lays the wiki out as
`wiki/<workspace>/<project>/` and wants to reconcile against it: any directory
whose project no longer appears here is an orphan (e.g. from a delete that
bypassed the admission chain, such as an offline `--data-dir` purge or a
direct DB edit) and can be pruned so the copy reflects the live server.

Test: seeds two (workspace, project) scopes and asserts both surface in the
`projects` array.
This commit is contained in:
Djalma Júnior
2026-07-13 12:42:33 -03:00
parent 0d9e659339
commit 70f97c728f
2 changed files with 88 additions and 0 deletions
+20
View File
@@ -8,6 +8,7 @@
//! - `POST /admin/auto-improve/report` — read-only auto-improve telemetry report.
//! - `POST /admin/curator` — dry-run or stage a rule-based curator report.
//! - `GET /admin/status` — lifetime counts + server data-dir info.
//! - `GET /admin/projects` — authoritative `(workspace, project)` list.
//! - `GET /admin/search?q=` — FTS5 hits against the wiki index.
//! - `POST /admin/reorg` — retro-fit sessions to per-cwd projects.
//! - `POST /admin/lint` — run the M8 lint pass.
@@ -523,6 +524,7 @@ pub fn admin_router(state: AdminState) -> Router {
post(handle_pending_write_reject),
)
.route("/admin/status", get(handle_status))
.route("/admin/projects", get(handle_list_projects))
.route(
"/admin/audit-contamination",
get(handle_audit_contamination),
@@ -752,6 +754,24 @@ pub struct StatusReport {
pub providers: ProviderHealthSnapshot,
}
/// `GET /admin/projects` — the authoritative list of `(workspace, project)`
/// pairs (with page counts + last-updated), read-only. Useful to any external
/// consumer that needs the live project inventory: a dashboard, an export, or
/// a backup/mirror that lays the wiki out as `wiki/<workspace>/<project>/` and
/// wants to reconcile against it — any directory whose project no longer
/// appears here is an orphan (e.g. from a delete that bypassed the admission
/// chain, such as an offline `--data-dir` purge or a direct DB edit) and can
/// be pruned so the copy reflects the live server.
async fn handle_list_projects(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
match state.reader.list_projects_with_stats().await {
Ok(projects) => (
StatusCode::OK,
Json(serde_json::json!({ "projects": projects })),
),
Err(e) => internal_err(e.to_string()),
}
}
async fn handle_status(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
match state.reader.status_counts().await {
Ok(counts) => match state.reader.derived_index_status().await {
@@ -121,6 +121,74 @@ async fn status_returns_counts_and_paths() {
);
}
#[tokio::test]
async fn list_projects_returns_workspace_project_pairs() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_admin_state(&tmp).await;
// Seed pages in two distinct (workspace, project) scopes.
seed_page(&store, "A", "notes/a.md", "body a").await; // default/scratch
let ws = store
.writer
.get_or_create_workspace("acme".to_string())
.await
.unwrap();
let proj = store
.writer
.get_or_create_project(ws, "infra".to_string(), None)
.await
.unwrap();
store
.writer
.upsert_page(NewPage {
workspace_id: ws,
project_id: proj,
path: PagePath::new("notes/b.md").unwrap(),
title: "B".into(),
body: "body b".into(),
tier: Tier::Semantic,
frontmatter_json: serde_json::json!({}),
pinned: false,
links: Vec::new(),
author_id: None,
})
.await
.unwrap();
let app = admin_router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/projects")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(&body_bytes(resp).await).unwrap();
let pairs: Vec<(String, String)> = body["projects"]
.as_array()
.expect("projects array")
.iter()
.map(|p| {
(
p["workspace_name"].as_str().unwrap().to_string(),
p["project_name"].as_str().unwrap().to_string(),
)
})
.collect();
assert!(
pairs.contains(&("default".to_string(), "scratch".to_string())),
"{pairs:?}"
);
assert!(
pairs.contains(&("acme".to_string(), "infra".to_string())),
"{pairs:?}"
);
}
#[tokio::test]
async fn search_returns_matching_hits() {
let tmp = TempDir::new().unwrap();