diff --git a/imphnen-backend/src/bin/seed_articles.rs b/imphnen-backend/src/bin/seed_articles.rs new file mode 100644 index 0000000..577675f --- /dev/null +++ b/imphnen-backend/src/bin/seed_articles.rs @@ -0,0 +1,40 @@ +#![allow(clippy::all)] +use chrono::Utc; +use imphnen_entities::seaorm::common::articles::{ + ActiveModel as ArticleActiveModel, Entity as ArticlesEntity, +}; +use imphnen_libs::postgres::PostgresConfig; +use sea_orm::{ActiveModelTrait, ActiveValue, Database}; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = PostgresConfig::from_env()?; + let db = Database::connect(&config.database_url).await?; + + let articles = vec![ + ("Cara Memulai Karier di UI/UX Design", "cara-memulai-karier-ui-ux-design", "UI/UX & Design", "Panduan lengkap untuk masuk ke dunia UI/UX design, dari skill yang dibutuhkan hingga portofolio.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. UI/UX design adalah bidang yang menjanjikan. Artikel ini membahas langkah awal memulai karier sebagai UI/UX designer, tools yang wajib dikuasai seperti Figma, serta cara membangun portofolio yang menarik bagi perekrut."), + ("Belajar Rust: Panduan Pemula 2026", "belajar-rust-panduan-pemula-2026", "Software/Web Dev", "Bahasa pemrograman Rust sedang naik daun. Pelajari konsep ownership dan borrow checker.", "Rust adalah bahasa pemrograman yang fokus pada performa dan keamanan memori. Dalam artikel ini kita membahas ownership, borrowing, dan cara setup environment Rust di Linux dan Windows, serta contoh project sederhana."), + ("Mengenal Machine Learning untuk Data Analyst", "mengenal-machine-learning-data-analyst", "Data & AI", "Peran Data Analyst berevolusi dengan hadirnya machine learning. Simak panduannya.", "Machine learning membuka peluang besar bagi data analyst. Artikel ini menjelaskan perbedaan data analysis dan machine learning, serta roadmap belajar dari Python, pandas, sampai scikit-learn."), + ]; + + for (title, slug, category, excerpt, content) in articles { + let am = ArticleActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + title: ActiveValue::Set(title.to_string()), + slug: ActiveValue::Set(slug.to_string()), + category: ActiveValue::Set(category.to_string()), + excerpt: ActiveValue::Set(excerpt.to_string()), + content: ActiveValue::Set(content.to_string()), + cover_url: ActiveValue::Set(None), + author_name: ActiveValue::Set(Some("IMPHNEN Editorial".to_string())), + is_published: ActiveValue::Set(true), + created_at: ActiveValue::Set(Utc::now()), + updated_at: ActiveValue::Set(Utc::now()), + }; + am.insert(&db).await?; + println!("✅ Inserted article: {}", slug); + } + println!("🟢 All articles seeded"); + Ok(()) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/domain/mod.rs b/imphnen-dimentorin/src/articles/domain/mod.rs index df1e347..7b24202 100644 --- a/imphnen-dimentorin/src/articles/domain/mod.rs +++ b/imphnen-dimentorin/src/articles/domain/mod.rs @@ -2,3 +2,8 @@ pub mod article; pub mod article_types; pub mod repository; pub mod service; + +pub use repository::ArticleRepository; +pub use service::ArticleService; +pub use article::ArticleEntity; +pub use article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand}; diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs index e69de29..1790f50 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/dto/mod.rs @@ -0,0 +1,5 @@ +pub mod request; +pub mod response; + +pub use request::CreateArticleRequestDto; +pub use response::{ArticleDetailDto, ArticleListItemDto}; \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs index e69de29..cd63349 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/dto/request.rs @@ -0,0 +1,43 @@ +use crate::articles::domain::article_types::CreateArticleCommand; +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zod_rs::prelude::*; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct CreateArticleRequestDto { + #[zod(min_length(3), max_length(200))] + pub title: String, + #[zod(min_length(3), max_length(200))] + pub slug: String, + #[zod(min_length(1), max_length(100))] + pub category: String, + #[zod(min_length(3), max_length(500))] + pub excerpt: String, + #[zod(min_length(10))] + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author_name: Option, +} + +impl ZodValidate for CreateArticleRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for CreateArticleCommand { + fn from(dto: CreateArticleRequestDto) -> Self { + Self { + title: dto.title, + slug: dto.slug, + category: dto.category, + excerpt: dto.excerpt, + content: dto.content, + cover_url: dto.cover_url, + author_name: dto.author_name, + } + } +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs b/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs index e69de29..7db38f0 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/dto/response.rs @@ -0,0 +1,63 @@ +use crate::articles::domain::article_types::{ArticleDetail, ArticleListItem}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ArticleListItemDto { + pub id: String, + pub title: String, + pub slug: String, + pub category: String, + pub excerpt: String, + pub cover_url: Option, + pub author_name: Option, + pub created_at: String, +} + +impl From for ArticleListItemDto { + fn from(a: ArticleListItem) -> Self { + Self { + id: a.id.to_string(), + title: a.title, + slug: a.slug, + category: a.category, + excerpt: a.excerpt, + cover_url: a.cover_url, + author_name: a.author_name, + created_at: a.created_at.to_rfc3339(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ArticleDetailDto { + pub id: String, + 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: String, + pub updated_at: String, +} + +impl From for ArticleDetailDto { + fn from(a: ArticleDetail) -> Self { + Self { + id: a.id.to_string(), + title: a.title, + slug: a.slug, + category: a.category, + excerpt: a.excerpt, + content: a.content, + cover_url: a.cover_url, + author_name: a.author_name, + is_published: a.is_published, + created_at: a.created_at.to_rfc3339(), + updated_at: a.updated_at.to_rfc3339(), + } + } +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs index e69de29..b3ec6c2 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mod.rs @@ -0,0 +1,8 @@ +pub mod mutation_handlers; +pub mod query_handlers; + +pub use mutation_handlers::post_create_article; +pub use query_handlers::{ + get_article_by_id, get_article_by_slug, get_article_categories, + get_articles_list, +}; \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs index e69de29..078f310 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/mutation_handlers.rs @@ -0,0 +1,27 @@ +use super::super::dto::{ArticleDetailDto, CreateArticleRequestDto}; +use crate::articles::domain::ArticleService; +use axum::{response::IntoResponse, Extension}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::{ApiSuccess, AppError}; +use std::sync::Arc; + +#[utoipa::path( + post, + path = "/v1/dimentorin/articles/create", + request_body = CreateArticleRequestDto, + responses( + (status = 201, description = "Article created successfully", body = ArticleDetailDto), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal server error") + ), + tag = "Articles", + security(("Bearer" = [])) +)] +pub async fn post_create_article( + Extension(_state): Extension, + Extension(service): Extension>, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let detail = service.create(dto.into()).await?; + Ok(ApiSuccess(ArticleDetailDto::from(detail))) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs index e69de29..b6f183d 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/handlers/query_handlers.rs @@ -0,0 +1,108 @@ +use super::super::dto::{ArticleDetailDto, ArticleListItemDto}; +use crate::articles::domain::ArticleService; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, +}; +use imphnen_libs::AppState; +use imphnen_utils::{ApiPaginated, ApiSuccess, AppError}; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct ArticleListQuery { + pub page: Option, + pub per_page: Option, + pub category: Option, +} + +#[utoipa::path( + get, + path = "/v1/dimentorin/articles", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("category" = Option, Query, description = "Filter by category"), + ), + responses( + (status = 200, description = "Articles retrieved successfully", body = Vec), + (status = 500, description = "Internal server error") + ), + tag = "Articles" +)] +pub async fn get_articles_list( + Extension(_state): Extension, + Extension(service): Extension>, + Query(q): Query, +) -> Result { + let page = q.page.unwrap_or(1).max(1); + let per_page = q.per_page.unwrap_or(10).clamp(1, 100); + let result = service.list(page, per_page, q.category).await?; + let mapped = PaginatorResponse { + data: result.data.into_iter().map(ArticleListItemDto::from).collect(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) +} + +#[utoipa::path( + get, + path = "/v1/dimentorin/articles/{id}", + params(("id" = String, Path, description = "Article ID")), + responses( + (status = 200, description = "Article retrieved successfully", body = ArticleDetailDto), + (status = 404, description = "Article not found"), + (status = 500, description = "Internal server error") + ), + tag = "Articles" +)] +pub async fn get_article_by_id( + Extension(_state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let uuid = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid article ID".to_string()))?; + let dto = ArticleDetailDto::from(service.get_by_id(uuid).await?); + Ok(ApiSuccess(dto)) +} + +#[utoipa::path( + get, + path = "/v1/dimentorin/articles/slug/{slug}", + params(("slug" = String, Path, description = "Article slug")), + responses( + (status = 200, description = "Article retrieved successfully", body = ArticleDetailDto), + (status = 404, description = "Article not found"), + (status = 500, description = "Internal server error") + ), + tag = "Articles" +)] +pub async fn get_article_by_slug( + Extension(_state): Extension, + Extension(service): Extension>, + Path(slug): Path, +) -> Result { + let dto = ArticleDetailDto::from(service.get_by_slug(&slug).await?); + Ok(ApiSuccess(dto)) +} + +#[utoipa::path( + get, + path = "/v1/dimentorin/articles/categories", + responses( + (status = 200, description = "Article categories retrieved successfully"), + (status = 500, description = "Internal server error") + ), + tag = "Articles" +)] +pub async fn get_article_categories( + Extension(_state): Extension, + Extension(service): Extension>, +) -> Result { + let cats = service.categories().await?; + Ok(ApiSuccess(cats)) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs index e69de29..1e75d0b 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs b/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs index e69de29..988aea6 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/http/routes.rs @@ -0,0 +1,37 @@ +use super::handlers::{ + get_article_by_id, get_article_by_slug, get_article_categories, + get_articles_list, post_create_article, +}; +use crate::articles::application::ArticleServiceImpl; +use crate::articles::domain::ArticleService; +use crate::articles::infrastructure::persistence::PostgresArticleRepository; +use axum::{Extension, Router, routing::{get, post}}; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +fn build_service(db: DatabaseConnection) -> Arc { + let repo = Arc::new(PostgresArticleRepository::new(db)); + Arc::new(ArticleServiceImpl::new(repo)) +} + +pub fn articles_public_routes(db: DatabaseConnection) -> Router { + let service = build_service(db); + Router::new() + .route("/articles", get(get_articles_list)) + .route("/articles/categories", get(get_article_categories)) + .route("/articles/slug/{slug}", get(get_article_by_slug)) + .route("/articles/{id}", get(get_article_by_id)) + .layer(Extension(service)) +} + +pub fn articles_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/articles/create", post(post_create_article)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/mod.rs index e69de29..21fc016 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/mod.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; \ No newline at end of file diff --git a/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs index fa38e02..4f0abaf 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/persistence/mod.rs @@ -1,2 +1,4 @@ pub mod postgres_article_queries; -pub mod postgres_article_repository; \ No newline at end of file +pub mod postgres_article_repository; + +pub use postgres_article_repository::PostgresArticleRepository; \ 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 index 6850963..615a5f3 100644 --- a/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_queries.rs +++ b/imphnen-dimentorin/src/articles/infrastructure/persistence/postgres_article_queries.rs @@ -5,7 +5,9 @@ use imphnen_entities::seaorm::common::articles::{ use imphnen_utils::AppError; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; use sea_orm::prelude::*; -use sea_orm::{EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder}; +use sea_orm::{ + EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, +}; use std::sync::Arc; pub fn model_to_entity(model: ArticleModel) -> ArticleEntity { @@ -49,7 +51,7 @@ pub async fn find_all_paginated( .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); + let meta = PaginatorResponseMeta::new(page as u32, per_page as u32, total as u32); Ok(PaginatorResponse { data, meta }) } @@ -81,12 +83,16 @@ pub async fn find_by_slug( } pub async fn find_categories(db: &Arc) -> Result, AppError> { - let rows = ArticlesEntity::find() + let rows: Vec = ArticlesEntity::find() .select_only() .column(ArticleColumn::Category) .distinct() + .into_json() .all(db.as_ref()) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(|r| r.category).collect()) + Ok(rows + .into_iter() + .filter_map(|r| r["category"].as_str().map(|s| s.to_string())) + .collect()) } diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index 115975f..1128ed2 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -1,5 +1,7 @@ +pub mod articles; pub mod mentors; pub mod sessions; +pub use articles::{articles_protected_routes, articles_public_routes}; pub use mentors::{mentors_protected_routes, mentors_public_routes}; pub use sessions::{sessions_protected_routes, sessions_public_routes}; diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index a959757..1757513 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -6,8 +6,8 @@ use imphnen_cms::{ roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes, }; use imphnen_dimentorin::{ - mentors_protected_routes, mentors_public_routes, sessions_protected_routes, - sessions_public_routes, + articles_protected_routes, articles_public_routes, mentors_protected_routes, + mentors_public_routes, sessions_protected_routes, sessions_public_routes, }; use imphnen_gacha::gacha_router; use imphnen_hackathon::hackathon_router; @@ -74,10 +74,15 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { let dimentorin_routes = Router::new() .merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc))) .merge(sessions_public_routes(db.clone())) + .merge(articles_public_routes(db.clone())) .merge( Router::new() .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) .merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc))) + .merge(articles_protected_routes( + db.clone(), + Arc::clone(&state_arc), + )) .layer(from_fn(auth_middleware)), );