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",
"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",

View File

@ -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 = [

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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(),
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}
},
}
}

View File

@ -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()
}
},
}
}

View File

@ -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()
}
},
}
}

View File

@ -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()
}
},
}
}

View File

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

View File

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

View File

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

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::{
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
View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@
'資工系',
'軟體工程',
'遊戲',
'魷魚'
'魷魚',
];
// 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),
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,
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()),
};
};

View File

@ -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(),
};
};

View File

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

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.
// 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;

View File

@ -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)',
},
},
}),
},
},
};

View File

@ -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/, ''),
},
},
},
});