BLOG-125 Get post by sementic ID (#134)
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

![截圖 2025-10-12 下午6.23.12.png](/attachments/67de1cd7-f584-40ad-9bbd-27f8bf6f1894)

### 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:
SquidSpirit 2025-10-12 18:28:58 +08:00 committed by squid
parent 7c32d347b4
commit 565df7aace
68 changed files with 398 additions and 160 deletions

21
backend/.env.example Normal file
View 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
View File

@ -2407,6 +2407,7 @@ dependencies = [
"auth", "auth",
"chrono", "chrono",
"common", "common",
"regex",
"sentry", "sentry",
"serde", "serde",
"sqlx", "sqlx",
@ -2638,9 +2639,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.11.1" version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -2650,9 +2651,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.9" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",

View File

@ -5,7 +5,6 @@ members = [
"feature/common", "feature/common",
"feature/image", "feature/image",
"feature/post", "feature/post",
"feature/common",
] ]
resolver = "2" resolver = "2"
@ -33,6 +32,7 @@ openidconnect = { version = "4.0.1", features = [
"reqwest-blocking", "reqwest-blocking",
] } ] }
percent-encoding = "2.3.1" percent-encoding = "2.3.1"
regex = "1.12.1"
sentry = { version = "0.42.0", features = ["actix", "anyhow"] } sentry = { version = "0.42.0", features = ["actix", "anyhow"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.5", features = [ sqlx = { version = "0.8.5", features = [

View File

@ -8,6 +8,7 @@ actix-web.workspace = true
anyhow.workspace = true anyhow.workspace = true
async-trait.workspace = true async-trait.workspace = true
chrono.workspace = true chrono.workspace = true
regex.workspace = true
sentry.workspace = true sentry.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true sqlx.workspace = true

View File

@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)] #[derive(Deserialize, ToSchema, Clone)]
pub struct CreatePostRequestDto { pub struct CreatePostRequestDto {
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub content: String, pub content: String,
@ -24,6 +25,7 @@ impl CreatePostRequestDto {
id: -1, id: -1,
info: PostInfo { info: PostInfo {
id: -1, id: -1,
semantic_id: self.semantic_id,
title: self.title, title: self.title,
description: self.description, description: self.description,
preview_image_url: self.preview_image_url, preview_image_url: self.preview_image_url,

View File

@ -15,8 +15,9 @@ use crate::{
create_label_use_case::CreateLabelUseCase, create_post_use_case::CreatePostUseCase, create_label_use_case::CreateLabelUseCase, create_post_use_case::CreatePostUseCase,
get_all_labels_use_case::GetAllLabelsUseCase, get_all_labels_use_case::GetAllLabelsUseCase,
get_all_post_info_use_case::GetAllPostInfoUseCase, get_all_post_info_use_case::GetAllPostInfoUseCase,
get_full_post_use_case::GetFullPostUseCase, update_label_use_case::UpdateLabelUseCase, get_post_by_id_use_case::GetPostByIdUseCase,
update_post_use_case::UpdatePostUseCase, 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>, user_id: Option<i32>,
) -> Result<Vec<PostInfoResponseDto>, PostError>; ) -> Result<Vec<PostInfoResponseDto>, PostError>;
async fn get_post_by_id( async fn get_post_by_id_or_semantic_id(
&self, &self,
id: i32, id_or_semantic_id: &str,
user_id: Option<i32>, user_id: Option<i32>,
) -> Result<PostResponseDto, PostError>; ) -> Result<PostResponseDto, PostError>;
@ -69,7 +70,8 @@ pub trait PostController: Send + Sync {
pub struct PostControllerImpl { pub struct PostControllerImpl {
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>, 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>, create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>, update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>, create_label_use_case: Arc<dyn CreateLabelUseCase>,
@ -80,7 +82,8 @@ pub struct PostControllerImpl {
impl PostControllerImpl { impl PostControllerImpl {
pub fn new( pub fn new(
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>, 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>, create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>, update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>, create_label_use_case: Arc<dyn CreateLabelUseCase>,
@ -89,7 +92,8 @@ impl PostControllerImpl {
) -> Self { ) -> Self {
Self { Self {
get_all_post_info_use_case, 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, create_post_use_case,
update_post_use_case, update_post_use_case,
create_label_use_case, create_label_use_case,
@ -97,6 +101,29 @@ impl PostControllerImpl {
get_all_labels_use_case, 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] #[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, &self,
id: i32, id_or_semantic_id: &str,
user_id: Option<i32>, user_id: Option<i32>,
) -> Result<PostResponseDto, PostError> { ) -> Result<PostResponseDto, PostError> {
let result = self.get_full_post_use_case.execute(id, user_id).await; if let Ok(id) = id_or_semantic_id.parse::<i32>() {
self.get_post_by_id(id, user_id).await
result.map(PostResponseDto::from) } else {
let semantic_id = id_or_semantic_id;
self.get_post_by_semantic_id(semantic_id, user_id).await
}
} }
async fn create_label( async fn create_label(

View File

@ -8,6 +8,7 @@ use super::label_response_dto::LabelResponseDto;
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct PostInfoResponseDto { pub struct PostInfoResponseDto {
pub id: i32, pub id: i32,
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub labels: Vec<LabelResponseDto>, pub labels: Vec<LabelResponseDto>,
@ -23,6 +24,7 @@ impl From<PostInfo> for PostInfoResponseDto {
fn from(entity: PostInfo) -> Self { fn from(entity: PostInfo) -> Self {
Self { Self {
id: entity.id, id: entity.id,
semantic_id: entity.semantic_id,
title: entity.title, title: entity.title,
description: entity.description, description: entity.description,
preview_image_url: entity.preview_image_url, preview_image_url: entity.preview_image_url,

View File

@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)] #[derive(Deserialize, ToSchema, Clone)]
pub struct UpdatePostRequestDto { pub struct UpdatePostRequestDto {
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub content: String, pub content: String,
@ -24,6 +25,7 @@ impl UpdatePostRequestDto {
id, id,
info: PostInfo { info: PostInfo {
id, id,
semantic_id: self.semantic_id,
title: self.title, title: self.title,
description: self.description, description: self.description,
preview_image_url: self.preview_image_url, preview_image_url: self.preview_image_url,

View File

@ -14,4 +14,5 @@ pub trait PostDbService: Send + Sync {
async fn get_post_by_id(&self, id: i32) -> Result<PostMapper, PostError>; 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 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 update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError>;
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError>;
} }

View File

@ -4,6 +4,7 @@ use crate::{adapter::gateway::label_db_mapper::LabelMapper, domain::entity::post
pub struct PostInfoMapper { pub struct PostInfoMapper {
pub id: i32, pub id: i32,
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: String,
@ -15,13 +16,18 @@ impl PostInfoMapper {
pub fn into_entity(self) -> PostInfo { pub fn into_entity(self) -> PostInfo {
PostInfo { PostInfo {
id: self.id, id: self.id,
semantic_id: self.semantic_id,
title: self.title.clone(), title: self.title.clone(),
description: self.description.clone(), description: self.description.clone(),
preview_image_url: self.preview_image_url.clone(), preview_image_url: self.preview_image_url.clone(),
published_time: self published_time: self
.published_time .published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)), .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(),
} }
} }
} }

View File

@ -44,6 +44,7 @@ impl PostRepository for PostRepositoryImpl {
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> { async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
let info_mapper = PostInfoMapper { let info_mapper = PostInfoMapper {
id: post.info.id, id: post.info.id,
semantic_id: post.info.semantic_id,
title: post.info.title, title: post.info.title,
description: post.info.description, description: post.info.description,
preview_image_url: post.info.preview_image_url, 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> { async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
let info_mapper = PostInfoMapper { let info_mapper = PostInfoMapper {
id: post.info.id, id: post.info.id,
semantic_id: post.info.semantic_id,
title: post.info.title, title: post.info.title,
description: post.info.description, description: post.info.description,
preview_image_url: post.info.preview_image_url, preview_image_url: post.info.preview_image_url,
@ -82,4 +84,8 @@ impl PostRepository for PostRepositoryImpl {
.update_post(post_mapper, label_ids) .update_post(post_mapper, label_ids)
.await .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
}
} }

View File

@ -4,6 +4,7 @@ use std::fmt::Display;
pub enum PostError { pub enum PostError {
NotFound, NotFound,
Unauthorized, Unauthorized,
InvalidSemanticId,
Unexpected(anyhow::Error), Unexpected(anyhow::Error),
} }
@ -12,6 +13,10 @@ impl Display for PostError {
match self { match self {
PostError::NotFound => write!(f, "Post not found"), PostError::NotFound => write!(f, "Post not found"),
PostError::Unauthorized => write!(f, "Unauthorized access to post"), 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), PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
} }
} }

View File

@ -11,4 +11,5 @@ pub trait PostRepository: Send + Sync {
async fn get_post_by_id(&self, id: i32) -> Result<Post, PostError>; 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 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 update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result<i32, PostError>;
} }

View File

@ -2,6 +2,7 @@ pub mod create_label_use_case;
pub mod create_post_use_case; pub mod create_post_use_case;
pub mod get_all_labels_use_case; pub mod get_all_labels_use_case;
pub mod get_all_post_info_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_label_use_case;
pub mod update_post_use_case; pub mod update_post_use_case;

View File

@ -25,8 +25,7 @@ impl CreatePostUseCaseImpl {
#[async_trait] #[async_trait]
impl CreatePostUseCase for CreatePostUseCaseImpl { impl CreatePostUseCase for CreatePostUseCaseImpl {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> { async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
self.post_repository post.validate()?;
.create_post(post, label_ids) self.post_repository.create_post(post, label_ids).await
.await
} }
} }

View File

@ -8,7 +8,7 @@ use crate::{
}; };
#[async_trait] #[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>; async fn execute(&self, id: i32, user_id: Option<i32>) -> Result<Post, PostError>;
} }
@ -23,7 +23,7 @@ impl GetFullPostUseCaseImpl {
} }
#[async_trait] #[async_trait]
impl GetFullPostUseCase for GetFullPostUseCaseImpl { impl GetPostByIdUseCase for GetFullPostUseCaseImpl {
async fn execute(&self, id: i32, user_id: Option<i32>) -> Result<Post, PostError> { async fn execute(&self, id: i32, user_id: Option<i32>) -> Result<Post, PostError> {
let post = self.post_repository.get_post_by_id(id).await?; let post = self.post_repository.get_post_by_id(id).await?;

View File

@ -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
}
}

View File

@ -25,6 +25,7 @@ impl UpdatePostUseCaseImpl {
#[async_trait] #[async_trait]
impl UpdatePostUseCase for UpdatePostUseCaseImpl { impl UpdatePostUseCase for UpdatePostUseCaseImpl {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> { async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
post.validate()?;
self.post_repository.update_post(post, label_ids).await self.post_repository.update_post(post, label_ids).await
} }
} }

View File

@ -1,3 +1,5 @@
use crate::application::error::post_error::PostError;
use super::post_info::PostInfo; use super::post_info::PostInfo;
pub struct Post { pub struct Post {
@ -5,3 +7,10 @@ pub struct Post {
pub info: PostInfo, pub info: PostInfo,
pub content: String, pub content: String,
} }
impl Post {
pub fn validate(&self) -> Result<(), PostError> {
self.info.validate()?;
Ok(())
}
}

View File

@ -1,12 +1,31 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use regex::Regex;
use crate::application::error::post_error::PostError;
use super::label::Label; use super::label::Label;
pub struct PostInfo { pub struct PostInfo {
pub id: i32, pub id: i32,
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: String,
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub published_time: Option<DateTime<Utc>>, 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(())
}
}

View File

@ -37,6 +37,7 @@ impl PostDbService for PostDbServiceImpl {
r#" r#"
SELECT SELECT
p.id AS post_id, p.id AS post_id,
p.semantic_id,
p.title, p.title,
p.description, p.description,
p.preview_image_url, p.preview_image_url,
@ -74,6 +75,7 @@ impl PostDbService for PostDbServiceImpl {
.entry(record.post_id) .entry(record.post_id)
.or_insert_with(|| PostInfoMapper { .or_insert_with(|| PostInfoMapper {
id: record.post_id, id: record.post_id,
semantic_id: record.semantic_id.clone(),
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
preview_image_url: record.preview_image_url.clone(), preview_image_url: record.preview_image_url.clone(),
@ -111,6 +113,7 @@ impl PostDbService for PostDbServiceImpl {
r#" r#"
SELECT SELECT
p.id AS post_id, p.id AS post_id,
p.semantic_id,
p.title, p.title,
p.description, p.description,
p.preview_image_url, p.preview_image_url,
@ -151,6 +154,7 @@ impl PostDbService for PostDbServiceImpl {
.or_insert_with(|| PostMapper { .or_insert_with(|| PostMapper {
id: record.post_id, id: record.post_id,
info: PostInfoMapper { info: PostInfoMapper {
semantic_id: record.semantic_id.clone(),
id: record.post_id, id: record.post_id,
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
@ -194,10 +198,11 @@ impl PostDbService for PostDbServiceImpl {
let post_id = sqlx::query_scalar!( let post_id = sqlx::query_scalar!(
r#" r#"
INSERT INTO post ( INSERT INTO post (
title, description, preview_image_url, content, published_time semantic_id, title, description, preview_image_url, content, published_time
) VALUES ($1, $2, $3, $4, $5) ) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id RETURNING id
"#, "#,
post.info.semantic_id,
post.info.title, post.info.title,
post.info.description, post.info.description,
post.info.preview_image_url, post.info.preview_image_url,
@ -243,13 +248,15 @@ impl PostDbService for PostDbServiceImpl {
r#" r#"
UPDATE post UPDATE post
SET SET
title = $1, semantic_id = $1,
description = $2, title = $2,
preview_image_url = $3, description = $3,
content = $4, preview_image_url = $4,
published_time = $5 content = $5,
WHERE id = $6 published_time = $6
WHERE id = $7
"#, "#,
post.info.semantic_id,
post.info.title, post.info.title,
post.info.description, post.info.description,
post.info.preview_image_url, post.info.preview_image_url,
@ -300,4 +307,23 @@ impl PostDbService for PostDbServiceImpl {
Ok(()) 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),
}
}
} }

View File

@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub struct PostInfoWithLabelRecord { pub struct PostInfoWithLabelRecord {
pub post_id: i32, pub post_id: i32,
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: String,

View File

@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub struct PostWithLabelRecord { pub struct PostWithLabelRecord {
pub post_id: i32, pub post_id: i32,
pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: String,

View File

@ -32,12 +32,16 @@ pub async fn create_label_handler(
match result { match result {
Ok(label) => HttpResponse::Created().json(label), Ok(label) => HttpResponse::Created().json(label),
Err(e) => { Err(e) => match e {
match e { PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::Unexpected(e) => capture_anyhow(&e), PostError::NotFound | PostError::InvalidSemanticId => {
_ => capture_anyhow(&anyhow!(e)), capture_anyhow(&anyhow!(e));
}; HttpResponse::InternalServerError().finish()
HttpResponse::InternalServerError().finish() }
} PostError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},
} }
} }

View File

@ -34,12 +34,17 @@ pub async fn create_post_handler(
match result { match result {
Ok(post) => HttpResponse::Created().json(post), Ok(post) => HttpResponse::Created().json(post),
Err(e) => { Err(e) => match e {
match e { PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::Unexpected(e) => capture_anyhow(&e), PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
_ => capture_anyhow(&anyhow!(e)), PostError::NotFound => {
}; capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }
PostError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},
} }
} }

View File

@ -23,12 +23,15 @@ pub async fn get_all_labels_handler(
match result { match result {
Ok(labels) => HttpResponse::Ok().json(labels), Ok(labels) => HttpResponse::Ok().json(labels),
Err(e) => { Err(e) => match e {
match e { PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => {
PostError::Unexpected(e) => capture_anyhow(&e), capture_anyhow(&anyhow!(e));
_ => capture_anyhow(&anyhow!(e)), HttpResponse::InternalServerError().finish()
}; }
HttpResponse::InternalServerError().finish() PostError::Unexpected(e) => {
} capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},
} }
} }

View File

@ -35,12 +35,15 @@ pub async fn get_all_post_info_handler(
match result { match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list), Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
Err(e) => { Err(e) => match e {
match e { PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => {
PostError::Unexpected(e) => capture_anyhow(&e), capture_anyhow(&anyhow!(e));
_ => capture_anyhow(&anyhow!(e)), HttpResponse::InternalServerError().finish()
}; }
HttpResponse::InternalServerError().finish() PostError::Unexpected(e) => {
} capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},
} }
} }

View File

@ -1,4 +1,5 @@
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId; use auth::framework::web::auth_middleware::UserId;
use sentry::integrations::anyhow::capture_anyhow; use sentry::integrations::anyhow::capture_anyhow;
@ -11,8 +12,8 @@ use crate::{
get, get,
path = "/post/{id}", path = "/post/{id}",
tag = "post", tag = "post",
summary = "Get post by ID", summary = "Get post by ID or semantic ID",
description = "Only authenticated users can access unpublished posts.", description = "Only authenticated users can access unpublished posts. Accepts either numeric ID or semantic ID.",
responses ( responses (
(status = 200, body = PostResponseDto), (status = 200, body = PostResponseDto),
(status = 404, description = "Post not found") (status = 404, description = "Post not found")
@ -20,12 +21,12 @@ use crate::{
)] )]
pub async fn get_post_by_id_handler( pub async fn get_post_by_id_handler(
post_controller: web::Data<dyn PostController>, post_controller: web::Data<dyn PostController>,
path: web::Path<i32>, path: web::Path<String>,
user_id: Option<UserId>, user_id: Option<UserId>,
) -> impl Responder { ) -> impl Responder {
let id = path.into_inner(); let id_or_semantic_id = path.into_inner();
let result = post_controller 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; .await;
match result { match result {
@ -33,6 +34,10 @@ pub async fn get_post_by_id_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => { PostError::Unexpected(e) => {
capture_anyhow(&e); capture_anyhow(&e);
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()

View File

@ -1,4 +1,5 @@
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId; use auth::framework::web::auth_middleware::UserId;
use sentry::integrations::anyhow::capture_anyhow; use sentry::integrations::anyhow::capture_anyhow;
@ -39,6 +40,10 @@ pub async fn update_label_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => { PostError::Unexpected(e) => {
capture_anyhow(&e); capture_anyhow(&e);
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()

View File

@ -39,6 +39,7 @@ pub async fn update_post_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
PostError::Unexpected(e) => { PostError::Unexpected(e) => {
capture_anyhow(&e); capture_anyhow(&e);
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()

View File

@ -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";

View File

@ -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);

View File

@ -36,13 +36,12 @@ use post::{
}, },
}, },
application::use_case::{ application::use_case::{
create_label_use_case::CreateLabelUseCaseImpl, create_label_use_case::CreateLabelUseCaseImpl, create_post_use_case::CreatePostUseCaseImpl,
create_post_use_case::CreatePostUseCaseImpl,
get_all_labels_use_case::GetAllLabelsUseCaseImpl, get_all_labels_use_case::GetAllLabelsUseCaseImpl,
get_all_post_info_use_case::GetAllPostInfoUseCaseImpl, get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
get_full_post_use_case::GetFullPostUseCaseImpl, get_post_by_id_use_case::GetFullPostUseCaseImpl,
update_label_use_case::UpdateLabelUseCaseImpl, get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCaseImpl,
update_post_use_case::UpdatePostUseCaseImpl, update_label_use_case::UpdateLabelUseCaseImpl, update_post_use_case::UpdatePostUseCaseImpl,
}, },
framework::db::{ framework::db::{
label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl, label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl,
@ -97,7 +96,12 @@ impl Container {
let get_all_post_info_use_case = let get_all_post_info_use_case =
Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository.clone())); 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 create_post_use_case = Arc::new(CreatePostUseCaseImpl::new(post_repository.clone()));
let update_post_use_case = Arc::new(UpdatePostUseCaseImpl::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())); 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( let post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case, 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, create_post_use_case,
update_post_use_case, update_post_use_case,
create_label_use_case, create_label_use_case,

1
frontend/.env.example Normal file
View File

@ -0,0 +1 @@
PUBLIC_API_BASE_URL=http://127.0.0.1:5173/api/

View File

@ -1,7 +1,7 @@
{ {
"useTabs": true, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "es5",
"printWidth": 100, "printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [ "overrides": [

View File

@ -18,13 +18,13 @@ export default ts.config(
...svelte.configs.prettier, ...svelte.configs.prettier,
{ {
languageOptions: { languageOptions: {
globals: { ...globals.browser, ...globals.node } globals: { ...globals.browser, ...globals.node },
}, },
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // 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 // 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'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
@ -33,8 +33,8 @@ export default ts.config(
projectService: true, projectService: true,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
parser: ts.parser, parser: ts.parser,
svelteConfig svelteConfig,
} },
} },
} }
); );

View File

@ -19,7 +19,7 @@ Sentry.init({
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
// If you don't want to use Session Replay, just remove the line below: // 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` // If you have a custom error handler, pass it to `handleErrorWithSentry`

View File

@ -12,7 +12,7 @@ import { Environment } from '$lib/environment';
Sentry.init({ Sentry.init({
dsn: Environment.SENTRY_DSN, dsn: Environment.SENTRY_DSN,
tracesSampleRate: 1, tracesSampleRate: 1,
enableLogs: true enableLogs: true,
}); });
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => { export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {

View File

@ -2,7 +2,7 @@ export enum StatusType {
Idle, Idle,
Loading, Loading,
Success, Success,
Error Error,
} }
export interface IdleState<T> { export interface IdleState<T> {

View File

@ -2,7 +2,7 @@
let { let {
label, label,
link, link,
isSelected isSelected,
}: { }: {
label: string; label: string;
link: string; link: string;

View File

@ -11,7 +11,7 @@
'在這裡', '在這裡',
'我會分享我的技術筆記、開發心得', '我會分享我的技術筆記、開發心得',
'還有各式各樣實用工具的評測與介紹', '還有各式各樣實用工具的評測與介紹',
'一起探索數位世界的無限可能吧!' '一起探索數位世界的無限可能吧!',
]; ];
let isReady: boolean = $state(false); let isReady: boolean = $state(false);

View File

@ -44,7 +44,7 @@
hour12: false, hour12: false,
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit',
}); });
} }
</script> </script>

View File

@ -31,7 +31,7 @@
'資工系', '資工系',
'軟體工程', '軟體工程',
'遊戲', '遊戲',
'魷魚' '魷魚',
]; ];
// Initialize with placeholder to prevent flickering // Initialize with placeholder to prevent flickering

View File

@ -5,7 +5,7 @@ export const ColorResponseSchema = z.object({
red: z.number().int().min(0).max(255), red: z.number().int().min(0).max(255),
green: z.number().int().min(0).max(255), green: z.number().int().min(0).max(255),
blue: 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 { export class ColorResponseDto {
@ -27,7 +27,7 @@ export class ColorResponseDto {
red: parsedJson.red, red: parsedJson.red,
green: parsedJson.green, green: parsedJson.green,
blue: parsedJson.blue, blue: parsedJson.blue,
alpha: parsedJson.alpha alpha: parsedJson.alpha,
}); });
} }
@ -36,7 +36,7 @@ export class ColorResponseDto {
red: this.red, red: this.red,
green: this.green, green: this.green,
blue: this.blue, blue: this.blue,
alpha: this.alpha alpha: this.alpha,
}); });
} }
} }

View File

@ -5,7 +5,7 @@ import { z } from 'zod';
export const LabelResponseSchema = z.object({ export const LabelResponseSchema = z.object({
id: z.int32(), id: z.int32(),
name: z.string(), name: z.string(),
color: ColorResponseSchema color: ColorResponseSchema,
}); });
export class LabelResponseDto { export class LabelResponseDto {
@ -24,7 +24,7 @@ export class LabelResponseDto {
return new LabelResponseDto({ return new LabelResponseDto({
id: parsedJson.id, id: parsedJson.id,
name: parsedJson.name, name: parsedJson.name,
color: ColorResponseDto.fromJson(parsedJson.color) color: ColorResponseDto.fromJson(parsedJson.color),
}); });
} }
@ -32,7 +32,7 @@ export class LabelResponseDto {
return new Label({ return new Label({
id: this.id, id: this.id,
name: this.name, name: this.name,
color: this.color color: this.color,
}); });
} }
} }

View File

@ -3,5 +3,5 @@ import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto'
export interface PostApiService { export interface PostApiService {
getAllPosts(): Promise<PostInfoResponseDto[]>; getAllPosts(): Promise<PostInfoResponseDto[]>;
getPost(id: number): Promise<PostResponseDto | null>; getPost(id: string): Promise<PostResponseDto | null>;
} }

View File

@ -4,15 +4,17 @@ import z from 'zod';
export const PostInfoResponseSchema = z.object({ export const PostInfoResponseSchema = z.object({
id: z.int32(), id: z.int32(),
semantic_id: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
preview_image_url: z.url(), preview_image_url: z.url(),
labels: z.array(LabelResponseSchema), labels: z.array(LabelResponseSchema),
published_time: z.iso.datetime({ offset: true }).nullable() published_time: z.iso.datetime({ offset: true }).nullable(),
}); });
export class PostInfoResponseDto { export class PostInfoResponseDto {
readonly id: number; readonly id: number;
readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL;
@ -21,6 +23,7 @@ export class PostInfoResponseDto {
private constructor(props: { private constructor(props: {
id: number; id: number;
semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL;
@ -28,6 +31,7 @@ export class PostInfoResponseDto {
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
this.id = props.id; this.id = props.id;
this.semanticId = props.semanticId;
this.title = props.title; this.title = props.title;
this.description = props.description; this.description = props.description;
this.previewImageUrl = props.previewImageUrl; this.previewImageUrl = props.previewImageUrl;
@ -45,22 +49,24 @@ export class PostInfoResponseDto {
return new PostInfoResponseDto({ return new PostInfoResponseDto({
id: parsedJson.id, id: parsedJson.id,
semanticId: parsedJson.semantic_id,
title: parsedJson.title, title: parsedJson.title,
description: parsedJson.description, description: parsedJson.description,
previewImageUrl: new URL(parsedJson.preview_image_url), previewImageUrl: new URL(parsedJson.preview_image_url),
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)), labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
publishedTime: published_time publishedTime: published_time,
}); });
} }
toEntity(): PostInfo { toEntity(): PostInfo {
return new PostInfo({ return new PostInfo({
id: this.id, id: this.id,
semanticId: this.semanticId,
title: this.title, title: this.title,
description: this.description, description: this.description,
previewImageUrl: this.previewImageUrl, previewImageUrl: this.previewImageUrl,
labels: this.labels.map((label) => label.toEntity()), labels: this.labels.map((label) => label.toEntity()),
publishedTime: this.publishedTime publishedTime: this.publishedTime,
}); });
} }
} }

View File

@ -11,7 +11,7 @@ export class PostRepositoryImpl implements PostRepository {
return dtos.map((dto) => dto.toEntity()); 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); const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null; return dto?.toEntity() ?? null;
} }

View File

@ -1,6 +1,6 @@
import { import {
PostInfoResponseDto, PostInfoResponseDto,
PostInfoResponseSchema PostInfoResponseSchema,
} from '$lib/post/adapter/gateway/postInfoResponseDto'; } from '$lib/post/adapter/gateway/postInfoResponseDto';
import { Post } from '$lib/post/domain/entity/post'; import { Post } from '$lib/post/domain/entity/post';
import z from 'zod'; import z from 'zod';
@ -8,7 +8,7 @@ import z from 'zod';
export const PostResponseSchema = z.object({ export const PostResponseSchema = z.object({
id: z.int32(), id: z.int32(),
info: PostInfoResponseSchema, info: PostInfoResponseSchema,
content: z.string() content: z.string(),
}); });
export class PostResponseDto { export class PostResponseDto {
@ -27,7 +27,7 @@ export class PostResponseDto {
return new PostResponseDto({ return new PostResponseDto({
id: parsedJson.id, id: parsedJson.id,
info: PostInfoResponseDto.fromJson(parsedJson.info), info: PostInfoResponseDto.fromJson(parsedJson.info),
content: parsedJson.content content: parsedJson.content,
}); });
} }
@ -35,7 +35,7 @@ export class PostResponseDto {
return new Post({ return new Post({
id: this.id, id: this.id,
info: this.info.toEntity(), info: this.info.toEntity(),
content: this.content content: this.content,
}); });
} }
} }

View File

@ -43,7 +43,7 @@ export class ColorViewModel {
red: Math.round(r * 255), red: Math.round(r * 255),
green: Math.round(g * 255), green: Math.round(g * 255),
blue: Math.round(b * 255), blue: Math.round(b * 255),
alpha: 255 alpha: 255,
}); });
} }
@ -52,7 +52,7 @@ export class ColorViewModel {
red: color.red, red: color.red,
green: color.green, green: color.green,
blue: color.blue, blue: color.blue,
alpha: color.alpha alpha: color.alpha,
}); });
} }
@ -115,7 +115,7 @@ export class ColorViewModel {
red: this.red, red: this.red,
green: this.green, green: this.green,
blue: this.blue, blue: this.blue,
alpha: this.alpha alpha: this.alpha,
}; };
} }
} }

View File

@ -1,6 +1,6 @@
import { import {
ColorViewModel, ColorViewModel,
type DehydratedColorProps type DehydratedColorProps,
} from '$lib/post/adapter/presenter/colorViewModel'; } from '$lib/post/adapter/presenter/colorViewModel';
import type { Label } from '$lib/post/domain/entity/label'; import type { Label } from '$lib/post/domain/entity/label';
@ -19,7 +19,7 @@ export class LabelViewModel {
return new LabelViewModel({ return new LabelViewModel({
id: label.id, id: label.id,
name: label.name, name: label.name,
color: ColorViewModel.fromEntity(label.color) color: ColorViewModel.fromEntity(label.color),
}); });
} }
@ -27,7 +27,7 @@ export class LabelViewModel {
return new LabelViewModel({ return new LabelViewModel({
id: props.id, id: props.id,
name: props.name, name: props.name,
color: ColorViewModel.rehydrate(props.color) color: ColorViewModel.rehydrate(props.color),
}); });
} }
@ -35,7 +35,7 @@ export class LabelViewModel {
return { return {
id: this.id, id: this.id,
name: this.name, name: this.name,
color: this.color.dehydrate() color: this.color.dehydrate(),
}; };
} }
} }

View File

@ -8,7 +8,7 @@ export type PostEvent = PostLoadedEvent;
export class PostBloc { export class PostBloc {
private readonly state = writable<PostState>({ private readonly state = writable<PostState>({
status: StatusType.Idle status: StatusType.Idle,
}); });
constructor( constructor(
@ -17,7 +17,7 @@ export class PostBloc {
) { ) {
this.state.set({ this.state.set({
status: StatusType.Idle, 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 }); this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const post = await this.getPostUseCase.execute(id); const post = await this.getPostUseCase.execute(id);
@ -44,7 +44,7 @@ export class PostBloc {
const postViewModel = PostViewModel.fromEntity(post); const postViewModel = PostViewModel.fromEntity(post);
const result: PostState = { const result: PostState = {
status: StatusType.Success, status: StatusType.Success,
data: postViewModel data: postViewModel,
}; };
this.state.set(result); this.state.set(result);
@ -53,10 +53,10 @@ export class PostBloc {
} }
export enum PostEventType { export enum PostEventType {
PostLoadedEvent PostLoadedEvent,
} }
export interface PostLoadedEvent { interface PostLoadedEvent {
event: PostEventType.PostLoadedEvent; event: PostEventType.PostLoadedEvent;
id: number; id: string;
} }

View File

@ -1,11 +1,12 @@
import { import {
LabelViewModel, LabelViewModel,
type DehydratedLabelProps type DehydratedLabelProps,
} from '$lib/post/adapter/presenter/labelViewModel'; } from '$lib/post/adapter/presenter/labelViewModel';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostInfoViewModel { export class PostInfoViewModel {
readonly id: number; readonly id: number;
readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL;
@ -14,6 +15,7 @@ export class PostInfoViewModel {
private constructor(props: { private constructor(props: {
id: number; id: number;
semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL;
@ -21,6 +23,7 @@ export class PostInfoViewModel {
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
this.id = props.id; this.id = props.id;
this.semanticId = props.semanticId;
this.title = props.title; this.title = props.title;
this.description = props.description; this.description = props.description;
this.previewImageUrl = props.previewImageUrl; this.previewImageUrl = props.previewImageUrl;
@ -31,11 +34,12 @@ export class PostInfoViewModel {
static fromEntity(postInfo: PostInfo): PostInfoViewModel { static fromEntity(postInfo: PostInfo): PostInfoViewModel {
return new PostInfoViewModel({ return new PostInfoViewModel({
id: postInfo.id, id: postInfo.id,
semanticId: postInfo.semanticId,
title: postInfo.title, title: postInfo.title,
description: postInfo.description, description: postInfo.description,
previewImageUrl: postInfo.previewImageUrl, previewImageUrl: postInfo.previewImageUrl,
labels: postInfo.labels.map((label) => LabelViewModel.fromEntity(label)), labels: postInfo.labels.map((label) => LabelViewModel.fromEntity(label)),
publishedTime: postInfo.publishedTime publishedTime: postInfo.publishedTime,
}); });
} }
@ -47,11 +51,12 @@ export class PostInfoViewModel {
return new PostInfoViewModel({ return new PostInfoViewModel({
id: props.id, id: props.id,
semanticId: props.semanticId,
title: props.title, title: props.title,
description: props.description, description: props.description,
previewImageUrl: new URL(props.previewImageUrl), previewImageUrl: new URL(props.previewImageUrl),
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)), labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
publishedTime: publishedTime publishedTime: publishedTime,
}); });
} }
@ -66,17 +71,19 @@ export class PostInfoViewModel {
dehydrate(): DehydratedPostInfoProps { dehydrate(): DehydratedPostInfoProps {
return { return {
id: this.id, id: this.id,
semanticId: this.semanticId,
title: this.title, title: this.title,
description: this.description, description: this.description,
previewImageUrl: this.previewImageUrl.href, previewImageUrl: this.previewImageUrl.href,
labels: this.labels.map((label) => label.dehydrate()), labels: this.labels.map((label) => label.dehydrate()),
publishedTime: this.publishedTime?.getTime() ?? null publishedTime: this.publishedTime?.getTime() ?? null,
}; };
} }
} }
export interface DehydratedPostInfoProps { export interface DehydratedPostInfoProps {
id: number; id: number;
semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: string; previewImageUrl: string;

View File

@ -8,7 +8,7 @@ export type PostListEvent = PostListLoadedEvent;
export class PostListBloc { export class PostListBloc {
private readonly state = writable<PostListState>({ private readonly state = writable<PostListState>({
status: StatusType.Idle status: StatusType.Idle,
}); });
constructor( constructor(
@ -17,7 +17,7 @@ export class PostListBloc {
) { ) {
this.state.set({ this.state.set({
status: StatusType.Idle, status: StatusType.Idle,
data: initialData data: initialData,
}); });
} }
@ -38,7 +38,7 @@ export class PostListBloc {
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post)); const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
const result: PostListState = { const result: PostListState = {
status: StatusType.Success, status: StatusType.Success,
data: postViewModels data: postViewModels,
}; };
this.state.set(result); this.state.set(result);
@ -47,7 +47,7 @@ export class PostListBloc {
} }
export enum PostListEventType { export enum PostListEventType {
PostListLoadedEvent PostListLoadedEvent,
} }
export interface PostListLoadedEvent { export interface PostListLoadedEvent {

View File

@ -1,6 +1,6 @@
import { import {
PostInfoViewModel, PostInfoViewModel,
type DehydratedPostInfoProps type DehydratedPostInfoProps,
} from '$lib/post/adapter/presenter/postInfoViewModel'; } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { Post } from '$lib/post/domain/entity/post'; import type { Post } from '$lib/post/domain/entity/post';
@ -19,7 +19,7 @@ export class PostViewModel {
return new PostViewModel({ return new PostViewModel({
id: post.id, id: post.id,
info: PostInfoViewModel.fromEntity(post.info), info: PostInfoViewModel.fromEntity(post.info),
content: post.content content: post.content,
}); });
} }
@ -27,7 +27,7 @@ export class PostViewModel {
return new PostViewModel({ return new PostViewModel({
id: props.id, id: props.id,
info: PostInfoViewModel.rehydrate(props.info), info: PostInfoViewModel.rehydrate(props.info),
content: props.content content: props.content,
}); });
} }
@ -35,7 +35,7 @@ export class PostViewModel {
return { return {
id: this.id, id: this.id,
info: this.info.dehydrate(), info: this.info.dehydrate(),
content: this.content content: this.content,
}; };
} }
} }

View File

@ -3,5 +3,5 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export interface PostRepository { export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>; getAllPosts(): Promise<PostInfo[]>;
getPost(id: number): Promise<Post | null>; getPost(id: string): Promise<Post | null>;
} }

View File

@ -4,7 +4,7 @@ import type { Post } from '$lib/post/domain/entity/post';
export class GetPostUseCase { export class GetPostUseCase {
constructor(private readonly postRepository: PostRepository) {} constructor(private readonly postRepository: PostRepository) {}
execute(id: number): Promise<Post | null> { execute(id: string): Promise<Post | null> {
return this.postRepository.getPost(id); return this.postRepository.getPost(id);
} }
} }

View File

@ -2,6 +2,7 @@ import type { Label } from '$lib/post/domain/entity/label';
export class PostInfo { export class PostInfo {
readonly id: number; readonly id: number;
readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL;
@ -10,6 +11,7 @@ export class PostInfo {
constructor(props: { constructor(props: {
id: number; id: number;
semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL;
@ -17,6 +19,7 @@ export class PostInfo {
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
this.id = props.id; this.id = props.id;
this.semanticId = props.semanticId;
this.title = props.title; this.title = props.title;
this.description = props.description; this.description = props.description;
this.previewImageUrl = props.previewImageUrl; this.previewImageUrl = props.previewImageUrl;

View File

@ -19,7 +19,7 @@ export class PostApiServiceImpl implements PostApiService {
return json.map(PostInfoResponseDto.fromJson); 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 url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href); const response = await this.fetchFn(url.href);

View File

@ -7,7 +7,7 @@
import generateTitle from '$lib/common/framework/ui/generateTitle'; import generateTitle from '$lib/common/framework/ui/generateTitle';
import StructuredData from '$lib/post/framework/ui/StructuredData.svelte'; 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 postBloc = getContext<PostBloc>(PostBloc.name);
const state = $derived($postBloc); const state = $derived($postBloc);

View File

@ -17,7 +17,11 @@
} }
</script> </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"> <div class="relative aspect-video overflow-hidden rounded-2xl bg-gray-200">
<img <img
class="rounded-2xl object-cover transition-opacity duration-300 class="rounded-2xl object-cover transition-opacity duration-300

View File

@ -5,7 +5,7 @@
headline, headline,
description, description,
datePublished, datePublished,
image image,
}: { }: {
headline: string; headline: string;
description: string; description: string;
@ -19,7 +19,7 @@
headline: headline, headline: headline,
description: description, description: description,
datePublished: datePublished.toISOString(), datePublished: datePublished.toISOString(),
image: image.href image: image.href,
}); });
const jsonLdScript = $derived( const jsonLdScript = $derived(

View File

@ -7,6 +7,6 @@ export const load: PageServerLoad = async ({ locals }) => {
const state = await postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }); const state = await postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent });
return { return {
dehydratedData: state.data?.map((post) => post.dehydrate()) dehydratedData: state.data?.map((post) => post.dehydrate()),
}; };
}; };

View File

@ -5,20 +5,15 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => { export const load: PageServerLoad = async ({ locals, params }) => {
const { postBloc } = locals; 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({ const state = await postBloc.dispatch({
event: PostEventType.PostLoadedEvent, event: PostEventType.PostLoadedEvent,
id: id id: params.id,
}); });
if (!state.data) { if (!state.data) {
error(404, { message: 'Post not found' }); error(404, { message: 'Post not found' });
} }
return { return {
dehydratedData: state.data.dehydrate() dehydratedData: state.data.dehydrate(),
}; };
}; };

View File

@ -9,8 +9,7 @@
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte'; import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
const { data, params }: PageProps = $props(); const { data, params }: PageProps = $props();
const { id } = params;
const id = parseInt(params.id, 10);
const initialData = PostViewModel.rehydrate(data.dehydratedData!); const initialData = PostViewModel.rehydrate(data.dehydratedData!);

View File

@ -11,8 +11,8 @@ const config = {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // 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. // 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. // See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() adapter: adapter(),
} },
}; };
export default config; export default config;

View File

@ -9,10 +9,10 @@ module.exports = {
'--tw-prose-headings': 'var(--color-gray-800)', '--tw-prose-headings': 'var(--color-gray-800)',
'--tw-prose-links': 'var(--color-gray-800)', '--tw-prose-links': 'var(--color-gray-800)',
'--tw-prose-bold': '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)',
} },
} },
}) }),
} },
} },
}; };

View File

@ -10,22 +10,22 @@ export default defineConfig({
sentrySvelteKit({ sentrySvelteKit({
sourceMapsUploadOptions: { sourceMapsUploadOptions: {
org: 'squidspirit', org: 'squidspirit',
project: 'blog-beta-frontend' project: 'blog-beta-frontend',
} },
}), }),
tailwindcss(), tailwindcss(),
sveltekit() sveltekit(),
], ],
define: { define: {
'App.__VERSION__': JSON.stringify(version) 'App.__VERSION__': JSON.stringify(version),
}, },
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:8080', target: 'http://127.0.0.1:8080',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, ''),
} },
} },
} },
}); });