BLOG-125 Get post by sementic ID (#134)
All checks were successful
Frontend CI / build (push) Successful in 1m26s
All checks were successful
Frontend CI / build (push) Successful in 1m26s
### Description #### Backend - String and interger can be pass as `id` to `GET` `/post/{id}` - For the posts existed, the default `semantic_id` for them will be `_id`. (e.g. `_1`, `_2`) - Semantic ID should follow the rules: 1. It shouldn't be an integer 1. It should match the pattern: `^[0-9a-zA-Z_\-]+$` <br> |Semantic ID|Result|Note| |-|-|-| |12|X|against with `i`| |-3|X|against with `i`| |3.14|X|against with `ii`| |hello world|X|against with `ii`| |*EMPTY*|X|against with `ii`| |12_34-56|O|| #### Frontend - The href of post preview card becomes the semantic ID. ### Package Changes ```toml regex = "1.12.1" ``` ### Screenshots  ### Reference Resolves #125. ### Checklist - [x] A milestone is set - [x] The related issuse has been linked to this branch Reviewed-on: #134 Co-authored-by: SquidSpirit <squid@squidspirit.com> Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
parent
7c32d347b4
commit
565df7aace
21
backend/.env.example
Normal file
21
backend/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
HOST=127.0.0.1
|
||||
PORT=8080
|
||||
|
||||
DATABASE_HOST=127.0.0.1
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=
|
||||
DATABASE_NAME=postgres
|
||||
|
||||
# For sqlx migrations
|
||||
DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres
|
||||
|
||||
OIDC_ISSUER_URL=https://xxx
|
||||
OIDC_CLIENT_ID=xxx
|
||||
OIDC_CLIENT_SECRET=xxx
|
||||
OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
|
||||
|
||||
SESSION_KEY='openssl rand -hex 64'
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
STORAGE_PATH=static
|
9
backend/Cargo.lock
generated
9
backend/Cargo.lock
generated
@ -2407,6 +2407,7 @@ dependencies = [
|
||||
"auth",
|
||||
"chrono",
|
||||
"common",
|
||||
"regex",
|
||||
"sentry",
|
||||
"serde",
|
||||
"sqlx",
|
||||
@ -2638,9 +2639,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@ -2650,9 +2651,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
@ -5,7 +5,6 @@ members = [
|
||||
"feature/common",
|
||||
"feature/image",
|
||||
"feature/post",
|
||||
"feature/common",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@ -33,6 +32,7 @@ openidconnect = { version = "4.0.1", features = [
|
||||
"reqwest-blocking",
|
||||
] }
|
||||
percent-encoding = "2.3.1"
|
||||
regex = "1.12.1"
|
||||
sentry = { version = "0.42.0", features = ["actix", "anyhow"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
sqlx = { version = "0.8.5", features = [
|
||||
|
@ -8,6 +8,7 @@ actix-web.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
regex.workspace = true
|
||||
sentry.workspace = true
|
||||
serde.workspace = true
|
||||
sqlx.workspace = true
|
||||
|
@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo};
|
||||
|
||||
#[derive(Deserialize, ToSchema, Clone)]
|
||||
pub struct CreatePostRequestDto {
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
@ -24,6 +25,7 @@ impl CreatePostRequestDto {
|
||||
id: -1,
|
||||
info: PostInfo {
|
||||
id: -1,
|
||||
semantic_id: self.semantic_id,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
preview_image_url: self.preview_image_url,
|
||||
|
@ -15,8 +15,9 @@ use crate::{
|
||||
create_label_use_case::CreateLabelUseCase, create_post_use_case::CreatePostUseCase,
|
||||
get_all_labels_use_case::GetAllLabelsUseCase,
|
||||
get_all_post_info_use_case::GetAllPostInfoUseCase,
|
||||
get_full_post_use_case::GetFullPostUseCase, update_label_use_case::UpdateLabelUseCase,
|
||||
update_post_use_case::UpdatePostUseCase,
|
||||
get_post_by_id_use_case::GetPostByIdUseCase,
|
||||
get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCase,
|
||||
update_label_use_case::UpdateLabelUseCase, update_post_use_case::UpdatePostUseCase,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -34,9 +35,9 @@ pub trait PostController: Send + Sync {
|
||||
user_id: Option<i32>,
|
||||
) -> Result<Vec<PostInfoResponseDto>, PostError>;
|
||||
|
||||
async fn get_post_by_id(
|
||||
async fn get_post_by_id_or_semantic_id(
|
||||
&self,
|
||||
id: i32,
|
||||
id_or_semantic_id: &str,
|
||||
user_id: Option<i32>,
|
||||
) -> Result<PostResponseDto, PostError>;
|
||||
|
||||
@ -69,7 +70,8 @@ pub trait PostController: Send + Sync {
|
||||
|
||||
pub struct PostControllerImpl {
|
||||
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
|
||||
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
|
||||
get_post_by_id_use_case: Arc<dyn GetPostByIdUseCase>,
|
||||
get_post_by_semantic_id_use_case: Arc<dyn GetPostBySemanticIdUseCase>,
|
||||
create_post_use_case: Arc<dyn CreatePostUseCase>,
|
||||
update_post_use_case: Arc<dyn UpdatePostUseCase>,
|
||||
create_label_use_case: Arc<dyn CreateLabelUseCase>,
|
||||
@ -80,7 +82,8 @@ pub struct PostControllerImpl {
|
||||
impl PostControllerImpl {
|
||||
pub fn new(
|
||||
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
|
||||
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
|
||||
get_post_by_id_use_case: Arc<dyn GetPostByIdUseCase>,
|
||||
get_post_by_semantic_id_use_case: Arc<dyn GetPostBySemanticIdUseCase>,
|
||||
create_post_use_case: Arc<dyn CreatePostUseCase>,
|
||||
update_post_use_case: Arc<dyn UpdatePostUseCase>,
|
||||
create_label_use_case: Arc<dyn CreateLabelUseCase>,
|
||||
@ -89,7 +92,8 @@ impl PostControllerImpl {
|
||||
) -> Self {
|
||||
Self {
|
||||
get_all_post_info_use_case,
|
||||
get_full_post_use_case,
|
||||
get_post_by_id_use_case,
|
||||
get_post_by_semantic_id_use_case,
|
||||
create_post_use_case,
|
||||
update_post_use_case,
|
||||
create_label_use_case,
|
||||
@ -97,6 +101,29 @@ impl PostControllerImpl {
|
||||
get_all_labels_use_case,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_post_by_id(
|
||||
&self,
|
||||
id: i32,
|
||||
user_id: Option<i32>,
|
||||
) -> Result<PostResponseDto, PostError> {
|
||||
let result = self.get_post_by_id_use_case.execute(id, user_id).await;
|
||||
|
||||
result.map(PostResponseDto::from)
|
||||
}
|
||||
|
||||
async fn get_post_by_semantic_id(
|
||||
&self,
|
||||
semantic_id: &str,
|
||||
user_id: Option<i32>,
|
||||
) -> Result<PostResponseDto, PostError> {
|
||||
let result = self
|
||||
.get_post_by_semantic_id_use_case
|
||||
.execute(semantic_id, user_id)
|
||||
.await;
|
||||
|
||||
result.map(PostResponseDto::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -121,14 +148,17 @@ impl PostController for PostControllerImpl {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_post_by_id(
|
||||
async fn get_post_by_id_or_semantic_id(
|
||||
&self,
|
||||
id: i32,
|
||||
id_or_semantic_id: &str,
|
||||
user_id: Option<i32>,
|
||||
) -> Result<PostResponseDto, PostError> {
|
||||
let result = self.get_full_post_use_case.execute(id, user_id).await;
|
||||
|
||||
result.map(PostResponseDto::from)
|
||||
if let Ok(id) = id_or_semantic_id.parse::<i32>() {
|
||||
self.get_post_by_id(id, user_id).await
|
||||
} else {
|
||||
let semantic_id = id_or_semantic_id;
|
||||
self.get_post_by_semantic_id(semantic_id, user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_label(
|
||||
|
@ -8,6 +8,7 @@ use super::label_response_dto::LabelResponseDto;
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PostInfoResponseDto {
|
||||
pub id: i32,
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub labels: Vec<LabelResponseDto>,
|
||||
@ -23,6 +24,7 @@ impl From<PostInfo> for PostInfoResponseDto {
|
||||
fn from(entity: PostInfo) -> Self {
|
||||
Self {
|
||||
id: entity.id,
|
||||
semantic_id: entity.semantic_id,
|
||||
title: entity.title,
|
||||
description: entity.description,
|
||||
preview_image_url: entity.preview_image_url,
|
||||
|
@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo};
|
||||
|
||||
#[derive(Deserialize, ToSchema, Clone)]
|
||||
pub struct UpdatePostRequestDto {
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
@ -24,6 +25,7 @@ impl UpdatePostRequestDto {
|
||||
id,
|
||||
info: PostInfo {
|
||||
id,
|
||||
semantic_id: self.semantic_id,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
preview_image_url: self.preview_image_url,
|
||||
|
@ -14,4 +14,5 @@ pub trait PostDbService: Send + Sync {
|
||||
async fn get_post_by_id(&self, id: i32) -> Result<PostMapper, PostError>;
|
||||
async fn create_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<i32, PostError>;
|
||||
async fn update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError>;
|
||||
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError>;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ use crate::{adapter::gateway::label_db_mapper::LabelMapper, domain::entity::post
|
||||
|
||||
pub struct PostInfoMapper {
|
||||
pub id: i32,
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub preview_image_url: String,
|
||||
@ -15,13 +16,18 @@ impl PostInfoMapper {
|
||||
pub fn into_entity(self) -> PostInfo {
|
||||
PostInfo {
|
||||
id: self.id,
|
||||
semantic_id: self.semantic_id,
|
||||
title: self.title.clone(),
|
||||
description: self.description.clone(),
|
||||
preview_image_url: self.preview_image_url.clone(),
|
||||
published_time: self
|
||||
.published_time
|
||||
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
|
||||
labels: self.labels.into_iter().map(LabelMapper::into_entity).collect(),
|
||||
labels: self
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(LabelMapper::into_entity)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ impl PostRepository for PostRepositoryImpl {
|
||||
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
|
||||
let info_mapper = PostInfoMapper {
|
||||
id: post.info.id,
|
||||
semantic_id: post.info.semantic_id,
|
||||
title: post.info.title,
|
||||
description: post.info.description,
|
||||
preview_image_url: post.info.preview_image_url,
|
||||
@ -65,6 +66,7 @@ impl PostRepository for PostRepositoryImpl {
|
||||
async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
|
||||
let info_mapper = PostInfoMapper {
|
||||
id: post.info.id,
|
||||
semantic_id: post.info.semantic_id,
|
||||
title: post.info.title,
|
||||
description: post.info.description,
|
||||
preview_image_url: post.info.preview_image_url,
|
||||
@ -82,4 +84,8 @@ impl PostRepository for PostRepositoryImpl {
|
||||
.update_post(post_mapper, label_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError> {
|
||||
self.post_db_service.get_id_by_semantic_id(semantic_id).await
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ use std::fmt::Display;
|
||||
pub enum PostError {
|
||||
NotFound,
|
||||
Unauthorized,
|
||||
InvalidSemanticId,
|
||||
Unexpected(anyhow::Error),
|
||||
}
|
||||
|
||||
@ -12,6 +13,10 @@ impl Display for PostError {
|
||||
match self {
|
||||
PostError::NotFound => write!(f, "Post not found"),
|
||||
PostError::Unauthorized => write!(f, "Unauthorized access to post"),
|
||||
PostError::InvalidSemanticId => write!(
|
||||
f,
|
||||
"Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_\\-]+$`"
|
||||
),
|
||||
PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
}
|
||||
}
|
||||
|
@ -11,4 +11,5 @@ pub trait PostRepository: Send + Sync {
|
||||
async fn get_post_by_id(&self, id: i32) -> Result<Post, PostError>;
|
||||
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError>;
|
||||
async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
|
||||
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError>;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ pub mod create_label_use_case;
|
||||
pub mod create_post_use_case;
|
||||
pub mod get_all_labels_use_case;
|
||||
pub mod get_all_post_info_use_case;
|
||||
pub mod get_full_post_use_case;
|
||||
pub mod get_post_by_id_use_case;
|
||||
pub mod get_post_by_semantic_id_use_case;
|
||||
pub mod update_label_use_case;
|
||||
pub mod update_post_use_case;
|
||||
|
@ -25,8 +25,7 @@ impl CreatePostUseCaseImpl {
|
||||
#[async_trait]
|
||||
impl CreatePostUseCase for CreatePostUseCaseImpl {
|
||||
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
|
||||
self.post_repository
|
||||
.create_post(post, label_ids)
|
||||
.await
|
||||
post.validate()?;
|
||||
self.post_repository.create_post(post, label_ids).await
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ use crate::{
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GetFullPostUseCase: Send + Sync {
|
||||
pub trait GetPostByIdUseCase: Send + Sync {
|
||||
async fn execute(&self, id: i32, user_id: Option<i32>) -> Result<Post, PostError>;
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ impl GetFullPostUseCaseImpl {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GetFullPostUseCase for GetFullPostUseCaseImpl {
|
||||
impl GetPostByIdUseCase for GetFullPostUseCaseImpl {
|
||||
async fn execute(&self, id: i32, user_id: Option<i32>) -> Result<Post, PostError> {
|
||||
let post = self.post_repository.get_post_by_id(id).await?;
|
||||
|
@ -0,0 +1,45 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
application::{
|
||||
error::post_error::PostError, gateway::post_repository::PostRepository,
|
||||
use_case::get_post_by_id_use_case::GetPostByIdUseCase,
|
||||
},
|
||||
domain::entity::post::Post,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GetPostBySemanticIdUseCase: Send + Sync {
|
||||
async fn execute(&self, semantic_id: &str, user_id: Option<i32>) -> Result<Post, PostError>;
|
||||
}
|
||||
|
||||
pub struct GetPostBySemanticIdUseCaseImpl {
|
||||
post_repository: Arc<dyn PostRepository>,
|
||||
get_post_by_id_use_case: Arc<dyn GetPostByIdUseCase>,
|
||||
}
|
||||
|
||||
impl GetPostBySemanticIdUseCaseImpl {
|
||||
pub fn new(
|
||||
post_repository: Arc<dyn PostRepository>,
|
||||
get_post_by_id_use_case: Arc<dyn GetPostByIdUseCase>,
|
||||
) -> Self {
|
||||
Self {
|
||||
post_repository,
|
||||
get_post_by_id_use_case,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GetPostBySemanticIdUseCase for GetPostBySemanticIdUseCaseImpl {
|
||||
async fn execute(&self, semantic_id: &str, user_id: Option<i32>) -> Result<Post, PostError> {
|
||||
let id = self
|
||||
.post_repository
|
||||
.get_id_by_semantic_id(semantic_id)
|
||||
.await?;
|
||||
|
||||
self.get_post_by_id_use_case.execute(id, user_id).await
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@ impl UpdatePostUseCaseImpl {
|
||||
#[async_trait]
|
||||
impl UpdatePostUseCase for UpdatePostUseCaseImpl {
|
||||
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
|
||||
post.validate()?;
|
||||
self.post_repository.update_post(post, label_ids).await
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
use crate::application::error::post_error::PostError;
|
||||
|
||||
use super::post_info::PostInfo;
|
||||
|
||||
pub struct Post {
|
||||
@ -5,3 +7,10 @@ pub struct Post {
|
||||
pub info: PostInfo,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn validate(&self) -> Result<(), PostError> {
|
||||
self.info.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,31 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::application::error::post_error::PostError;
|
||||
|
||||
use super::label::Label;
|
||||
|
||||
pub struct PostInfo {
|
||||
pub id: i32,
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub preview_image_url: String,
|
||||
pub labels: Vec<Label>,
|
||||
pub published_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PostInfo {
|
||||
pub fn validate(&self) -> Result<(), PostError> {
|
||||
if self.semantic_id.parse::<i32>().is_ok() {
|
||||
return Err(PostError::InvalidSemanticId);
|
||||
}
|
||||
|
||||
let re = Regex::new(r"^[0-9a-zA-Z_\-]+$").unwrap();
|
||||
if !re.is_match(&self.semantic_id) {
|
||||
return Err(PostError::InvalidSemanticId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ impl PostDbService for PostDbServiceImpl {
|
||||
r#"
|
||||
SELECT
|
||||
p.id AS post_id,
|
||||
p.semantic_id,
|
||||
p.title,
|
||||
p.description,
|
||||
p.preview_image_url,
|
||||
@ -74,6 +75,7 @@ impl PostDbService for PostDbServiceImpl {
|
||||
.entry(record.post_id)
|
||||
.or_insert_with(|| PostInfoMapper {
|
||||
id: record.post_id,
|
||||
semantic_id: record.semantic_id.clone(),
|
||||
title: record.title.clone(),
|
||||
description: record.description.clone(),
|
||||
preview_image_url: record.preview_image_url.clone(),
|
||||
@ -111,6 +113,7 @@ impl PostDbService for PostDbServiceImpl {
|
||||
r#"
|
||||
SELECT
|
||||
p.id AS post_id,
|
||||
p.semantic_id,
|
||||
p.title,
|
||||
p.description,
|
||||
p.preview_image_url,
|
||||
@ -151,6 +154,7 @@ impl PostDbService for PostDbServiceImpl {
|
||||
.or_insert_with(|| PostMapper {
|
||||
id: record.post_id,
|
||||
info: PostInfoMapper {
|
||||
semantic_id: record.semantic_id.clone(),
|
||||
id: record.post_id,
|
||||
title: record.title.clone(),
|
||||
description: record.description.clone(),
|
||||
@ -194,10 +198,11 @@ impl PostDbService for PostDbServiceImpl {
|
||||
let post_id = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO post (
|
||||
title, description, preview_image_url, content, published_time
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
semantic_id, title, description, preview_image_url, content, published_time
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
"#,
|
||||
post.info.semantic_id,
|
||||
post.info.title,
|
||||
post.info.description,
|
||||
post.info.preview_image_url,
|
||||
@ -243,13 +248,15 @@ impl PostDbService for PostDbServiceImpl {
|
||||
r#"
|
||||
UPDATE post
|
||||
SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
preview_image_url = $3,
|
||||
content = $4,
|
||||
published_time = $5
|
||||
WHERE id = $6
|
||||
semantic_id = $1,
|
||||
title = $2,
|
||||
description = $3,
|
||||
preview_image_url = $4,
|
||||
content = $5,
|
||||
published_time = $6
|
||||
WHERE id = $7
|
||||
"#,
|
||||
post.info.semantic_id,
|
||||
post.info.title,
|
||||
post.info.description,
|
||||
post.info.preview_image_url,
|
||||
@ -300,4 +307,23 @@ impl PostDbService for PostDbServiceImpl {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError> {
|
||||
let id = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM post
|
||||
WHERE semantic_id = $1 AND deleted_time IS NULL
|
||||
"#,
|
||||
semantic_id,
|
||||
)
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await
|
||||
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))?;
|
||||
|
||||
match id {
|
||||
Some(id) => Ok(id),
|
||||
None => Err(PostError::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct PostInfoWithLabelRecord {
|
||||
pub post_id: i32,
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub preview_image_url: String,
|
||||
|
@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct PostWithLabelRecord {
|
||||
pub post_id: i32,
|
||||
pub semantic_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub preview_image_url: String,
|
||||
|
@ -32,12 +32,16 @@ pub async fn create_label_handler(
|
||||
|
||||
match result {
|
||||
Ok(label) => HttpResponse::Created().json(label),
|
||||
Err(e) => {
|
||||
match e {
|
||||
PostError::Unexpected(e) => capture_anyhow(&e),
|
||||
_ => capture_anyhow(&anyhow!(e)),
|
||||
};
|
||||
Err(e) => match e {
|
||||
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
PostError::NotFound | PostError::InvalidSemanticId => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -34,12 +34,17 @@ pub async fn create_post_handler(
|
||||
|
||||
match result {
|
||||
Ok(post) => HttpResponse::Created().json(post),
|
||||
Err(e) => {
|
||||
match e {
|
||||
PostError::Unexpected(e) => capture_anyhow(&e),
|
||||
_ => capture_anyhow(&anyhow!(e)),
|
||||
};
|
||||
Err(e) => match e {
|
||||
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
|
||||
PostError::NotFound => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -23,12 +23,15 @@ pub async fn get_all_labels_handler(
|
||||
|
||||
match result {
|
||||
Ok(labels) => HttpResponse::Ok().json(labels),
|
||||
Err(e) => {
|
||||
match e {
|
||||
PostError::Unexpected(e) => capture_anyhow(&e),
|
||||
_ => capture_anyhow(&anyhow!(e)),
|
||||
};
|
||||
Err(e) => match e {
|
||||
PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -35,12 +35,15 @@ pub async fn get_all_post_info_handler(
|
||||
|
||||
match result {
|
||||
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
|
||||
Err(e) => {
|
||||
match e {
|
||||
PostError::Unexpected(e) => capture_anyhow(&e),
|
||||
_ => capture_anyhow(&anyhow!(e)),
|
||||
};
|
||||
Err(e) => match e {
|
||||
PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use anyhow::anyhow;
|
||||
use auth::framework::web::auth_middleware::UserId;
|
||||
use sentry::integrations::anyhow::capture_anyhow;
|
||||
|
||||
@ -11,8 +12,8 @@ use crate::{
|
||||
get,
|
||||
path = "/post/{id}",
|
||||
tag = "post",
|
||||
summary = "Get post by ID",
|
||||
description = "Only authenticated users can access unpublished posts.",
|
||||
summary = "Get post by ID or semantic ID",
|
||||
description = "Only authenticated users can access unpublished posts. Accepts either numeric ID or semantic ID.",
|
||||
responses (
|
||||
(status = 200, body = PostResponseDto),
|
||||
(status = 404, description = "Post not found")
|
||||
@ -20,12 +21,12 @@ use crate::{
|
||||
)]
|
||||
pub async fn get_post_by_id_handler(
|
||||
post_controller: web::Data<dyn PostController>,
|
||||
path: web::Path<i32>,
|
||||
path: web::Path<String>,
|
||||
user_id: Option<UserId>,
|
||||
) -> impl Responder {
|
||||
let id = path.into_inner();
|
||||
let id_or_semantic_id = path.into_inner();
|
||||
let result = post_controller
|
||||
.get_post_by_id(id, user_id.map(|id| id.get()))
|
||||
.get_post_by_id_or_semantic_id(&id_or_semantic_id, user_id.map(|id| id.get()))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@ -33,6 +34,10 @@ pub async fn get_post_by_id_handler(
|
||||
Err(e) => match e {
|
||||
PostError::NotFound => HttpResponse::NotFound().finish(),
|
||||
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
PostError::InvalidSemanticId => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
|
@ -1,4 +1,5 @@
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use anyhow::anyhow;
|
||||
use auth::framework::web::auth_middleware::UserId;
|
||||
use sentry::integrations::anyhow::capture_anyhow;
|
||||
|
||||
@ -39,6 +40,10 @@ pub async fn update_label_handler(
|
||||
Err(e) => match e {
|
||||
PostError::NotFound => HttpResponse::NotFound().finish(),
|
||||
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
PostError::InvalidSemanticId => {
|
||||
capture_anyhow(&anyhow!(e));
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
|
@ -39,6 +39,7 @@ pub async fn update_post_handler(
|
||||
Err(e) => match e {
|
||||
PostError::NotFound => HttpResponse::NotFound().finish(),
|
||||
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
|
||||
PostError::Unexpected(e) => {
|
||||
capture_anyhow(&e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
|
@ -0,0 +1,5 @@
|
||||
-- Drop the unique index on semantic_id
|
||||
DROP INDEX IF EXISTS idx_post_semantic_id;
|
||||
|
||||
-- Remove the semantic_id column from post table
|
||||
ALTER TABLE post DROP COLUMN IF EXISTS "semantic_id";
|
@ -0,0 +1,7 @@
|
||||
ALTER TABLE post ADD COLUMN "semantic_id" VARCHAR(100) NOT NULL DEFAULT '';
|
||||
|
||||
-- Update existing records to use their id as semantic_id with _id prefix
|
||||
UPDATE post SET semantic_id = '_' || id::VARCHAR WHERE semantic_id = '';
|
||||
|
||||
-- Create unique index on semantic_id
|
||||
CREATE UNIQUE INDEX idx_post_semantic_id ON post (semantic_id);
|
@ -36,13 +36,12 @@ use post::{
|
||||
},
|
||||
},
|
||||
application::use_case::{
|
||||
create_label_use_case::CreateLabelUseCaseImpl,
|
||||
create_post_use_case::CreatePostUseCaseImpl,
|
||||
create_label_use_case::CreateLabelUseCaseImpl, create_post_use_case::CreatePostUseCaseImpl,
|
||||
get_all_labels_use_case::GetAllLabelsUseCaseImpl,
|
||||
get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
|
||||
get_full_post_use_case::GetFullPostUseCaseImpl,
|
||||
update_label_use_case::UpdateLabelUseCaseImpl,
|
||||
update_post_use_case::UpdatePostUseCaseImpl,
|
||||
get_post_by_id_use_case::GetFullPostUseCaseImpl,
|
||||
get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCaseImpl,
|
||||
update_label_use_case::UpdateLabelUseCaseImpl, update_post_use_case::UpdatePostUseCaseImpl,
|
||||
},
|
||||
framework::db::{
|
||||
label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl,
|
||||
@ -97,7 +96,12 @@ impl Container {
|
||||
|
||||
let get_all_post_info_use_case =
|
||||
Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository.clone()));
|
||||
let get_full_post_use_case = Arc::new(GetFullPostUseCaseImpl::new(post_repository.clone()));
|
||||
let get_post_by_id_use_case =
|
||||
Arc::new(GetFullPostUseCaseImpl::new(post_repository.clone()));
|
||||
let get_post_by_semantic_id_use_case = Arc::new(GetPostBySemanticIdUseCaseImpl::new(
|
||||
post_repository.clone(),
|
||||
get_post_by_id_use_case.clone(),
|
||||
));
|
||||
let create_post_use_case = Arc::new(CreatePostUseCaseImpl::new(post_repository.clone()));
|
||||
let update_post_use_case = Arc::new(UpdatePostUseCaseImpl::new(post_repository.clone()));
|
||||
let create_label_use_case = Arc::new(CreateLabelUseCaseImpl::new(label_repository.clone()));
|
||||
@ -107,7 +111,8 @@ impl Container {
|
||||
|
||||
let post_controller = Arc::new(PostControllerImpl::new(
|
||||
get_all_post_info_use_case,
|
||||
get_full_post_use_case,
|
||||
get_post_by_id_use_case,
|
||||
get_post_by_semantic_id_use_case,
|
||||
create_post_use_case,
|
||||
update_post_use_case,
|
||||
create_label_use_case,
|
||||
|
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@ -0,0 +1 @@
|
||||
PUBLIC_API_BASE_URL=http://127.0.0.1:5173/api/
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
|
@ -18,13 +18,13 @@ export default ts.config(
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
@ -33,8 +33,8 @@ export default ts.config(
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -19,7 +19,7 @@ Sentry.init({
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// If you don't want to use Session Replay, just remove the line below:
|
||||
integrations: [replayIntegration()]
|
||||
integrations: [replayIntegration()],
|
||||
});
|
||||
|
||||
// If you have a custom error handler, pass it to `handleErrorWithSentry`
|
||||
|
@ -12,7 +12,7 @@ import { Environment } from '$lib/environment';
|
||||
Sentry.init({
|
||||
dsn: Environment.SENTRY_DSN,
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true
|
||||
enableLogs: true,
|
||||
});
|
||||
|
||||
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {
|
||||
|
@ -2,7 +2,7 @@ export enum StatusType {
|
||||
Idle,
|
||||
Loading,
|
||||
Success,
|
||||
Error
|
||||
Error,
|
||||
}
|
||||
|
||||
export interface IdleState<T> {
|
||||
|
@ -2,7 +2,7 @@
|
||||
let {
|
||||
label,
|
||||
link,
|
||||
isSelected
|
||||
isSelected,
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
|
@ -11,7 +11,7 @@
|
||||
'在這裡',
|
||||
'我會分享我的技術筆記、開發心得',
|
||||
'還有各式各樣實用工具的評測與介紹',
|
||||
'一起探索數位世界的無限可能吧!'
|
||||
'一起探索數位世界的無限可能吧!',
|
||||
];
|
||||
|
||||
let isReady: boolean = $state(false);
|
||||
|
@ -44,7 +44,7 @@
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -31,7 +31,7 @@
|
||||
'資工系',
|
||||
'軟體工程',
|
||||
'遊戲',
|
||||
'魷魚'
|
||||
'魷魚',
|
||||
];
|
||||
|
||||
// Initialize with placeholder to prevent flickering
|
||||
|
@ -5,7 +5,7 @@ export const ColorResponseSchema = z.object({
|
||||
red: z.number().int().min(0).max(255),
|
||||
green: z.number().int().min(0).max(255),
|
||||
blue: z.number().int().min(0).max(255),
|
||||
alpha: z.number().int().min(0).max(255)
|
||||
alpha: z.number().int().min(0).max(255),
|
||||
});
|
||||
|
||||
export class ColorResponseDto {
|
||||
@ -27,7 +27,7 @@ export class ColorResponseDto {
|
||||
red: parsedJson.red,
|
||||
green: parsedJson.green,
|
||||
blue: parsedJson.blue,
|
||||
alpha: parsedJson.alpha
|
||||
alpha: parsedJson.alpha,
|
||||
});
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export class ColorResponseDto {
|
||||
red: this.red,
|
||||
green: this.green,
|
||||
blue: this.blue,
|
||||
alpha: this.alpha
|
||||
alpha: this.alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { z } from 'zod';
|
||||
export const LabelResponseSchema = z.object({
|
||||
id: z.int32(),
|
||||
name: z.string(),
|
||||
color: ColorResponseSchema
|
||||
color: ColorResponseSchema,
|
||||
});
|
||||
|
||||
export class LabelResponseDto {
|
||||
@ -24,7 +24,7 @@ export class LabelResponseDto {
|
||||
return new LabelResponseDto({
|
||||
id: parsedJson.id,
|
||||
name: parsedJson.name,
|
||||
color: ColorResponseDto.fromJson(parsedJson.color)
|
||||
color: ColorResponseDto.fromJson(parsedJson.color),
|
||||
});
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export class LabelResponseDto {
|
||||
return new Label({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
color: this.color
|
||||
color: this.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -3,5 +3,5 @@ import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto'
|
||||
|
||||
export interface PostApiService {
|
||||
getAllPosts(): Promise<PostInfoResponseDto[]>;
|
||||
getPost(id: number): Promise<PostResponseDto | null>;
|
||||
getPost(id: string): Promise<PostResponseDto | null>;
|
||||
}
|
||||
|
@ -4,15 +4,17 @@ import z from 'zod';
|
||||
|
||||
export const PostInfoResponseSchema = z.object({
|
||||
id: z.int32(),
|
||||
semantic_id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
preview_image_url: z.url(),
|
||||
labels: z.array(LabelResponseSchema),
|
||||
published_time: z.iso.datetime({ offset: true }).nullable()
|
||||
published_time: z.iso.datetime({ offset: true }).nullable(),
|
||||
});
|
||||
|
||||
export class PostInfoResponseDto {
|
||||
readonly id: number;
|
||||
readonly semanticId: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly previewImageUrl: URL;
|
||||
@ -21,6 +23,7 @@ export class PostInfoResponseDto {
|
||||
|
||||
private constructor(props: {
|
||||
id: number;
|
||||
semanticId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
previewImageUrl: URL;
|
||||
@ -28,6 +31,7 @@ export class PostInfoResponseDto {
|
||||
publishedTime: Date | null;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.semanticId = props.semanticId;
|
||||
this.title = props.title;
|
||||
this.description = props.description;
|
||||
this.previewImageUrl = props.previewImageUrl;
|
||||
@ -45,22 +49,24 @@ export class PostInfoResponseDto {
|
||||
|
||||
return new PostInfoResponseDto({
|
||||
id: parsedJson.id,
|
||||
semanticId: parsedJson.semantic_id,
|
||||
title: parsedJson.title,
|
||||
description: parsedJson.description,
|
||||
previewImageUrl: new URL(parsedJson.preview_image_url),
|
||||
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
|
||||
publishedTime: published_time
|
||||
publishedTime: published_time,
|
||||
});
|
||||
}
|
||||
|
||||
toEntity(): PostInfo {
|
||||
return new PostInfo({
|
||||
id: this.id,
|
||||
semanticId: this.semanticId,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
previewImageUrl: this.previewImageUrl,
|
||||
labels: this.labels.map((label) => label.toEntity()),
|
||||
publishedTime: this.publishedTime
|
||||
publishedTime: this.publishedTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export class PostRepositoryImpl implements PostRepository {
|
||||
return dtos.map((dto) => dto.toEntity());
|
||||
}
|
||||
|
||||
async getPost(id: number): Promise<Post | null> {
|
||||
async getPost(id: string): Promise<Post | null> {
|
||||
const dto = await this.postApiService.getPost(id);
|
||||
return dto?.toEntity() ?? null;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
PostInfoResponseDto,
|
||||
PostInfoResponseSchema
|
||||
PostInfoResponseSchema,
|
||||
} from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||
import { Post } from '$lib/post/domain/entity/post';
|
||||
import z from 'zod';
|
||||
@ -8,7 +8,7 @@ import z from 'zod';
|
||||
export const PostResponseSchema = z.object({
|
||||
id: z.int32(),
|
||||
info: PostInfoResponseSchema,
|
||||
content: z.string()
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export class PostResponseDto {
|
||||
@ -27,7 +27,7 @@ export class PostResponseDto {
|
||||
return new PostResponseDto({
|
||||
id: parsedJson.id,
|
||||
info: PostInfoResponseDto.fromJson(parsedJson.info),
|
||||
content: parsedJson.content
|
||||
content: parsedJson.content,
|
||||
});
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export class PostResponseDto {
|
||||
return new Post({
|
||||
id: this.id,
|
||||
info: this.info.toEntity(),
|
||||
content: this.content
|
||||
content: this.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ export class ColorViewModel {
|
||||
red: Math.round(r * 255),
|
||||
green: Math.round(g * 255),
|
||||
blue: Math.round(b * 255),
|
||||
alpha: 255
|
||||
alpha: 255,
|
||||
});
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ export class ColorViewModel {
|
||||
red: color.red,
|
||||
green: color.green,
|
||||
blue: color.blue,
|
||||
alpha: color.alpha
|
||||
alpha: color.alpha,
|
||||
});
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ export class ColorViewModel {
|
||||
red: this.red,
|
||||
green: this.green,
|
||||
blue: this.blue,
|
||||
alpha: this.alpha
|
||||
alpha: this.alpha,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
ColorViewModel,
|
||||
type DehydratedColorProps
|
||||
type DehydratedColorProps,
|
||||
} from '$lib/post/adapter/presenter/colorViewModel';
|
||||
import type { Label } from '$lib/post/domain/entity/label';
|
||||
|
||||
@ -19,7 +19,7 @@ export class LabelViewModel {
|
||||
return new LabelViewModel({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: ColorViewModel.fromEntity(label.color)
|
||||
color: ColorViewModel.fromEntity(label.color),
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export class LabelViewModel {
|
||||
return new LabelViewModel({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
color: ColorViewModel.rehydrate(props.color)
|
||||
color: ColorViewModel.rehydrate(props.color),
|
||||
});
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export class LabelViewModel {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
color: this.color.dehydrate()
|
||||
color: this.color.dehydrate(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ export type PostEvent = PostLoadedEvent;
|
||||
|
||||
export class PostBloc {
|
||||
private readonly state = writable<PostState>({
|
||||
status: StatusType.Idle
|
||||
status: StatusType.Idle,
|
||||
});
|
||||
|
||||
constructor(
|
||||
@ -17,7 +17,7 @@ export class PostBloc {
|
||||
) {
|
||||
this.state.set({
|
||||
status: StatusType.Idle,
|
||||
data: initialData
|
||||
data: initialData,
|
||||
});
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export class PostBloc {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPost(id: number): Promise<PostState> {
|
||||
private async loadPost(id: string): Promise<PostState> {
|
||||
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
|
||||
|
||||
const post = await this.getPostUseCase.execute(id);
|
||||
@ -44,7 +44,7 @@ export class PostBloc {
|
||||
const postViewModel = PostViewModel.fromEntity(post);
|
||||
const result: PostState = {
|
||||
status: StatusType.Success,
|
||||
data: postViewModel
|
||||
data: postViewModel,
|
||||
};
|
||||
|
||||
this.state.set(result);
|
||||
@ -53,10 +53,10 @@ export class PostBloc {
|
||||
}
|
||||
|
||||
export enum PostEventType {
|
||||
PostLoadedEvent
|
||||
PostLoadedEvent,
|
||||
}
|
||||
|
||||
export interface PostLoadedEvent {
|
||||
interface PostLoadedEvent {
|
||||
event: PostEventType.PostLoadedEvent;
|
||||
id: number;
|
||||
id: string;
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import {
|
||||
LabelViewModel,
|
||||
type DehydratedLabelProps
|
||||
type DehydratedLabelProps,
|
||||
} from '$lib/post/adapter/presenter/labelViewModel';
|
||||
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
|
||||
export class PostInfoViewModel {
|
||||
readonly id: number;
|
||||
readonly semanticId: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly previewImageUrl: URL;
|
||||
@ -14,6 +15,7 @@ export class PostInfoViewModel {
|
||||
|
||||
private constructor(props: {
|
||||
id: number;
|
||||
semanticId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
previewImageUrl: URL;
|
||||
@ -21,6 +23,7 @@ export class PostInfoViewModel {
|
||||
publishedTime: Date | null;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.semanticId = props.semanticId;
|
||||
this.title = props.title;
|
||||
this.description = props.description;
|
||||
this.previewImageUrl = props.previewImageUrl;
|
||||
@ -31,11 +34,12 @@ export class PostInfoViewModel {
|
||||
static fromEntity(postInfo: PostInfo): PostInfoViewModel {
|
||||
return new PostInfoViewModel({
|
||||
id: postInfo.id,
|
||||
semanticId: postInfo.semanticId,
|
||||
title: postInfo.title,
|
||||
description: postInfo.description,
|
||||
previewImageUrl: postInfo.previewImageUrl,
|
||||
labels: postInfo.labels.map((label) => LabelViewModel.fromEntity(label)),
|
||||
publishedTime: postInfo.publishedTime
|
||||
publishedTime: postInfo.publishedTime,
|
||||
});
|
||||
}
|
||||
|
||||
@ -47,11 +51,12 @@ export class PostInfoViewModel {
|
||||
|
||||
return new PostInfoViewModel({
|
||||
id: props.id,
|
||||
semanticId: props.semanticId,
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
previewImageUrl: new URL(props.previewImageUrl),
|
||||
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
|
||||
publishedTime: publishedTime
|
||||
publishedTime: publishedTime,
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,17 +71,19 @@ export class PostInfoViewModel {
|
||||
dehydrate(): DehydratedPostInfoProps {
|
||||
return {
|
||||
id: this.id,
|
||||
semanticId: this.semanticId,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
previewImageUrl: this.previewImageUrl.href,
|
||||
labels: this.labels.map((label) => label.dehydrate()),
|
||||
publishedTime: this.publishedTime?.getTime() ?? null
|
||||
publishedTime: this.publishedTime?.getTime() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface DehydratedPostInfoProps {
|
||||
id: number;
|
||||
semanticId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
previewImageUrl: string;
|
||||
|
@ -8,7 +8,7 @@ export type PostListEvent = PostListLoadedEvent;
|
||||
|
||||
export class PostListBloc {
|
||||
private readonly state = writable<PostListState>({
|
||||
status: StatusType.Idle
|
||||
status: StatusType.Idle,
|
||||
});
|
||||
|
||||
constructor(
|
||||
@ -17,7 +17,7 @@ export class PostListBloc {
|
||||
) {
|
||||
this.state.set({
|
||||
status: StatusType.Idle,
|
||||
data: initialData
|
||||
data: initialData,
|
||||
});
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ export class PostListBloc {
|
||||
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
|
||||
const result: PostListState = {
|
||||
status: StatusType.Success,
|
||||
data: postViewModels
|
||||
data: postViewModels,
|
||||
};
|
||||
|
||||
this.state.set(result);
|
||||
@ -47,7 +47,7 @@ export class PostListBloc {
|
||||
}
|
||||
|
||||
export enum PostListEventType {
|
||||
PostListLoadedEvent
|
||||
PostListLoadedEvent,
|
||||
}
|
||||
|
||||
export interface PostListLoadedEvent {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
PostInfoViewModel,
|
||||
type DehydratedPostInfoProps
|
||||
type DehydratedPostInfoProps,
|
||||
} from '$lib/post/adapter/presenter/postInfoViewModel';
|
||||
import type { Post } from '$lib/post/domain/entity/post';
|
||||
|
||||
@ -19,7 +19,7 @@ export class PostViewModel {
|
||||
return new PostViewModel({
|
||||
id: post.id,
|
||||
info: PostInfoViewModel.fromEntity(post.info),
|
||||
content: post.content
|
||||
content: post.content,
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export class PostViewModel {
|
||||
return new PostViewModel({
|
||||
id: props.id,
|
||||
info: PostInfoViewModel.rehydrate(props.info),
|
||||
content: props.content
|
||||
content: props.content,
|
||||
});
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export class PostViewModel {
|
||||
return {
|
||||
id: this.id,
|
||||
info: this.info.dehydrate(),
|
||||
content: this.content
|
||||
content: this.content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -3,5 +3,5 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
|
||||
export interface PostRepository {
|
||||
getAllPosts(): Promise<PostInfo[]>;
|
||||
getPost(id: number): Promise<Post | null>;
|
||||
getPost(id: string): Promise<Post | null>;
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import type { Post } from '$lib/post/domain/entity/post';
|
||||
export class GetPostUseCase {
|
||||
constructor(private readonly postRepository: PostRepository) {}
|
||||
|
||||
execute(id: number): Promise<Post | null> {
|
||||
execute(id: string): Promise<Post | null> {
|
||||
return this.postRepository.getPost(id);
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import type { Label } from '$lib/post/domain/entity/label';
|
||||
|
||||
export class PostInfo {
|
||||
readonly id: number;
|
||||
readonly semanticId: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly previewImageUrl: URL;
|
||||
@ -10,6 +11,7 @@ export class PostInfo {
|
||||
|
||||
constructor(props: {
|
||||
id: number;
|
||||
semanticId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
previewImageUrl: URL;
|
||||
@ -17,6 +19,7 @@ export class PostInfo {
|
||||
publishedTime: Date | null;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.semanticId = props.semanticId;
|
||||
this.title = props.title;
|
||||
this.description = props.description;
|
||||
this.previewImageUrl = props.previewImageUrl;
|
||||
|
@ -19,7 +19,7 @@ export class PostApiServiceImpl implements PostApiService {
|
||||
return json.map(PostInfoResponseDto.fromJson);
|
||||
}
|
||||
|
||||
async getPost(id: number): Promise<PostResponseDto | null> {
|
||||
async getPost(id: string): Promise<PostResponseDto | null> {
|
||||
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
|
||||
|
||||
const response = await this.fetchFn(url.href);
|
||||
|
@ -7,7 +7,7 @@
|
||||
import generateTitle from '$lib/common/framework/ui/generateTitle';
|
||||
import StructuredData from '$lib/post/framework/ui/StructuredData.svelte';
|
||||
|
||||
const { id }: { id: number } = $props();
|
||||
const { id }: { id: string } = $props();
|
||||
|
||||
const postBloc = getContext<PostBloc>(PostBloc.name);
|
||||
const state = $derived($postBloc);
|
||||
|
@ -17,7 +17,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<a class="flex cursor-pointer flex-col gap-y-6" href="/post/{postInfo.id}" title={postInfo.title}>
|
||||
<a
|
||||
class="flex cursor-pointer flex-col gap-y-6"
|
||||
href="/post/{postInfo.semanticId}"
|
||||
title={postInfo.title}
|
||||
>
|
||||
<div class="relative aspect-video overflow-hidden rounded-2xl bg-gray-200">
|
||||
<img
|
||||
class="rounded-2xl object-cover transition-opacity duration-300
|
||||
|
@ -5,7 +5,7 @@
|
||||
headline,
|
||||
description,
|
||||
datePublished,
|
||||
image
|
||||
image,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
@ -19,7 +19,7 @@
|
||||
headline: headline,
|
||||
description: description,
|
||||
datePublished: datePublished.toISOString(),
|
||||
image: image.href
|
||||
image: image.href,
|
||||
});
|
||||
|
||||
const jsonLdScript = $derived(
|
||||
|
@ -7,6 +7,6 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const state = await postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent });
|
||||
|
||||
return {
|
||||
dehydratedData: state.data?.map((post) => post.dehydrate())
|
||||
dehydratedData: state.data?.map((post) => post.dehydrate()),
|
||||
};
|
||||
};
|
||||
|
@ -5,20 +5,15 @@ import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { postBloc } = locals;
|
||||
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id) || id <= 0) {
|
||||
error(400, { message: 'Invalid post ID' });
|
||||
}
|
||||
|
||||
const state = await postBloc.dispatch({
|
||||
event: PostEventType.PostLoadedEvent,
|
||||
id: id
|
||||
id: params.id,
|
||||
});
|
||||
if (!state.data) {
|
||||
error(404, { message: 'Post not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
dehydratedData: state.data.dehydrate()
|
||||
dehydratedData: state.data.dehydrate(),
|
||||
};
|
||||
};
|
||||
|
@ -9,8 +9,7 @@
|
||||
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
|
||||
|
||||
const { data, params }: PageProps = $props();
|
||||
|
||||
const id = parseInt(params.id, 10);
|
||||
const { id } = params;
|
||||
|
||||
const initialData = PostViewModel.rehydrate(data.dehydratedData!);
|
||||
|
||||
|
@ -11,8 +11,8 @@ const config = {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
@ -9,10 +9,10 @@ module.exports = {
|
||||
'--tw-prose-headings': 'var(--color-gray-800)',
|
||||
'--tw-prose-links': 'var(--color-gray-800)',
|
||||
'--tw-prose-bold': 'var(--color-gray-800)',
|
||||
'--tw-prose-quotes': 'var(--color-gray-800)'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
'--tw-prose-quotes': 'var(--color-gray-800)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -10,22 +10,22 @@ export default defineConfig({
|
||||
sentrySvelteKit({
|
||||
sourceMapsUploadOptions: {
|
||||
org: 'squidspirit',
|
||||
project: 'blog-beta-frontend'
|
||||
}
|
||||
project: 'blog-beta-frontend',
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
sveltekit()
|
||||
sveltekit(),
|
||||
],
|
||||
define: {
|
||||
'App.__VERSION__': JSON.stringify(version)
|
||||
'App.__VERSION__': JSON.stringify(version),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user