add collections and private posts
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
f5c7570d9d
commit
203df76d6c
18 changed files with 221 additions and 27 deletions
5
migrations/20230713090533_add-collection.down.sql
Normal file
5
migrations/20230713090533_add-collection.down.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE pages
|
||||
DROP COLUMN collections,
|
||||
DROP COLUMN published;
|
||||
|
||||
DROP TABLE collections;
|
13
migrations/20230713090533_add-collection.up.sql
Normal file
13
migrations/20230713090533_add-collection.up.sql
Normal file
|
@ -0,0 +1,13 @@
|
|||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- noqa: RF05
|
||||
|
||||
CREATE TABLE collections (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
site UUID NOT NULL REFERENCES sites (id) ON DELETE CASCADE,
|
||||
slug VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
parent UUID REFERENCES collections (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN collections UUID [] NOT NULL DEFAULT array[]::UUID[], -- noqa
|
||||
ADD COLUMN published BOOLEAN NOT NULL DEFAULT FALSE;
|
|
@ -0,0 +1,4 @@
|
|||
-- Remove constraint and index (collections_site_slug_unique, collections_site_slug_idx)
|
||||
ALTER TABLE collections DROP CONSTRAINT collections_site_slug_unique;
|
||||
|
||||
DROP INDEX IF EXISTS collections_site_slug_unique;
|
|
@ -0,0 +1,7 @@
|
|||
-- Add index/constraint on site and collection slug
|
||||
CREATE UNIQUE INDEX collections_site_slug_idx
|
||||
ON collections (site, slug);
|
||||
|
||||
ALTER TABLE collections
|
||||
ADD CONSTRAINT collections_site_slug_unique UNIQUE
|
||||
USING INDEX collections_site_slug_idx;
|
32
src/content/collection.rs
Normal file
32
src/content/collection.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::Error;
|
||||
|
||||
pub const DEFAULT_COLLECTIONS: [(&str, &str); 2] = [("blog", "Blog"), ("pages", "Pages")];
|
||||
|
||||
#[derive(Deserialize, Serialize, FromRow)]
|
||||
pub struct Collection {
|
||||
/// Collection ID
|
||||
pub id: Uuid,
|
||||
|
||||
/// Site ID
|
||||
pub site: Uuid,
|
||||
|
||||
/// Collection URL name
|
||||
pub slug: String,
|
||||
|
||||
/// Collection display name
|
||||
pub name: String,
|
||||
|
||||
/// Collection parent (eg. blog categories have blog as parent)
|
||||
pub parent: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CollectionRepository {
|
||||
/// Create default collections for a site
|
||||
async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error>;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub mod collection;
|
||||
pub mod post;
|
||||
pub mod site;
|
||||
|
||||
|
|
|
@ -32,6 +32,12 @@ pub struct Post {
|
|||
/// Post blocks (content)
|
||||
pub blocks: Json<Vec<PostBlock>>,
|
||||
|
||||
/// Collections the post belongs to
|
||||
pub collections: Vec<Uuid>,
|
||||
|
||||
/// Is the post published?
|
||||
pub published: bool,
|
||||
|
||||
/// Times
|
||||
pub created_at: NaiveDateTime,
|
||||
pub modified_at: Option<NaiveDateTime>,
|
||||
|
@ -81,6 +87,8 @@ pub struct CreatePostData {
|
|||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub blocks: Vec<PostBlock>,
|
||||
pub collections: Vec<Uuid>,
|
||||
pub published: bool,
|
||||
}
|
||||
|
||||
/// Data to update a new post
|
||||
|
@ -91,6 +99,8 @@ pub struct UpdatePostData {
|
|||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub blocks: Option<Vec<PostBlock>>,
|
||||
pub collections: Option<Vec<Uuid>>,
|
||||
pub published: Option<bool>,
|
||||
}
|
||||
|
||||
/// Post with site and author dereferenced for convenience
|
||||
|
@ -106,6 +116,7 @@ pub struct PostWithAuthor {
|
|||
pub created_at: NaiveDateTime,
|
||||
pub modified_at: Option<NaiveDateTime>,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub published: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
|
@ -31,13 +31,20 @@ pub struct Site {
|
|||
|
||||
/// More useful version of Site for showing to users
|
||||
#[derive(Serialize)]
|
||||
pub struct SiteWithAuthor {
|
||||
pub struct SiteData {
|
||||
pub name: String,
|
||||
pub owner: String,
|
||||
pub owner_display_name: Option<String>,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub collections: Vec<CollectionNameAndSlug>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CollectionNameAndSlug {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Data required to create a new site
|
||||
|
@ -59,10 +66,10 @@ pub struct UpdateSiteData {
|
|||
#[async_trait]
|
||||
pub trait SiteRepository {
|
||||
/// Retrieve site info from site name/slug
|
||||
async fn get_site_by_name(&self, name: &str) -> Result<SiteWithAuthor, Error>;
|
||||
async fn get_site_by_name(&self, name: &str) -> Result<SiteData, Error>;
|
||||
|
||||
/// Create a new instance of a site and store it
|
||||
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error>;
|
||||
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<Uuid, Error>;
|
||||
|
||||
/// Update an existing site's info
|
||||
async fn update_site(
|
||||
|
|
31
src/database/collection.rs
Normal file
31
src/database/collection.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::content::{
|
||||
collection::{CollectionRepository, DEFAULT_COLLECTIONS},
|
||||
Error,
|
||||
};
|
||||
|
||||
use super::Database;
|
||||
|
||||
#[async_trait]
|
||||
impl CollectionRepository for Database {
|
||||
async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
for (slug, name) in DEFAULT_COLLECTIONS {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO collections (site, slug, name) VALUES ($1, $2, $3)"#,
|
||||
site,
|
||||
slug,
|
||||
name
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod collection;
|
||||
pub mod post;
|
||||
pub mod site;
|
||||
|
||||
|
|
|
@ -31,7 +31,8 @@ impl PostRepository for Database {
|
|||
pages.blocks as "blocks!: Json<Vec<PostBlock>>",
|
||||
pages.created_at,
|
||||
pages.modified_at,
|
||||
pages.deleted_at
|
||||
pages.deleted_at,
|
||||
pages.published
|
||||
FROM pages
|
||||
JOIN
|
||||
sites ON site = sites.id
|
||||
|
@ -61,7 +62,7 @@ impl PostRepository for Database {
|
|||
data: CreatePostData,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO pages (author, site, slug, title, description, tags, blocks) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"INSERT INTO pages (author, site, slug, title, description, tags, blocks, collections, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
owner,
|
||||
site,
|
||||
data.slug,
|
||||
|
@ -69,6 +70,8 @@ impl PostRepository for Database {
|
|||
data.description.unwrap_or_else(|| "".to_string()),
|
||||
&data.tags,
|
||||
json!(data.blocks),
|
||||
&data.collections,
|
||||
data.published
|
||||
).execute(&self.pool).await.map_err(|err| match err {
|
||||
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
|
||||
Error::IdentifierNotAvailable
|
||||
|
@ -86,15 +89,18 @@ impl PostRepository for Database {
|
|||
data: UpdatePostData,
|
||||
) -> 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",
|
||||
"UPDATE pages SET title = COALESCE($1, title), description = COALESCE($2, description), tags = COALESCE($3, tags), blocks = COALESCE($4, blocks), collections = COALESCE($8, collections), published = COALESCE($9, published) 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,
|
||||
slug
|
||||
).execute(&self.pool).await.map_err(|err| match err {
|
||||
slug,
|
||||
data.collections.as_deref(),
|
||||
data.published
|
||||
)
|
||||
.execute(&self.pool).await.map_err(|err| match err {
|
||||
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
|
||||
Error::IdentifierNotAvailable
|
||||
}
|
||||
|
|
|
@ -1,31 +1,53 @@
|
|||
use async_trait::async_trait;
|
||||
use sqlx::Postgres;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::content::{
|
||||
site::{CreateSiteData, SiteRepository, SiteWithAuthor, UpdateSiteData},
|
||||
site::{CollectionNameAndSlug, CreateSiteData, SiteData, SiteRepository, UpdateSiteData},
|
||||
Error,
|
||||
};
|
||||
|
||||
use super::Database;
|
||||
|
||||
impl sqlx::Type<Postgres> for CollectionNameAndSlug {
|
||||
fn type_info() -> sqlx::postgres::PgTypeInfo {
|
||||
sqlx::postgres::PgTypeInfo::with_name("collection_name_slug")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> sqlx::Decode<'r, Postgres> for CollectionNameAndSlug {
|
||||
fn decode(
|
||||
value: sqlx::postgres::PgValueRef<'r>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
|
||||
let mut decoder = sqlx::postgres::types::PgRecordDecoder::new(value)?;
|
||||
let slug = decoder.try_decode::<String>()?;
|
||||
let name = decoder.try_decode::<String>()?;
|
||||
Ok(Self { name, slug })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SiteRepository for Database {
|
||||
async fn get_site_by_name(&self, name: &str) -> Result<SiteWithAuthor, Error> {
|
||||
let site = sqlx::query_as!(
|
||||
SiteWithAuthor,
|
||||
async fn get_site_by_name(&self, name: &str) -> Result<SiteData, Error> {
|
||||
let site = sqlx::query!(
|
||||
r#"SELECT
|
||||
sites.name,
|
||||
users.name as owner,
|
||||
COALESCE(users.display_name,users.name) as owner_display_name,
|
||||
title,
|
||||
description,
|
||||
sites.created_at
|
||||
sites.created_at,
|
||||
array_agg(row(collections.slug, collections.name)) as "collections!: Vec<CollectionNameAndSlug>"
|
||||
FROM sites
|
||||
JOIN
|
||||
users ON sites.owner = users.id
|
||||
JOIN
|
||||
collections ON collections.site = sites.id
|
||||
WHERE
|
||||
sites.name = $1
|
||||
AND sites.deleted_at IS NULL"#,
|
||||
AND sites.deleted_at IS NULL
|
||||
GROUP BY
|
||||
sites.name, users.name, users.display_name, title, description, sites.created_at"#,
|
||||
name
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
|
@ -34,18 +56,26 @@ impl SiteRepository for Database {
|
|||
sqlx::Error::RowNotFound => Error::NotFound,
|
||||
_ => e.into(),
|
||||
})?;
|
||||
Ok(site)
|
||||
Ok(SiteData {
|
||||
name: site.name,
|
||||
owner: site.owner,
|
||||
owner_display_name: site.owner_display_name,
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
created_at: site.created_at,
|
||||
collections: site.collections,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4)",
|
||||
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<Uuid, Error> {
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
options.name,
|
||||
owner,
|
||||
options.title,
|
||||
options.description,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => {
|
||||
|
@ -53,7 +83,7 @@ impl SiteRepository for Database {
|
|||
}
|
||||
_ => err.into(),
|
||||
})?;
|
||||
Ok(())
|
||||
Ok(result.id)
|
||||
}
|
||||
|
||||
async fn update_site(
|
||||
|
|
|
@ -49,6 +49,23 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub struct OptionalUser(pub Option<User>);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for OptionalUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError<'static>;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Extension::<User>::from_request_parts(parts, state).await {
|
||||
Ok(Extension(user)) => Ok(OptionalUser(Some(user))),
|
||||
_ => Ok(OptionalUser(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RequireSession(pub Session);
|
||||
|
||||
#[async_trait]
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
mod auth;
|
||||
mod builtins;
|
||||
mod content;
|
||||
mod database;
|
||||
mod http;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
|
|
|
@ -8,8 +8,8 @@ use std::sync::Arc;
|
|||
|
||||
use crate::{
|
||||
auth::{hash::random, user::User},
|
||||
builtins::ROLE_SUPERADMIN,
|
||||
http::error::ApiError,
|
||||
roles::ROLE_SUPERADMIN,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
|
|
|
@ -11,17 +11,39 @@ use crate::{
|
|||
content::{
|
||||
post::{CreatePostData, PostRepository, UpdatePostData},
|
||||
site::SiteRepository,
|
||||
Error,
|
||||
},
|
||||
database::Database,
|
||||
http::{error::ApiError, json::JsonBody, session::RequireUser},
|
||||
http::{
|
||||
error::ApiError,
|
||||
json::JsonBody,
|
||||
session::{OptionalUser, RequireUser},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn get_post<Repo: PostRepository>(
|
||||
repository: Repo,
|
||||
OptionalUser(user): OptionalUser,
|
||||
Path((site, slug)): Path<(String, String)>,
|
||||
) -> Result<impl IntoResponse, ApiError<'static>> {
|
||||
Ok(Json(repository.get_post_from_url(&site, &slug).await?))
|
||||
let post = repository.get_post_from_url(&site, &slug).await?;
|
||||
|
||||
// If post has been published, show to everyone, otherwise only show to owner/author
|
||||
if post.published {
|
||||
return Ok(Json(post));
|
||||
}
|
||||
|
||||
if let Some(user) = user {
|
||||
// TODO This will need to be changed if we want more users managing the same site
|
||||
// Also, we should have a better way to match the user!! (mapping func? prolly)
|
||||
if user.name == post.author_username {
|
||||
return Ok(Json(post));
|
||||
}
|
||||
}
|
||||
|
||||
// 404'd!!
|
||||
Err(Error::NotFound.into())
|
||||
}
|
||||
|
||||
async fn create_post<Repo: PostRepository + SiteRepository>(
|
||||
|
|
|
@ -8,7 +8,10 @@ use serde_json::json;
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
content::site::{CreateSiteData, SiteRepository, UpdateSiteData},
|
||||
content::{
|
||||
collection::CollectionRepository,
|
||||
site::{CreateSiteData, SiteRepository, UpdateSiteData},
|
||||
},
|
||||
database::Database,
|
||||
http::{error::ApiError, json::JsonBody, session::RequireUser},
|
||||
state::AppState,
|
||||
|
@ -21,12 +24,16 @@ async fn get_site<Repo: SiteRepository>(
|
|||
Ok(Json(repository.get_site_by_name(&name).await?))
|
||||
}
|
||||
|
||||
async fn create_site<Repo: SiteRepository>(
|
||||
async fn create_site<Repo: SiteRepository + CollectionRepository>(
|
||||
repository: Repo,
|
||||
RequireUser(user): RequireUser,
|
||||
JsonBody(options): JsonBody<CreateSiteData>,
|
||||
) -> Result<impl IntoResponse, ApiError<'static>> {
|
||||
repository.create_site(&user.id, options).await?;
|
||||
let id = repository.create_site(&user.id, options).await?;
|
||||
|
||||
// Create default content for the new site
|
||||
repository.create_default_collections(&id).await?;
|
||||
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue