diff --git a/imphnen-backend/src/bin/create_schema.rs b/imphnen-backend/src/bin/create_schema.rs index 0df5e49..a2d4ed9 100644 --- a/imphnen-backend/src/bin/create_schema.rs +++ b/imphnen-backend/src/bin/create_schema.rs @@ -29,6 +29,8 @@ async fn main() -> Result<(), Box> { drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?; drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity) .await?; + drop_and_create_table(&db, builder, "app_articles", common::articles::Entity) + .await?; drop_and_create_table(&db, builder, "events", common::events::Entity).await?; drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity) diff --git a/imphnen-dimentorin/src/articles/application/article_service.rs b/imphnen-dimentorin/src/articles/application/article_service.rs new file mode 100644 index 0000000..945b278 --- /dev/null +++ b/imphnen-dimentorin/src/articles/application/article_service.rs @@ -0,0 +1,73 @@ +use async_trait::async_trait; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use std::sync::Arc; +use uuid::Uuid; + +use super::super::domain::article::ArticleEntity; +use super::super::domain::article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand}; +use super::super::domain::repository::ArticleRepository; +use super::super::domain::service::ArticleService; +use imphnen_utils::AppError; + +pub struct ArticleServiceImpl { + repo: Arc, +} + +impl ArticleServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl ArticleService for ArticleServiceImpl { + async fn list( + &self, + page: u64, + per_page: u64, + category: Option, + ) -> Result, AppError> { + let result = self.repo.find_all_paginated(page, per_page, category).await?; + let items: Vec = result + .data + .into_iter() + .map(ArticleListItem::from) + .collect(); + Ok(PaginatorResponse { + data: items, + meta: result.meta, + }) + } + + async fn get_by_id(&self, id: Uuid) -> Result { + let entity = self.repo.find_by_id(id).await?; + Ok(ArticleDetail::from(entity)) + } + + async fn get_by_slug(&self, slug: &str) -> Result { + let entity = self.repo.find_by_slug(slug).await?; + Ok(ArticleDetail::from(entity)) + } + + async fn categories(&self) -> Result, AppError> { + self.repo.find_categories().await + } + + async fn create(&self, cmd: CreateArticleCommand) -> Result { + let entity = ArticleEntity { + id: Uuid::new_v4(), + title: cmd.title, + slug: cmd.slug, + category: cmd.category, + excerpt: cmd.excerpt, + content: cmd.content, + cover_url: cmd.cover_url, + author_name: cmd.author_name, + is_published: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + self.repo.create(entity.clone()).await?; + Ok(ArticleDetail::from(entity)) + } +} diff --git a/imphnen-dimentorin/src/articles/application/mod.rs b/imphnen-dimentorin/src/articles/application/mod.rs new file mode 100644 index 0000000..4549722 --- /dev/null +++ b/imphnen-dimentorin/src/articles/application/mod.rs @@ -0,0 +1,3 @@ +pub mod article_service; + +pub use article_service::ArticleServiceImpl; diff --git a/imphnen-dimentorin/src/articles/domain/article.rs b/imphnen-dimentorin/src/articles/domain/article.rs new file mode 100644 index 0000000..01c518b --- /dev/null +++ b/imphnen-dimentorin/src/articles/domain/article.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct ArticleEntity { + pub id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub excerpt: String, + pub content: String, + pub cover_url: Option, + pub author_name: Option, + pub is_published: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/imphnen-dimentorin/src/articles/domain/article_types.rs b/imphnen-dimentorin/src/articles/domain/article_types.rs new file mode 100644 index 0000000..f2c0ea6 --- /dev/null +++ b/imphnen-dimentorin/src/articles/domain/article_types.rs @@ -0,0 +1,75 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::article::ArticleEntity; + +#[derive(Clone, Debug)] +pub struct ArticleListItem { + pub id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub excerpt: String, + pub cover_url: Option, + pub author_name: Option, + pub created_at: DateTime, +} + +#[derive(Clone, Debug)] +pub struct ArticleDetail { + pub id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub excerpt: String, + pub content: String, + pub cover_url: Option, + pub author_name: Option, + pub is_published: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Clone, Debug)] +pub struct CreateArticleCommand { + pub title: String, + pub slug: String, + pub category: String, + pub excerpt: String, + pub content: String, + pub cover_url: Option, + pub author_name: Option, +} + +impl From for ArticleListItem { + fn from(e: ArticleEntity) -> Self { + Self { + id: e.id, + title: e.title, + slug: e.slug, + category: e.category, + excerpt: e.excerpt, + cover_url: e.cover_url, + author_name: e.author_name, + created_at: e.created_at, + } + } +} + +impl From for ArticleDetail { + fn from(e: ArticleEntity) -> Self { + Self { + id: e.id, + title: e.title, + slug: e.slug, + category: e.category, + excerpt: e.excerpt, + content: e.content, + cover_url: e.cover_url, + author_name: e.author_name, + is_published: e.is_published, + created_at: e.created_at, + updated_at: e.updated_at, + } + } +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/domain/mod.rs b/imphnen-dimentorin/src/articles/domain/mod.rs new file mode 100644 index 0000000..df1e347 --- /dev/null +++ b/imphnen-dimentorin/src/articles/domain/mod.rs @@ -0,0 +1,4 @@ +pub mod article; +pub mod article_types; +pub mod repository; +pub mod service; diff --git a/imphnen-dimentorin/src/articles/domain/repository.rs b/imphnen-dimentorin/src/articles/domain/repository.rs new file mode 100644 index 0000000..1d49608 --- /dev/null +++ b/imphnen-dimentorin/src/articles/domain/repository.rs @@ -0,0 +1,20 @@ +use async_trait::async_trait; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +use super::article::ArticleEntity; +use imphnen_utils::AppError; + +#[async_trait] +pub trait ArticleRepository: Send + Sync { + async fn find_all_paginated( + &self, + page: u64, + per_page: u64, + category: Option, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn find_by_slug(&self, slug: &str) -> Result; + async fn find_categories(&self) -> Result, AppError>; + async fn create(&self, entity: ArticleEntity) -> Result; +} diff --git a/imphnen-dimentorin/src/articles/domain/service.rs b/imphnen-dimentorin/src/articles/domain/service.rs new file mode 100644 index 0000000..e95e8c9 --- /dev/null +++ b/imphnen-dimentorin/src/articles/domain/service.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +use super::article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand}; +use imphnen_utils::AppError; + +#[async_trait] +pub trait ArticleService: Send + Sync { + async fn list( + &self, + page: u64, + per_page: u64, + category: Option, + ) -> Result, AppError>; + async fn get_by_id(&self, id: Uuid) -> Result; + async fn get_by_slug(&self, slug: &str) -> Result; + async fn categories(&self) -> Result, AppError>; + async fn create( + &self, + cmd: CreateArticleCommand, + ) -> Result; +} diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs b/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..fa38e02 --- /dev/null +++ b/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod postgres_article_queries; +pub mod postgres_article_repository; \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_queries.rs b/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_queries.rs new file mode 100644 index 0000000..6850963 --- /dev/null +++ b/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_queries.rs @@ -0,0 +1,92 @@ +use super::super::super::domain::article::ArticleEntity; +use imphnen_entities::seaorm::common::articles::{ + Column as ArticleColumn, Entity as ArticlesEntity, Model as ArticleModel, +}; +use imphnen_utils::AppError; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder}; +use std::sync::Arc; + +pub fn model_to_entity(model: ArticleModel) -> ArticleEntity { + ArticleEntity { + id: model.id, + title: model.title, + slug: model.slug, + category: model.category, + excerpt: model.excerpt, + content: model.content, + cover_url: model.cover_url, + author_name: model.author_name, + is_published: model.is_published, + created_at: model.created_at, + updated_at: model.updated_at, + } +} + +pub async fn find_all_paginated( + db: &Arc, + page: u64, + per_page: u64, + category: Option, +) -> Result, AppError> { + let mut query = ArticlesEntity::find().filter(ArticleColumn::IsPublished.eq(true)); + + if let Some(cat) = category.filter(|c| !c.is_empty()) { + query = query.filter(ArticleColumn::Category.eq(cat)); + } + + query = query.order_by(ArticleColumn::CreatedAt, Order::Desc); + + let paginator = query.paginate(db.as_ref(), per_page); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let articles = paginator + .fetch_page(page.saturating_sub(1)) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let data = articles.into_iter().map(model_to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) +} + +pub async fn find_by_id( + db: &Arc, + id: Uuid, +) -> Result { + let model = ArticlesEntity::find_by_id(id) + .filter(ArticleColumn::IsPublished.eq(true)) + .one(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Article not found".to_string()))?; + Ok(model_to_entity(model)) +} + +pub async fn find_by_slug( + db: &Arc, + slug: &str, +) -> Result { + let model = ArticlesEntity::find() + .filter(ArticleColumn::Slug.eq(slug)) + .filter(ArticleColumn::IsPublished.eq(true)) + .one(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Article not found".to_string()))?; + Ok(model_to_entity(model)) +} + +pub async fn find_categories(db: &Arc) -> Result, AppError> { + let rows = ArticlesEntity::find() + .select_only() + .column(ArticleColumn::Category) + .distinct() + .all(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(|r| r.category).collect()) +} diff --git a/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_repository.rs b/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_repository.rs new file mode 100644 index 0000000..5c3eb9c --- /dev/null +++ b/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_repository.rs @@ -0,0 +1,67 @@ +use super::postgres_article_queries::{ + find_all_paginated, find_by_id, find_by_slug, find_categories, +}; +use super::super::super::domain::article::ArticleEntity; +use super::super::super::domain::repository::ArticleRepository; +use imphnen_entities::seaorm::common::articles::{ActiveModel, Column as ArticleColumn}; +use imphnen_utils::AppError; +use async_trait::async_trait; +use paginator_utils::PaginatorResponse; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; +use uuid::Uuid; + +pub struct PostgresArticleRepository { + db: Arc, +} + +impl PostgresArticleRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } +} + +#[async_trait] +impl ArticleRepository for PostgresArticleRepository { + async fn find_all_paginated( + &self, + page: u64, + per_page: u64, + category: Option, + ) -> Result, AppError> { + find_all_paginated(&self.db, page, per_page, category).await + } + + async fn find_by_id(&self, id: Uuid) -> Result { + find_by_id(&self.db, id).await + } + + async fn find_by_slug(&self, slug: &str) -> Result { + find_by_slug(&self.db, slug).await + } + + async fn find_categories(&self) -> Result, AppError> { + find_categories(&self.db).await + } + + async fn create(&self, entity: ArticleEntity) -> Result { + let active = ActiveModel { + id: sea_orm::ActiveValue::Set(entity.id), + title: Set(entity.title), + slug: Set(entity.slug), + category: Set(entity.category), + excerpt: Set(entity.excerpt), + content: Set(entity.content), + cover_url: Set(entity.cover_url), + author_name: Set(entity.author_name), + is_published: Set(entity.is_published), + created_at: Set(entity.created_at), + updated_at: Set(entity.updated_at), + }; + active + .insert(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(entity.id) + } +} diff --git a/imphnen-dimentorin/src/articles/mod.rs b/imphnen-dimentorin/src/articles/mod.rs new file mode 100644 index 0000000..3c1daba --- /dev/null +++ b/imphnen-dimentorin/src/articles/mod.rs @@ -0,0 +1,5 @@ +pub mod domain; +pub mod application; +pub mod infrastructure; + +pub use infrastructure::http::routes::{articles_public_routes, articles_protected_routes}; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs index caaf26e..053daf9 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs @@ -12,7 +12,13 @@ use axum::{ use imphnen_libs::{ValidatedJson, decode_access_token}; use imphnen_utils::AppError; use imphnen_utils::ApiSuccess; +use imphnen_entities::seaorm::auth::mentors::{ + Column as MentorColumn, Entity as MentorsEntity, +}; +use imphnen_libs::AppState; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use std::sync::Arc; +use uuid::Uuid; fn extract_user_id(headers: &HeaderMap) -> Result { let token = headers @@ -43,14 +49,23 @@ fn extract_user_id(headers: &HeaderMap) -> Result { )] pub async fn post_book_session( headers: HeaderMap, + Extension(state): Extension, Extension(service): Extension>, Path(mentor_id): Path, ValidatedJson(dto): ValidatedJson, ) -> Result { let user_id = extract_user_id(&headers)?; + let mentor_uuid = Uuid::parse_str(&mentor_id).map_err(|_| { + AppError::BadRequestError("Invalid mentor ID format".to_string()) + })?; + // Resolve mentor profile id -> user id (sessions.mentor_id FK ke app_users) + let mentor = MentorsEntity::find_by_id(mentor_uuid) + .one(&state.postgres_connection.conn) + .await? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; let resp = BookSessionResponseDto::from( service - .book_session(mentor_id, user_id, dto.into()) + .book_session(mentor.user_id.to_string(), user_id, dto.into()) .await?, ); Ok(ApiSuccess(resp)) diff --git a/imphnen-entities/src/seaorm/common/articles.rs b/imphnen-entities/src/seaorm/common/articles.rs new file mode 100644 index 0000000..93c9585 --- /dev/null +++ b/imphnen-entities/src/seaorm/common/articles.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "app_articles")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + pub title: String, + + pub slug: String, + + pub category: String, + + pub excerpt: String, + + pub content: String, + + #[sea_orm(nullable)] + pub cover_url: Option, + + #[sea_orm(nullable)] + pub author_name: Option, + + pub is_published: bool, + + pub created_at: DateTime, + + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/common/mod.rs b/imphnen-entities/src/seaorm/common/mod.rs index 7f9eed6..e14fd78 100644 --- a/imphnen-entities/src/seaorm/common/mod.rs +++ b/imphnen-entities/src/seaorm/common/mod.rs @@ -1,3 +1,4 @@ +pub mod articles; pub mod audit_log; pub mod enum_impls; pub mod enums;