diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 31e145b..5f6b3e9 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -47,11 +47,13 @@ mod common; pub mod issues; +pub mod news; pub mod projects; pub mod roles; pub mod taxonomy; pub mod time_entries; pub mod users; +pub mod wiki_pages; pub use common::{ encode_path_segment, html_escape, identifier_to_u64, record_id_to_u64, wrap_in_doc, AppState, diff --git a/crates/rm-handlers/src/news.rs b/crates/rm-handlers/src/news.rs new file mode 100644 index 0000000..0a52e50 --- /dev/null +++ b/crates/rm-handlers/src/news.rs @@ -0,0 +1,235 @@ +//! **W6a** — News (`project_news` codebook id `0x0114`). +//! +//! Routes: +//! - `GET /news` — list (Title + Summary) +//! - `GET /news/:id` — detail (record-keyed URL like W1's Issue; +//! News doesn't have a slug field — Redmine's `/news/:id` uses +//! the numeric primary key) + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::NewsRow; +use surrealdb_types::{RecordId, ToSql}; + +use crate::common::{html_escape, record_id_to_u64, wrap_in_doc, AppState, HandlerError}; + +/// `GET /news` — render the news list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let news = state.store.list_news().await?; + let cols = list_columns(); + let hrefs: Vec = news + .iter() + .map(|n| { + n.id.as_ref() + .map(|rid| format!("/news/{}", rid.key.to_sql())) + .unwrap_or_default() + }) + .collect(); + let ids: Vec = news + .iter() + .map(|n| n.id.as_ref().map(record_id_to_u64).unwrap_or(0)) + .collect(); + let rows: Vec> = news + .iter() + .enumerate() + .map(|(idx, n)| RowSource { + record_id: ids[idx], + css_classes: "news", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &n.title, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { value: &n.summary }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list("News", 0x0114, "project_news", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("News", &body))) +} + +/// `GET /news/:id` — render a single news entry's detail page. +pub async fn detail( + State(state): State, + Path(id_str): Path, +) -> Result, HandlerError> { + let rid = RecordId::new("news", id_str.as_str()); + let entry: NewsRow = state.store.find_news(&rid).await?; + let cols = detail_columns(); + let href = format!("/news/{}", id_str); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&entry.title) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &entry.title, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &entry.summary, + }, + }, + CellSource { + column: &cols[2], + css_classes: "", + data: CellData::Plain { + value: &entry.description, + }, + }, + ]; + let body = render_detail( + 0x0114, + "project_news", + record_id_to_u64(&rid), + &headline, + &entry.summary, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&entry.title, &body))) +} + +fn list_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("title", "Title", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("summary", "Summary", ColumnKind::Plain), + ] +} + +fn detail_columns() -> [RenderColumn; 3] { + [ + RenderColumn::new("title", "Title", ColumnKind::PrimaryLink), + RenderColumn::new("summary", "Summary", ColumnKind::Plain), + RenderColumn::new("description", "Description", ColumnKind::Plain), + ] +} + +/// Build the News router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/news", get(list)) + .route("/news/:id", get(detail)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewNews, Store}; + use tower::ServiceExt; + + #[tokio::test] + async fn list_renders_empty_state() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot(Request::builder().uri("/news").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0114\"")); + assert!(s.contains("No data.")); + } + + #[tokio::test] + async fn list_renders_seeded_news() { + let store = Store::open().await.unwrap(); + store + .create_news(NewNews { + title: "Release 0.1".to_string(), + summary: "MVP ships".to_string(), + description: "Browse + create + auth.".to_string(), + }) + .await + .unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot(Request::builder().uri("/news").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("Release 0.1")); + assert!(s.contains("MVP ships")); + assert!(s.contains("href=\"/news/")); + } + + #[tokio::test] + async fn detail_renders_by_record_key() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_news(NewNews { + title: "Detail target".to_string(), + summary: "sub".to_string(), + description: "long body".to_string(), + }) + .await + .unwrap(); + let key = inserted.id.unwrap().key.to_sql(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri(format!("/news/{key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("Detail target")); + assert!(s.contains("long body")); + assert!(s.contains("data-class-id=\"0x0114\"")); + } + + #[tokio::test] + async fn detail_404_for_unknown_id() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/news/missing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-handlers/src/wiki_pages.rs b/crates/rm-handlers/src/wiki_pages.rs new file mode 100644 index 0000000..c27efce --- /dev/null +++ b/crates/rm-handlers/src/wiki_pages.rs @@ -0,0 +1,207 @@ +//! **W6b** — WikiPage (`project_wiki_page` codebook id `0x010C`). +//! +//! Routes: +//! - `GET /wiki` — list (Title) +//! - `GET /wiki/:title` — detail (Redmine convention: +//! `/projects/:proj/wiki/:title`; we flatten to `/wiki/:title` at +//! the top level for MVP since multi-project nesting lands as a +//! W2-followup once Member binds Project ↔ User) + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::WikiPageRow; + +use crate::common::{ + encode_path_segment, html_escape, identifier_to_u64, wrap_in_doc, AppState, HandlerError, +}; + +/// `GET /wiki` — render the wiki-page list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let pages = state.store.list_wiki_pages().await?; + let cols = list_columns(); + let hrefs: Vec = pages + .iter() + .map(|p| format!("/wiki/{}", encode_path_segment(&p.title))) + .collect(); + let ids: Vec = pages.iter().map(|p| identifier_to_u64(&p.title)).collect(); + let rows: Vec> = pages + .iter() + .enumerate() + .map(|(idx, p)| RowSource { + record_id: ids[idx], + css_classes: "wiki-page", + group: None, + inline: vec![CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &p.title, + href: &hrefs[idx], + }, + }], + block: Vec::new(), + }) + .collect(); + let body = render_list("Wiki", 0x010C, "project_wiki_page", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Wiki", &body))) +} + +/// `GET /wiki/:title` — render a wiki-page detail. +pub async fn detail( + State(state): State, + Path(title): Path, +) -> Result, HandlerError> { + let page: WikiPageRow = state.store.find_wiki_page_by_title(&title).await?; + let cols = detail_columns(); + let href = format!("/wiki/{}", encode_path_segment(&page.title)); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&page.title) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &page.title, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { value: &page.body }, + }, + ]; + let body = render_detail( + 0x010C, + "project_wiki_page", + identifier_to_u64(&page.title), + &headline, + &page.title, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&page.title, &body))) +} + +fn list_columns() -> [RenderColumn; 1] { + [RenderColumn::new("title", "Title", ColumnKind::PrimaryLink) + .sortable() + .frozen()] +} + +fn detail_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("title", "Title", ColumnKind::PrimaryLink), + RenderColumn::new("body", "Body", ColumnKind::Plain), + ] +} + +/// Build the WikiPage router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/wiki", get(list)) + .route("/wiki/:title", get(detail)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewWikiPage, Store}; + use tower::ServiceExt; + + #[tokio::test] + async fn list_renders_empty_state() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot(Request::builder().uri("/wiki").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x010C\"")); + assert!(s.contains("No data.")); + } + + #[tokio::test] + async fn detail_by_title_renders() { + let store = Store::open().await.unwrap(); + store + .create_wiki_page(NewWikiPage { + title: "Home".to_string(), + body: "Welcome to the wiki.".to_string(), + }) + .await + .unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/wiki/Home") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("Home")); + assert!(s.contains("Welcome to the wiki.")); + assert!(s.contains("data-class-id=\"0x010C\"")); + } + + #[tokio::test] + async fn detail_404_for_unknown_title() { + let store = Store::open().await.unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri("/wiki/Nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn list_percent_encodes_titles_with_reserved_chars() { + let store = Store::open().await.unwrap(); + store + .create_wiki_page(NewWikiPage { + title: "A/B testing".to_string(), + body: String::new(), + }) + .await + .unwrap(); + let app = router(AppState { store }); + let res = app + .oneshot(Request::builder().uri("/wiki").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!( + s.contains("href=\"/wiki/A%2FB%20testing\""), + "expected percent-encoded title in href:\n{s}" + ); + } +} diff --git a/crates/rm-server/src/router.rs b/crates/rm-server/src/router.rs index 96933ef..d548925 100644 --- a/crates/rm-server/src/router.rs +++ b/crates/rm-server/src/router.rs @@ -82,11 +82,13 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router { // ── W* width tracks — keep merge calls alphabetised on the URL // path so parallel branches don't conflict on this file. ── .merge(rm_handlers::issues::router(state.clone())) // W1: /issues + .merge(rm_handlers::news::router(state.clone())) // W6a: /news .merge(rm_handlers::projects::router(state.clone())) // W2: /projects .merge(rm_handlers::roles::router(state.clone())) // W4b: /roles .merge(rm_handlers::taxonomy::router(state.clone())) // W5: /issue_statuses + /trackers + /enumerations/issue_priorities .merge(rm_handlers::time_entries::router(state.clone())) // W3: /time_entries .merge(rm_handlers::users::router(state.clone())) // W4a: /users + .merge(rm_handlers::wiki_pages::router(state.clone())) // W6b: /wiki // ── Phase-0 auxiliary surfaces ── .merge(rm_auth::router(auth_cfg)) // /login, /logout, /me .layer(CookieManagerLayer::new()) diff --git a/crates/rm-store/src/lib.rs b/crates/rm-store/src/lib.rs index b662b12..37702b9 100644 --- a/crates/rm-store/src/lib.rs +++ b/crates/rm-store/src/lib.rs @@ -47,15 +47,18 @@ mod error; mod issue; +mod news; mod project; mod role; mod store; mod taxonomy; mod time_entry; mod user; +mod wiki_page; pub use error::StoreError; pub use issue::{IssueRow, NewIssue}; +pub use news::{NewNews, NewsRow}; pub use project::{NewProject, ProjectRow}; pub use role::{NewRole, RoleRow}; pub use store::Store; @@ -64,3 +67,4 @@ pub use taxonomy::{ }; pub use time_entry::{NewTimeEntry, TimeEntryRow}; pub use user::{NewUser, UserRow}; +pub use wiki_page::{NewWikiPage, WikiPageRow}; diff --git a/crates/rm-store/src/news.rs b/crates/rm-store/src/news.rs new file mode 100644 index 0000000..1992e00 --- /dev/null +++ b/crates/rm-store/src/news.rs @@ -0,0 +1,108 @@ +//! News (`project_news` codebook id `0x0114`) — project-scoped +//! announcement / changelog entries. +//! +//! W6 of the Redmine Integration Plan, first comms-track concept. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_news`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewNews { + /// Headline (renders as the link label on the list page). + pub title: String, + /// One-line summary shown next to the title. + pub summary: String, + /// Long-form body (markdown — rendered as Plain today, RichText + /// once D1 forms + a markdown renderer land). + pub description: String, +} + +/// Row returned by [`Store::find_news`] / [`Store::list_news`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewsRow { + /// SurrealDB record id (`news:`). + pub id: Option, + /// Headline. + pub title: String, + /// One-line summary. + pub summary: String, + /// Long-form body. + pub description: String, +} + +impl Store { + /// Insert a News entry. + pub async fn create_news(&self, new: NewNews) -> Result { + let row: Option = self.db().create("news").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Read a News entry by its SurrealDB record id. + pub async fn find_news(&self, id: &RecordId) -> Result { + match self.db().select(id.clone()).await { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every News entry, in insertion order. + pub async fn list_news(&self) -> Result, StoreError> { + match self.db().select::>("news").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_then_find_round_trips() { + let store = Store::open().await.unwrap(); + let new = NewNews { + title: "Release 0.1".to_string(), + summary: "First MVP cut".to_string(), + description: "Browse + create + auth landed.".to_string(), + }; + let inserted = store.create_news(new.clone()).await.unwrap(); + assert_eq!(inserted.title, new.title); + let id = inserted.id.clone().unwrap(); + let fetched = store.find_news(&id).await.unwrap(); + assert_eq!(fetched.title, new.title); + assert_eq!(fetched.summary, new.summary); + } + + #[tokio::test] + async fn find_returns_not_found() { + let store = Store::open().await.unwrap(); + let id = RecordId::new("news", "missing"); + assert!(matches!( + store.find_news(&id).await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_news().await.unwrap().is_empty()); + for i in 0..2 { + store + .create_news(NewNews { + title: format!("entry {i}"), + summary: String::new(), + description: String::new(), + }) + .await + .unwrap(); + } + assert_eq!(store.list_news().await.unwrap().len(), 2); + } +} diff --git a/crates/rm-store/src/wiki_page.rs b/crates/rm-store/src/wiki_page.rs new file mode 100644 index 0000000..df8580a --- /dev/null +++ b/crates/rm-store/src/wiki_page.rs @@ -0,0 +1,111 @@ +//! WikiPage (`project_wiki_page` codebook id `0x010C`) — versioned +//! page content. The canonical class declares only `title`; the +//! body lives in a separate `WikiContent` table in Redmine + OP. +//! MVP collapses both into one row with a `body` field; versioning +//! lands when D-something history tracking ships. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_wiki_page`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewWikiPage { + /// URL-safe page title (Redmine convention: title doubles as + /// slug). + pub title: String, + /// Page body (Redmine wiki markup — Textile / Markdown depending + /// on per-project setting; rendered as Plain today). + pub body: String, +} + +/// Row returned by [`Store::find_wiki_page_by_title`] / [`Store::list_wiki_pages`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct WikiPageRow { + /// SurrealDB record id. + pub id: Option, + /// Title (URL slug). + pub title: String, + /// Page body. + pub body: String, +} + +impl Store { + /// Insert a WikiPage. + pub async fn create_wiki_page(&self, new: NewWikiPage) -> Result { + let row: Option = self.db().create("wiki_page").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a WikiPage by title (URL slug). + pub async fn find_wiki_page_by_title(&self, title: &str) -> Result { + match self + .db() + .query("SELECT * FROM wiki_page WHERE title = $t LIMIT 1") + .bind(("t", title.to_string())) + .await + { + Ok(mut res) => match res.take::>(0) { + Ok(rows) => rows.into_iter().next().ok_or(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + }, + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every WikiPage. + pub async fn list_wiki_pages(&self) -> Result, StoreError> { + match self.db().select::>("wiki_page").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_then_find_by_title() { + let store = Store::open().await.unwrap(); + store + .create_wiki_page(NewWikiPage { + title: "Home".to_string(), + body: "Welcome.".to_string(), + }) + .await + .unwrap(); + let r = store.find_wiki_page_by_title("Home").await.unwrap(); + assert_eq!(r.title, "Home"); + assert_eq!(r.body, "Welcome."); + } + + #[tokio::test] + async fn find_returns_not_found() { + let store = Store::open().await.unwrap(); + assert!(matches!( + store.find_wiki_page_by_title("Nope").await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_wiki_pages().await.unwrap().is_empty()); + for t in ["Home", "Guide"] { + store + .create_wiki_page(NewWikiPage { + title: t.to_string(), + body: String::new(), + }) + .await + .unwrap(); + } + assert_eq!(store.list_wiki_pages().await.unwrap().len(), 2); + } +}