BLOG-104 Implement CRUD functionality for Posts (#108)
All checks were successful
Frontend CI / build (push) Successful in 1m8s

### Description

This pull request introduces the core functionality for creating and updating posts, completing the backend CRUD operations for the `post` feature. It includes new API endpoints, database schema changes, and corresponding updates across the entire application stack from the database layer to the frontend.

#### Backend API

-   **Added new authenticated endpoints:**
    -   `POST /post`: To create a new post.
    -   `PUT /post/{id}`: To update an existing post.
-   Implemented the full vertical slice for these operations, including:
    -   `CreatePostUseCase` and `UpdatePostUseCase`.
    -   Repository and DB service methods for creating, updating, and associating posts with labels.
    -   Transactional database operations to ensure data integrity when creating/updating posts and their associated labels.

#### Database

-   Added a new migration to include an `"order"` column in the `post_label` table.
-   This column preserves the user-defined order of labels for each post.
-   Queries have been updated to fetch and sort labels based on this new column.

#### API Schema & Documentation

-   Enhanced `utoipa` OpenAPI documentation with more specific formats for data types:
    -   `#[schema(format = Uri)]` for URLs like `preview_image_url`.
    -   `#[schema(format = Email)]` for user emails.
    -   `#[schema(format = DateTime)]` for timestamps.
-   Standardized the `published_time` field to use the RFC3339 string format instead of a numeric timestamp, improving API clarity and interoperability.

#### Frontend

-   Updated the `PostInfoResponseDto` in the frontend to correctly parse the new `DateTime` (ISO string) format for `published_time`.

#### Refactoring

-   Renamed `get_full_post` to a more descriptive `get_post_by_id` across the post feature module for better code clarity.

### Package Changes

```toml
utoipa = { version = "5.4.0", features = [
    "actix_extras",
    "non_strict_integers",
    "url",
] }
```

### Screenshots

_No response_

### Reference

Resolves #104

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #108
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-08-02 14:35:27 +08:00 committed by squid
parent 71528294ae
commit a5f66616c4
34 changed files with 659 additions and 39 deletions

View File

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO post_label (\n post_id, label_id, \"order\"\n ) VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "0c9effcc24f4319c47898e0ade4e5ccef3c47c014cfcb65805cbf1c625fef1e7"
}

View File

@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, name, color\n FROM label\n WHERE deleted_time IS NULL\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "color",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "38181c2e36077c546944fbfe124c623706f920fd6b1a9a1cd143ecee6c9d5019"
}

View File

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE label\n SET name = $1, color = $2\n WHERE id = $3 AND deleted_time IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Int4"
]
},
"nullable": []
},
"hash": "5189bdfd0aa6b4ac478cc48efde4cdbd9cc9605fe0f2c4dc4506827fa0fd2ad6"
}

View File

@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, name, color\n FROM label\n WHERE id = $1 AND deleted_time IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "color",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "a0e1ed95ce9d705653281455cc59e8ed130a496b09dccbf89e919f4c9798e91a"
}

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM post_label\n WHERE post_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "b084aa65fa3cdb1abdd02fd9e2ade04a71dd98eef245780a6f34f0b72564f63e"
}

View File

@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE post\n SET \n title = $1, \n description = $2, \n preview_image_url = $3, \n content = $4, \n published_time = $5\n WHERE id = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamp",
"Int4"
]
},
"nullable": []
},
"hash": "d0867ba2857fedcdc9a754d0394c4f040d559118d0b9f8b6f4dcd6e6fde5d381"
}

View File

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO post (\n title, description, preview_image_url, content, published_time\n ) VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "f0c2c0fe0a30790e88449da79c859d4e3829b9b2a6a496c9a429a05fbdb2e30a"
}

View File

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO label (name, color)\n VALUES ($1, $2)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "f4ef2b4e53389d2bf6a6299fc4e4ffd0df1393e1805ae1c37306b25c721de7e3"
}

1
backend/Cargo.lock generated
View File

@ -3537,6 +3537,7 @@ dependencies = [
"quote",
"regex",
"syn",
"url",
]
[[package]]

View File

@ -30,7 +30,11 @@ sqlx = { version = "0.8.5", features = [
"runtime-tokio-rustls",
] }
tokio = { version = "1.45.0", features = ["full"] }
utoipa = { version = "5.4.0", features = ["actix_extras"] }
utoipa = { version = "5.4.0", features = [
"actix_extras",
"non_strict_integers",
"url",
] }
utoipa-redoc = { version = "6.0.0", features = ["actix-web"] }
server.path = "server"

View File

@ -7,6 +7,8 @@ use crate::domain::entity::user::User;
pub struct UserResponseDto {
pub id: i32,
pub displayed_name: String,
#[schema(format = Email)]
pub email: String,
}

View File

@ -1,9 +1,11 @@
pub mod color_request_dto;
pub mod color_response_dto;
pub mod create_label_request_dto;
pub mod create_post_request_dto;
pub mod label_response_dto;
pub mod post_controller;
pub mod post_info_query_dto;
pub mod post_info_response_dto;
pub mod post_response_dto;
pub mod update_label_request_dto;
pub mod update_post_request_dto;

View File

@ -5,16 +5,9 @@ use crate::domain::entity::color::Color;
#[derive(Deserialize, ToSchema)]
pub struct ColorRequestDto {
#[schema(maximum = 255)]
pub red: u8,
#[schema(maximum = 255)]
pub green: u8,
#[schema(maximum = 255)]
pub blue: u8,
#[schema(maximum = 255)]
pub alpha: u8,
}

View File

@ -5,16 +5,9 @@ use crate::domain::entity::color::Color;
#[derive(Serialize, ToSchema)]
pub struct ColorResponseDto {
#[schema(maximum = 255)]
pub red: u8,
#[schema(maximum = 255)]
pub green: u8,
#[schema(maximum = 255)]
pub blue: u8,
#[schema(maximum = 255)]
pub alpha: u8,
}

View File

@ -0,0 +1,39 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)]
pub struct CreatePostRequestDto {
pub title: String,
pub description: String,
pub content: String,
pub label_ids: Vec<i32>,
#[schema(format = Uri)]
pub preview_image_url: String,
#[schema(required, format = DateTime)]
pub published_time: Option<String>,
}
impl CreatePostRequestDto {
pub fn into_entity(self) -> Post {
Post {
id: -1,
info: PostInfo {
id: -1,
title: self.title,
description: self.description,
preview_image_url: self.preview_image_url,
labels: Vec::new(),
published_time: self
.published_time
.and_then(|time_str| DateTime::parse_from_rfc3339(&time_str).ok())
.map(|dt| dt.with_timezone(&Utc)),
},
content: self.content,
}
}
}

View File

@ -4,16 +4,19 @@ use async_trait::async_trait;
use crate::{
adapter::delivery::{
create_label_request_dto::CreateLabelRequestDto, post_info_query_dto::PostQueryDto,
create_label_request_dto::CreateLabelRequestDto,
create_post_request_dto::CreatePostRequestDto, post_info_query_dto::PostQueryDto,
update_label_request_dto::UpdateLabelRequestDto,
update_post_request_dto::UpdatePostRequestDto,
},
application::{
error::post_error::PostError,
use_case::{
create_label_use_case::CreateLabelUseCase,
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,
},
},
};
@ -32,6 +35,14 @@ pub trait PostController: Send + Sync {
async fn get_post_by_id(&self, id: i32) -> Result<PostResponseDto, PostError>;
async fn create_post(&self, post: CreatePostRequestDto) -> Result<PostResponseDto, PostError>;
async fn update_post(
&self,
id: i32,
post: UpdatePostRequestDto,
) -> Result<PostResponseDto, PostError>;
async fn create_label(
&self,
label: CreateLabelRequestDto,
@ -49,6 +60,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>,
create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
@ -58,6 +71,8 @@ impl PostControllerImpl {
pub fn new(
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
@ -65,6 +80,8 @@ impl PostControllerImpl {
Self {
get_all_post_info_use_case,
get_full_post_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
@ -136,4 +153,31 @@ impl PostController for PostControllerImpl {
.collect()
})
}
async fn create_post(&self, post: CreatePostRequestDto) -> Result<PostResponseDto, PostError> {
let label_ids = post.label_ids.clone();
let post_entity = post.into_entity();
let id = self
.create_post_use_case
.execute(post_entity, &label_ids)
.await?;
self.get_post_by_id(id).await
}
async fn update_post(
&self,
id: i32,
post: UpdatePostRequestDto,
) -> Result<PostResponseDto, PostError> {
let label_ids = post.label_ids.clone();
let post_entity = post.into_entity(id);
self.update_post_use_case
.execute(post_entity, &label_ids)
.await?;
self.get_post_by_id(id).await
}
}

View File

@ -10,9 +10,13 @@ pub struct PostInfoResponseDto {
pub id: i32,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub labels: Vec<LabelResponseDto>,
pub published_time: Option<i64>,
#[schema(format = Uri)]
pub preview_image_url: String,
#[schema(format = DateTime)]
pub published_time: Option<String>,
}
impl From<PostInfo> for PostInfoResponseDto {
@ -27,9 +31,7 @@ impl From<PostInfo> for PostInfoResponseDto {
.into_iter()
.map(LabelResponseDto::from)
.collect(),
published_time: entity
.published_time
.map(|datetime| datetime.timestamp_micros()),
published_time: entity.published_time.map(|datetime| datetime.to_rfc3339()),
}
}
}

View File

@ -0,0 +1,39 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)]
pub struct UpdatePostRequestDto {
pub title: String,
pub description: String,
pub content: String,
pub label_ids: Vec<i32>,
#[schema(format = Uri)]
pub preview_image_url: String,
#[schema(required, format = DateTime)]
pub published_time: Option<String>,
}
impl UpdatePostRequestDto {
pub fn into_entity(self, id: i32) -> Post {
Post {
id,
info: PostInfo {
id,
title: self.title,
description: self.description,
preview_image_url: self.preview_image_url,
labels: Vec::new(),
published_time: self
.published_time
.and_then(|time_str| DateTime::parse_from_rfc3339(&time_str).ok())
.map(|dt| dt.with_timezone(&Utc)),
},
content: self.content,
}
}
}

View File

@ -1,7 +1,7 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::{post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper},
adapter::gateway::{post_db_mapper::PostMapper, post_info_db_mapper::PostInfoMapper},
application::error::post_error::PostError,
};
@ -11,5 +11,7 @@ pub trait PostDbService: Send + Sync {
&self,
is_published_only: bool,
) -> Result<Vec<PostInfoMapper>, PostError>;
async fn get_full_post(&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 update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError>;
}

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::gateway::{post_db_mapper::PostMapper, post_info_db_mapper::PostInfoMapper},
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::{post::Post, post_info::PostInfo},
};
@ -33,10 +34,52 @@ impl PostRepository for PostRepositoryImpl {
})
}
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
async fn get_post_by_id(&self, id: i32) -> Result<Post, PostError> {
self.post_db_service
.get_full_post(id)
.get_post_by_id(id)
.await
.map(|mapper| mapper.into_entity())
}
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
let info_mapper = PostInfoMapper {
id: post.info.id,
title: post.info.title,
description: post.info.description,
preview_image_url: post.info.preview_image_url,
labels: Vec::new(),
published_time: post.info.published_time.map(|dt| dt.naive_utc()),
};
let post_mapper = PostMapper {
id: post.id,
info: info_mapper,
content: post.content,
};
self.post_db_service
.create_post(post_mapper, label_ids)
.await
}
async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
let info_mapper = PostInfoMapper {
id: post.info.id,
title: post.info.title,
description: post.info.description,
preview_image_url: post.info.preview_image_url,
labels: Vec::new(),
published_time: post.info.published_time.map(|dt| dt.naive_utc()),
};
let post_mapper = PostMapper {
id: post.id,
info: info_mapper,
content: post.content,
};
self.post_db_service
.update_post(post_mapper, label_ids)
.await
}
}

View File

@ -8,5 +8,7 @@ use crate::{
#[async_trait]
pub trait PostRepository: Send + Sync {
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&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 update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
}

View File

@ -1,5 +1,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 update_label_use_case;
pub mod update_post_use_case;

View File

@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::post::Post,
};
#[async_trait]
pub trait CreatePostUseCase: Send + Sync {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError>;
}
pub struct CreatePostUseCaseImpl {
post_repository: Arc<dyn PostRepository>,
}
impl CreatePostUseCaseImpl {
pub fn new(post_repository: Arc<dyn PostRepository>) -> Self {
Self { post_repository }
}
}
#[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
}
}

View File

@ -25,6 +25,6 @@ impl GetFullPostUseCaseImpl {
#[async_trait]
impl GetFullPostUseCase for GetFullPostUseCaseImpl {
async fn execute(&self, id: i32) -> Result<Post, PostError> {
self.post_repository.get_full_post(id).await
self.post_repository.get_post_by_id(id).await
}
}

View File

@ -0,0 +1,30 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::post::Post,
};
#[async_trait]
pub trait UpdatePostUseCase: Send + Sync {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
}
pub struct UpdatePostUseCaseImpl {
post_repository: Arc<dyn PostRepository>,
}
impl UpdatePostUseCaseImpl {
pub fn new(post_repository: Arc<dyn PostRepository>) -> Self {
Self { post_repository }
}
}
#[async_trait]
impl UpdatePostUseCase for UpdatePostUseCaseImpl {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
self.post_repository.update_post(post, label_ids).await
}
}

View File

@ -5,8 +5,8 @@ use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_service::PostDbService,
post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper,
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_mapper::PostMapper,
post_db_service::PostDbService, post_info_db_mapper::PostInfoMapper,
},
application::error::post_error::PostError,
};
@ -58,7 +58,7 @@ impl PostDbService for PostDbServiceImpl {
query_builder.push(r#" AND p.published_time IS NOT NULL"#);
}
query_builder.push(r#" ORDER BY p.id"#);
query_builder.push(r#" ORDER BY p.id, pl."order""#);
let records = query_builder
.build_query_as::<PostInfoWithLabelRecord>()
@ -105,7 +105,7 @@ impl PostDbService for PostDbServiceImpl {
Ok(ordered_posts)
}
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError> {
async fn get_post_by_id(&self, id: i32) -> Result<PostMapper, PostError> {
let mut query_builder = sqlx::QueryBuilder::new(
r#"
SELECT
@ -130,7 +130,7 @@ impl PostDbService for PostDbServiceImpl {
);
query_builder.push_bind(id);
query_builder.push(r#" ORDER BY p.id"#);
query_builder.push(r#" ORDER BY p.id, pl."order""#);
let records = query_builder
.build_query_as::<PostWithLabelRecord>()
@ -182,4 +182,121 @@ impl PostDbService for PostDbServiceImpl {
None => Err(PostError::NotFound),
}
}
async fn create_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<i32, PostError> {
let mut tx = self
.db_pool
.begin()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let post_id = sqlx::query_scalar!(
r#"
INSERT INTO post (
title, description, preview_image_url, content, published_time
) VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
post.info.title,
post.info.description,
post.info.preview_image_url,
post.content,
post.info.published_time,
)
.fetch_one(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
for (order, &label_id) in label_ids.iter().enumerate() {
sqlx::query!(
r#"
INSERT INTO post_label (
post_id, label_id, "order"
) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
"#,
post_id,
label_id,
order as i32,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
}
tx.commit()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
Ok(post_id)
}
async fn update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError> {
let mut tx = self
.db_pool
.begin()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let affected_rows = sqlx::query!(
r#"
UPDATE post
SET
title = $1,
description = $2,
preview_image_url = $3,
content = $4,
published_time = $5
WHERE id = $6
"#,
post.info.title,
post.info.description,
post.info.preview_image_url,
post.content,
post.info.published_time,
post.id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?
.rows_affected();
if affected_rows == 0 {
return Err(PostError::NotFound);
}
sqlx::query!(
r#"
DELETE FROM post_label
WHERE post_id = $1
"#,
post.id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
for (order, &label_id) in label_ids.iter().enumerate() {
sqlx::query!(
r#"
INSERT INTO post_label (
post_id, label_id, "order"
) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
"#,
post.id,
label_id,
order as i32,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
}
tx.commit()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
Ok(())
}
}

View File

@ -2,7 +2,9 @@ pub mod post_api_doc;
pub mod post_web_routes;
mod create_label_handler;
mod create_post_handler;
mod get_all_labels_handler;
mod get_all_post_info_handler;
mod get_post_by_id_handler;
mod update_label_handler;
mod update_post_handler;

View File

@ -0,0 +1,35 @@
use actix_web::{web, HttpResponse, Responder};
use auth::framework::web::auth_middleware::UserId;
use crate::adapter::delivery::{
create_post_request_dto::CreatePostRequestDto, post_controller::PostController,
post_response_dto::PostResponseDto,
};
#[utoipa::path(
post,
path = "/post",
tag = "post",
summary = "Create a new post",
responses(
(status = 201, body = PostResponseDto),
),
security(
("oauth2" = [])
)
)]
pub async fn create_post_handler(
post_controller: web::Data<dyn PostController>,
post_dto: web::Json<CreatePostRequestDto>,
_: UserId,
) -> impl Responder {
let result = post_controller.create_post(post_dto.into_inner()).await;
match result {
Ok(post) => HttpResponse::Created().json(post),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -1,6 +1,6 @@
use crate::framework::web::{
create_label_handler, get_all_labels_handler, get_all_post_info_handler,
get_post_by_id_handler, update_label_handler,
create_label_handler, create_post_handler, get_all_labels_handler, get_all_post_info_handler,
get_post_by_id_handler, update_label_handler, update_post_handler,
};
use utoipa::{OpenApi, openapi};
@ -8,6 +8,8 @@ use utoipa::{OpenApi, openapi};
#[openapi(paths(
get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler,
create_post_handler::create_post_handler,
update_post_handler::update_post_handler,
create_label_handler::create_label_handler,
update_label_handler::update_label_handler,
get_all_labels_handler::get_all_labels_handler

View File

@ -1,16 +1,20 @@
use actix_web::web;
use crate::framework::web::{
create_label_handler::create_label_handler, get_all_labels_handler::get_all_labels_handler,
create_label_handler::create_label_handler, create_post_handler::create_post_handler,
get_all_labels_handler::get_all_labels_handler,
get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler, update_label_handler::update_label_handler,
update_post_handler::update_post_handler,
};
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/post")
.route("", web::get().to(get_all_post_info_handler))
.route("/{id}", web::get().to(get_post_by_id_handler)),
.route("", web::post().to(create_post_handler))
.route("/{id}", web::get().to(get_post_by_id_handler))
.route("/{id}", web::put().to(update_post_handler)),
);
cfg.service(

View File

@ -0,0 +1,43 @@
use actix_web::{HttpResponse, Responder, web};
use auth::framework::web::auth_middleware::UserId;
use crate::adapter::delivery::{
post_controller::PostController, post_response_dto::PostResponseDto,
update_post_request_dto::UpdatePostRequestDto,
};
#[utoipa::path(
put,
path = "/post/{id}",
tag = "post",
summary = "Update a post by ID",
responses(
(status = 200, body = PostResponseDto),
(status = 404, description = "Post not found"),
),
security(
("oauth2" = [])
)
)]
pub async fn update_post_handler(
post_controller: web::Data<dyn PostController>,
path: web::Path<i32>,
post_dto: web::Json<UpdatePostRequestDto>,
_: UserId,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller.update_post(id, post_dto.into_inner()).await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
log::error!("{e:?}");
match e {
crate::application::error::post_error::PostError::NotFound => {
HttpResponse::NotFound().finish()
}
_ => HttpResponse::InternalServerError().finish(),
}
}
}
}

View File

@ -0,0 +1 @@
ALTER TABLE post_label ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0;

View File

@ -37,10 +37,12 @@ use post::{
},
application::use_case::{
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,
},
framework::db::{
label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl,
@ -96,6 +98,8 @@ 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 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()));
let update_label_use_case = Arc::new(UpdateLabelUseCaseImpl::new(label_repository.clone()));
let get_all_labels_use_case =
@ -104,6 +108,8 @@ impl Container {
let post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case,
get_full_post_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,

View File

@ -8,7 +8,7 @@ export const PostInfoResponseSchema = z.object({
description: z.string(),
preview_image_url: z.url(),
labels: z.array(LabelResponseSchema),
published_time: z.number().int()
published_time: z.iso.datetime()
});
export class PostInfoResponseDto {
@ -43,7 +43,7 @@ export class PostInfoResponseDto {
description: parsedJson.description,
previewImageUrl: new URL(parsedJson.preview_image_url),
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
publishedTime: new Date(parsedJson.published_time / 1000)
publishedTime: new Date(parsedJson.published_time)
});
}