diff --git a/migrations/20230706231224_add-slug-constraint.down.sql b/migrations/20230706231224_add-slug-constraint.down.sql new file mode 100644 index 0000000..e1659ee --- /dev/null +++ b/migrations/20230706231224_add-slug-constraint.down.sql @@ -0,0 +1,4 @@ +-- Remove constraint and index (page_site_slug_unique, page_site_slug_idx) +ALTER TABLE pages DROP CONSTRAINT pages_site_slug_unique; + +DROP INDEX IF EXISTS pages_site_slug_unique; diff --git a/migrations/20230706231224_add-slug-constraint.up.sql b/migrations/20230706231224_add-slug-constraint.up.sql new file mode 100644 index 0000000..5ea9295 --- /dev/null +++ b/migrations/20230706231224_add-slug-constraint.up.sql @@ -0,0 +1,6 @@ +-- Add index/constraint on page site and slug +CREATE UNIQUE INDEX pages_site_slug_idx +ON pages (site, slug); + +ALTER TABLE pages +ADD CONSTRAINT pages_site_slug_unique UNIQUE USING INDEX pages_site_slug_idx; diff --git a/src/content/page.rs b/src/content/page.rs index 107551b..3a20b62 100644 --- a/src/content/page.rs +++ b/src/content/page.rs @@ -38,11 +38,6 @@ pub struct Page { pub deleted_at: Option, } -#[async_trait] -pub trait PageRepository { - async fn get_page_from_url(&self, site: &str, slug: &str) -> Result; -} - #[derive(Deserialize, Serialize)] #[serde(tag = "kind")] pub enum PageBlock { @@ -77,3 +72,55 @@ pub struct ImageElement { /// Optional caption to put near the image pub caption: Option, } + +/// Data to create a new page +#[derive(Deserialize)] +pub struct CreatePageData { + pub slug: String, + pub title: String, + pub description: Option, + pub tags: Vec, + pub blocks: Vec, +} + +/// Data to update a new page +#[derive(Deserialize)] +pub struct UpdatePageData { + pub slug: Option, + pub title: Option, + pub description: Option, + pub tags: Option>, + pub blocks: Option>, +} + +#[async_trait] +pub trait PageRepository { + /// Get a page from its slug + async fn get_page_from_url(&self, site: &str, slug: &str) -> Result; + + /// Create a new page + async fn create_page( + &self, + owner: &Uuid, + site: &Uuid, + data: CreatePageData, + ) -> Result<(), Error>; + + /// Update a page + async fn update_page( + &self, + owner: &Uuid, + site: &Uuid, + slug: &str, + data: UpdatePageData, + ) -> Result<(), Error>; + + /// Delete a page + async fn delete_page( + &self, + owner: &Uuid, + site: &Uuid, + name: &str, + soft_delete: bool, + ) -> Result<(), Error>; +} diff --git a/src/content/site.rs b/src/content/site.rs index 2b50af2..2b93be5 100644 --- a/src/content/site.rs +++ b/src/content/site.rs @@ -41,14 +41,15 @@ pub struct APISite { /// Data required to create a new site #[derive(Deserialize)] -pub struct CreateSiteOptions { +pub struct CreateSiteData { pub name: String, pub title: String, + pub description: Option, } /// Data required to update a site's info #[derive(Deserialize)] -pub struct UpdateSiteOptions { +pub struct UpdateSiteData { pub name: Option, pub title: Option, pub description: Option, @@ -60,16 +61,19 @@ pub trait SiteRepository { async fn get_site_by_name(&self, name: &str) -> Result; /// Create a new instance of a site and store it - async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error>; + async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error>; /// Update an existing site's info async fn update_site( &self, owner: &Uuid, name: &str, - options: &UpdateSiteOptions, + options: UpdateSiteData, ) -> Result<(), Error>; /// Delete a site async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>; + + /// Resolve a site's name to its ID + async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result; } diff --git a/src/database/page.rs b/src/database/page.rs index 2aff86a..0695da5 100644 --- a/src/database/page.rs +++ b/src/database/page.rs @@ -1,8 +1,11 @@ use async_trait::async_trait; +use serde_json::json; +use uuid::Uuid; use crate::content::{ self, - page::{Page, PageRepository}, + page::{CreatePageData, Page, PageRepository, UpdatePageData}, + Error, }; use super::Database; @@ -18,9 +21,95 @@ impl PageRepository for Database { .fetch_one(&self.pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => content::Error::NotFound, + sqlx::Error::RowNotFound => Error::NotFound, _ => e.into(), })?; Ok(page) } + + async fn create_page( + &self, + owner: &Uuid, + site: &Uuid, + data: CreatePageData, + ) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO pages (author, site, slug, title, description, tags, blocks) VALUES ($1, $2, $3, $4, $5, $6, $7)", + owner, + site, + data.slug, + data.title, + data.description.unwrap_or_else(|| "".to_string()), + &data.tags, + json!(data.blocks), + ).execute(&self.pool).await.map_err(|err| match err { + sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => { + Error::IdentifierNotAvailable + } + _ => err.into(), + })?; + Ok(()) + } + + async fn update_page( + &self, + owner: &Uuid, + site: &Uuid, + page: &str, + data: UpdatePageData, + ) -> Result<(), Error> { + let result = sqlx::query!( + "UPDATE pages SET title = COALESCE($1, title), description = COALESCE($2, description), tags = COALESCE($3, tags), blocks = COALESCE($4, blocks) WHERE author = $5 AND site = $6 AND slug = $7 AND deleted_at IS NULL", + data.title, + data.description, + data.tags.as_deref(), + data.blocks.map(|x| json!(x)), + owner, + site, + page + ).execute(&self.pool).await.map_err(|err| match err { + sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => { + Error::IdentifierNotAvailable + } + _ => err.into(), + })?; + + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + + Ok(()) + } + + async fn delete_page( + &self, + owner: &Uuid, + site: &Uuid, + page: &str, + soft_delete: bool, + ) -> Result<(), Error> { + let result = if soft_delete { + sqlx::query!( + "UPDATE pages SET deleted_at = NOW() WHERE author = $1 AND site = $2 AND slug = $3 AND deleted_at IS NULL", + owner, + site, + page + ).execute(&self.pool).await? + } else { + sqlx::query!( + "DELETE FROM pages WHERE author = $1 AND site = $2 AND slug = $3", + owner, + site, + page + ) + .execute(&self.pool) + .await? + }; + + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + + Ok(()) + } } diff --git a/src/database/site.rs b/src/database/site.rs index 1dde272..fd2224f 100644 --- a/src/database/site.rs +++ b/src/database/site.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use uuid::Uuid; use crate::content::{ - site::{APISite, CreateSiteOptions, SiteRepository, UpdateSiteOptions}, + site::{APISite, CreateSiteData, SiteRepository, UpdateSiteData}, Error, }; @@ -24,12 +24,13 @@ impl SiteRepository for Database { Ok(site) } - async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error> { + async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error> { sqlx::query!( - "INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)", + "INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4)", options.name, owner, options.title, + options.description, ) .execute(&self.pool) .await @@ -46,7 +47,7 @@ impl SiteRepository for Database { &self, owner: &Uuid, name: &str, - options: &UpdateSiteOptions, + options: UpdateSiteData, ) -> Result<(), Error> { let result = sqlx::query!( "UPDATE sites SET name = COALESCE($1, name), title = COALESCE($2, title), description = COALESCE($3, description), modified_at = NOW() WHERE name = $4 AND owner = $5 AND deleted_at IS NULL", @@ -95,4 +96,19 @@ impl SiteRepository for Database { Ok(()) } + + async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result { + let record = sqlx::query!( + "SELECT id FROM sites WHERE name = $1 AND owner = $2 AND deleted_at IS NULL", + name, + owner + ) + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => Error::NotFound, + _ => e.into(), + })?; + Ok(record.id) + } } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 7ea89cd..ade6d2e 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -4,10 +4,17 @@ use axum::{ routing::{get, post}, Json, Router, }; +use serde_json::json; use std::sync::Arc; use crate::{ - content::page::PageRepository, database::Database, http::error::ApiError, state::AppState, + content::{ + page::{CreatePageData, PageRepository, UpdatePageData}, + site::SiteRepository, + }, + database::Database, + http::{error::ApiError, json::JsonBody, session::RequireUser}, + state::AppState, }; async fn get_page( @@ -17,15 +24,55 @@ async fn get_page( Ok(Json(repository.get_page_from_url(&site, &slug).await?)) } -async fn create_page(//repository: Repo, - //Path(site): Path, - //RequireUser(user): RequireUser, +async fn create_page( + repository: Repo, + Path(site): Path, + RequireUser(user): RequireUser, + JsonBody(page): JsonBody, ) -> Result> { - Ok(Json("todo")) + // Resolve site + let site_id = repository.get_site_id(&site, &user.id).await?; + + repository.create_page(&user.id, &site_id, page).await?; + Ok(Json(json!({ "ok": true }))) +} + +async fn update_page( + repository: Repo, + Path((site, slug)): Path<(String, String)>, + RequireUser(user): RequireUser, + JsonBody(page): JsonBody, +) -> Result> { + // Resolve site + let site_id = repository.get_site_id(&site, &user.id).await?; + + repository + .update_page(&user.id, &site_id, &slug, page) + .await?; + Ok(Json(json!({ "ok": true }))) +} + +async fn delete_page( + repository: Repo, + Path((site, slug)): Path<(String, String)>, + RequireUser(user): RequireUser, +) -> Result> { + // Resolve site + let site_id = repository.get_site_id(&site, &user.id).await?; + + repository + .delete_page(&user.id, &site_id, &slug, true) + .await?; + Ok(Json(json!({ "ok": true }))) } pub fn router() -> Router> { Router::new() .route("/:site", post(create_page::)) - .route("/:site/:slug", get(get_page::)) + .route( + "/:site/:slug", + get(get_page::) + .put(update_page::) + .delete(delete_page::), + ) } diff --git a/src/routes/sites.rs b/src/routes/sites.rs index 757ddb3..52c1849 100644 --- a/src/routes/sites.rs +++ b/src/routes/sites.rs @@ -8,7 +8,7 @@ use serde_json::json; use std::sync::Arc; use crate::{ - content::site::{CreateSiteOptions, SiteRepository, UpdateSiteOptions}, + content::site::{CreateSiteData, SiteRepository, UpdateSiteData}, database::Database, http::{error::ApiError, json::JsonBody, session::RequireUser}, state::AppState, @@ -24,9 +24,9 @@ async fn get_site( async fn create_site( repository: Repo, RequireUser(user): RequireUser, - JsonBody(options): JsonBody, + JsonBody(options): JsonBody, ) -> Result> { - repository.create_site(&user.id, &options).await?; + repository.create_site(&user.id, options).await?; Ok(Json(json!({"ok": true}))) } @@ -34,9 +34,9 @@ async fn update_site( repository: Repo, Path(name): Path, RequireUser(user): RequireUser, - JsonBody(options): JsonBody, + JsonBody(options): JsonBody, ) -> Result> { - repository.update_site(&user.id, &name, &options).await?; + repository.update_site(&user.id, &name, options).await?; Ok(Json(json!({"ok": true}))) }