use axum::{
Extension,
extract::{Json, Path, Query, State},
};
use crate::authz;
use crate::db::{DbPool, models::*};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::{filter_visible, with_read, with_write};
fn require_page_role(
db: &DbPool,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
project_id: Option<i64>,
min: Role,
) -> Result<(), LificError> {
match project_id {
Some(pid) => authz::require_role(db, identity, pid, min),
None => authz::require_workspace_admin(db, identity),
}
}
fn require_page_role_conn(
conn: &rusqlite::Connection,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
project_id: Option<i64>,
min: Role,
) -> Result<(), LificError> {
authz::require_project_or_workspace_role_conn(conn, identity, project_id, min)
}
#[derive(serde::Deserialize)]
pub(super) struct PageQuery {
project_id: Option<i64>,
folder_id: Option<i64>,
label: Option<String>,
status: Option<String>,
order_by: Option<String>,
order: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
pub(super) async fn list_pages_handler(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Query(q): Query<PageQuery>,
) -> Result<Json<Vec<Page>>, LificError> {
if let Some(pid) = q.project_id {
authz::require_role(&db, &identity, pid, Role::Viewer)?;
return with_read(&db, |conn| {
crate::db::queries::list_pages(
conn,
q.project_id,
q.folder_id,
q.label.as_deref(),
q.status.as_deref(),
q.order_by.as_deref(),
q.order.as_deref(),
q.limit,
q.offset,
)
})
.map(Json);
}
let visible = authz::visible_project_ids(&db, &identity)?;
let pages = with_read(&db, |conn| {
crate::db::queries::list_pages(
conn,
q.project_id,
q.folder_id,
q.label.as_deref(),
q.status.as_deref(),
q.order_by.as_deref(),
q.order.as_deref(),
q.limit,
q.offset,
)
})?;
Ok(Json(filter_visible(pages, &visible, |p| p.project_id)))
}
pub(super) async fn get_page(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
) -> Result<Json<Page>, LificError> {
let page = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?;
require_page_role(&db, &identity, page.project_id, Role::Viewer)?;
Ok(Json(page))
}
pub(super) async fn resolve_page(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(identifier): Path<String>,
) -> Result<Json<Page>, LificError> {
let page = with_read(&db, |conn| {
let id = crate::db::queries::resolve_page_identifier(conn, &identifier)?;
crate::db::queries::get_page(conn, id)
})?;
require_page_role(&db, &identity, page.project_id, Role::Viewer)?;
Ok(Json(page))
}
pub(super) async fn create_page(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Json(input): Json<CreatePage>,
) -> Result<Json<Page>, LificError> {
require_page_role(&db, &identity, input.project_id, Role::Maintainer)?;
let user = super::require_user(&identity)?;
let page = db.transaction(|conn| {
let page = crate::db::queries::create_page(conn, &input)?;
require_page_role_conn(conn, &identity, page.project_id, Role::Maintainer)?;
super::attachments::sync_links_scoped(
conn,
AttachmentEntity::Page,
page.id,
&page.content,
&user,
page.project_id,
)?;
Ok(page)
})?;
if let Some(project_id) = page.project_id {
realtime.send_with_seq(RealtimeEvent::ProjectUpdated { project_id }, page.seq);
}
Ok(Json(page))
}
pub(super) async fn update_page(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Json(input): Json<UpdatePage>,
) -> Result<Json<Page>, LificError> {
let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?.project_id;
require_page_role(&db, &identity, project_id, Role::Maintainer)?;
let user = super::require_user(&identity)?;
let page = db.transaction(|conn| {
let page = crate::db::queries::update_page(conn, id, &input)?;
require_page_role_conn(conn, &identity, page.project_id, Role::Maintainer)?;
super::attachments::sync_links_scoped(
conn,
AttachmentEntity::Page,
page.id,
&page.content,
&user,
page.project_id,
)?;
Ok(page)
})?;
if let Some(project_id) = page.project_id {
realtime.send_with_seq(RealtimeEvent::ProjectUpdated { project_id }, page.seq);
}
Ok(Json(page))
}
pub(super) async fn delete_page_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, LificError> {
let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?.project_id;
require_page_role(&db, &identity, project_id, Role::Maintainer)?;
let seq = with_write(&db, |conn| {
crate::db::queries::delete_page(conn, id)?;
crate::db::queries::page_seq(conn, id)
})?;
if let Some(project_id) = project_id {
realtime.send_with_seq(RealtimeEvent::ProjectUpdated { project_id }, seq);
}
Ok(Json(serde_json::json!({"deleted": true})))
}
pub(super) async fn restore_page_handler(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
) -> Result<Json<Page>, LificError> {
let project_id = with_read(&db, |conn| {
crate::db::queries::deleted_page_project_id(conn, id)
})?;
require_page_role(&db, &identity, project_id, Role::Maintainer)?;
let page = with_write(&db, |conn| crate::db::queries::restore_page(conn, id))?;
if let Some(project_id) = page.project_id {
realtime.send_with_seq(RealtimeEvent::ProjectUpdated { project_id }, page.seq);
}
Ok(Json(page))
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
async fn seed_project_with_labels(app: &axum::Router) -> i64 {
let (project_id, _) = seed_project(app).await;
for (name, color) in [("design", "#22C55E"), ("draft", "#F59E0B")] {
json_post(
app,
"/api/labels",
serde_json::json!({
"project_id": project_id,
"name": name,
"color": color,
}),
)
.await;
}
project_id
}
async fn put(
app: &axum::Router,
uri: &str,
body: serde_json::Value,
) -> axum::response::Response {
app.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(uri)
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
async fn seed_page_with_seq(app: &axum::Router, project_id: i64, title: &str) -> (i64, i64) {
let created = parse_json(
json_post(
app,
"/api/pages",
serde_json::json!({"project_id": project_id, "title": title}),
)
.await,
)
.await;
(
created["id"].as_i64().unwrap(),
created["seq"].as_i64().unwrap(),
)
}
#[tokio::test]
async fn a_stale_expected_seq_is_refused_with_the_current_page() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let (id, stale_seq) = seed_page_with_seq(&app, pid, "Contended").await;
put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"content": "someone else got here first"}),
)
.await;
let resp = put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"content": "clobber", "expected_seq": stale_seq}),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let body = parse_json(resp).await;
assert_eq!(body["code"], "update_conflict");
assert!(
body["error"].as_str().unwrap().contains("TST-DOC-1"),
"the message names the entity: {body}"
);
let fresh = parse_json(json_get(&app, &format!("/api/pages/{id}")).await).await;
assert_eq!(body["current"], fresh);
assert_eq!(fresh["content"], "someone else got here first");
}
#[tokio::test]
async fn a_fresh_expected_seq_updates_the_page_and_returns_the_new_seq() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let (id, seq) = seed_page_with_seq(&app, pid, "Uncontended").await;
let resp = put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"title": "Edited", "expected_seq": seq}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let updated = parse_json(resp).await;
assert_eq!(updated["title"], "Edited");
assert!(updated["seq"].as_i64().unwrap() > seq);
}
#[tokio::test]
async fn omitting_expected_seq_keeps_page_updates_last_writer_wins() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let (id, _) = seed_page_with_seq(&app, pid, "Unguarded").await;
put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"content": "first"}),
)
.await;
let resp = put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"content": "second"}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["content"], "second");
}
#[tokio::test]
async fn a_deleted_page_is_a_404_even_with_a_precondition() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let (id, seq) = seed_page_with_seq(&app, pid, "Doomed").await;
json_delete(&app, &format!("/api/pages/{id}")).await;
for expected in [seq, seq + 999] {
let resp = put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"title": "Ghost", "expected_seq": expected}),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn field_disjoint_page_updates_without_preconditions_both_land() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let (id, _) = seed_page_with_seq(&app, pid, "Shared").await;
put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"content": "body text"}),
)
.await;
put(
&app,
&format!("/api/pages/{id}"),
serde_json::json!({"status": "active"}),
)
.await;
let page = parse_json(json_get(&app, &format!("/api/pages/{id}")).await).await;
assert_eq!(page["content"], "body text");
assert_eq!(page["status"], "active");
assert_eq!(page["title"], "Shared");
}
#[tokio::test]
async fn deleted_page_is_gone_until_it_is_restored() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Architecture",
"content": "quenelle notes",
"labels": ["design"],
}),
)
.await,
)
.await;
let id = created["id"].as_i64().unwrap();
assert_eq!(
json_delete(&app, &format!("/api/pages/{id}"))
.await
.status(),
StatusCode::OK
);
assert_eq!(
json_get(&app, &format!("/api/pages/{id}")).await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
json_get(&app, "/api/pages/resolve/TST-DOC-1")
.await
.status(),
StatusCode::NOT_FOUND
);
let listed: Vec<serde_json::Value> = serde_json::from_value(
parse_json(json_get(&app, &format!("/api/pages?project_id={pid}")).await).await,
)
.unwrap();
assert!(listed.is_empty());
let resp = json_post(
&app,
&format!("/api/pages/{id}/restore"),
serde_json::json!({}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let restored = parse_json(resp).await;
assert_eq!(restored["identifier"], "TST-DOC-1");
assert_eq!(restored["labels"], serde_json::json!(["design"]));
assert_eq!(
json_get(&app, &format!("/api/pages/{id}")).await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn restoring_a_live_page_is_a_404() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({"project_id": pid, "title": "Alive"}),
)
.await,
)
.await;
let id = created["id"].as_i64().unwrap();
assert_eq!(
json_post(
&app,
&format!("/api/pages/{id}/restore"),
serde_json::json!({})
)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn create_page_accepts_labels_and_returns_them() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let resp = json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Spec",
"labels": ["design"],
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let page = parse_json(resp).await;
assert_eq!(page["labels"], serde_json::json!(["design"]));
}
#[tokio::test]
async fn update_page_replaces_labels() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Spec",
"labels": ["design"],
}),
)
.await,
)
.await;
let id = created["id"].as_i64().unwrap();
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/pages/{id}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&serde_json::json!({ "labels": ["draft"] })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let updated = parse_json(resp).await;
assert_eq!(updated["labels"], serde_json::json!(["draft"]));
}
#[tokio::test]
async fn list_pages_supports_label_filter() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Designy",
"labels": ["design"],
}),
)
.await;
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Plain",
}),
)
.await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/pages?project_id={pid}&label=design"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let list: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["title"], "Designy");
}
#[tokio::test]
async fn list_pages_supports_status_filter() {
let app = test_app();
let (pid, _) = seed_project(&app).await;
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Drafty",
}),
)
.await;
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Archived doc",
"status": "archived",
}),
)
.await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/pages?project_id={pid}&status=archived"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let list: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["title"], "Archived doc");
assert_eq!(list[0]["status"], "archived");
}
#[tokio::test]
async fn list_pages_orders_and_paginates_results() {
let app = test_app();
let (pid, _) = seed_project(&app).await;
for title in ["Delta", "Alpha", "Charlie", "Bravo"] {
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": title,
}),
)
.await;
}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/pages?project_id={pid}&order_by=title&order=asc&limit=2&offset=1"
))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let list: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
let titles: Vec<&str> = list
.iter()
.map(|page| page["title"].as_str().unwrap())
.collect();
assert_eq!(titles, ["Bravo", "Charlie"]);
}
#[tokio::test]
async fn create_page_defaults_status_to_draft() {
let app = test_app();
let (pid, _) = seed_project(&app).await;
let resp = json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Fresh",
}),
)
.await;
let page = parse_json(resp).await;
assert_eq!(page["status"], "draft");
}
#[tokio::test]
async fn get_page_includes_labels() {
let app = test_app();
let pid = seed_project_with_labels(&app).await;
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({
"project_id": pid,
"title": "Spec",
"labels": ["design", "draft"],
}),
)
.await,
)
.await;
let id = created["id"].as_i64().unwrap();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/pages/{id}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let page = parse_json(resp).await;
let labels = page["labels"].as_array().unwrap();
assert_eq!(labels.len(), 2);
}
#[tokio::test]
async fn resolve_page_by_project_identifier_returns_full_page() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({ "project_id": project_id, "title": "Project spec" }),
)
.await,
)
.await;
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/pages/resolve/TST-DOC-1")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let page = parse_json(response).await;
assert_eq!(page["id"], created["id"]);
assert_eq!(page["identifier"], "TST-DOC-1");
assert_eq!(page["title"], "Project spec");
}
#[tokio::test]
async fn resolve_page_by_workspace_identifier_returns_full_page() {
let app = test_app();
let created = parse_json(
json_post(
&app,
"/api/pages",
serde_json::json!({ "title": "Workspace guide" }),
)
.await,
)
.await;
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/pages/resolve/DOC-1")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let page = parse_json(response).await;
assert_eq!(page["id"], created["id"]);
assert_eq!(page["identifier"], "DOC-1");
assert!(page["project_id"].is_null());
}
#[tokio::test]
async fn resolve_page_returns_not_found_for_unknown_identifier() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.uri("/api/pages/resolve/TST-DOC-999")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn resolve_page_allows_viewer_and_denies_non_member_when_enforced() {
let (db, _admin, lead, _maintainer, viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
let page = parse_json(
json_post(
&lead_app,
"/api/pages",
serde_json::json!({ "project_id": project_id, "title": "Members only" }),
)
.await,
)
.await;
let identifier = page["identifier"].as_str().unwrap();
let viewer_app = app_as_user(db.clone(), &viewer);
let response = viewer_app
.oneshot(
Request::builder()
.uri(format!("/api/pages/resolve/{identifier}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let non_member_app = app_as_user(db, &non_member);
let response = non_member_app
.oneshot(
Request::builder()
.uri(format!("/api/pages/resolve/{identifier}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
}