//! §7 M3 read-only projects surface: `GET /api/v1/admin/projects` — every //! project (repo-root) with the count of group nodes it owns. Backs //! `pic projects ls`. Behind the `/admin` middleware, so any authenticated //! admin can list them (a project reveals only org structure, like the groups //! list); ownership WRITES stay in the apply path. use std::sync::Arc; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; use chrono::{DateTime, Utc}; use picloud_shared::Principal; use serde::Serialize; use serde_json::json; use crate::project_repo::{ProjectRepository, ProjectRepositoryError}; #[derive(Clone)] pub struct ProjectsState { pub projects: Arc, } /// Mounted under `/api/v1/admin`. pub fn projects_router(state: ProjectsState) -> Router { Router::new() .route("/projects", get(list_projects)) .with_state(state) } #[derive(Serialize)] struct ProjectListItem { slug: String, name: String, owned_groups: i64, created_at: DateTime, } async fn list_projects( State(s): State, Extension(_principal): Extension, ) -> Result>, ProjectsApiError> { let items = s .projects .list_with_counts() .await? .into_iter() .map(|(p, owned_groups)| ProjectListItem { slug: p.slug, name: p.name, owned_groups, created_at: p.created_at, }) .collect(); Ok(Json(items)) } #[derive(Debug, thiserror::Error)] pub enum ProjectsApiError { #[error("backend: {0}")] Backend(String), } impl From for ProjectsApiError { fn from(e: ProjectRepositoryError) -> Self { Self::Backend(e.to_string()) } } impl IntoResponse for ProjectsApiError { fn into_response(self) -> Response { tracing::error!(error = %self, "projects api error"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } }