Compare commits
3
Commits
67d3f2fced
...
9b5efeff87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5efeff87 | ||
|
|
1d34d29b0b | ||
|
|
7d1078f52a |
@@ -29,6 +29,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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)
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<dyn ArticleRepository>,
|
||||
}
|
||||
|
||||
impl ArticleServiceImpl {
|
||||
pub fn new(repo: Arc<dyn ArticleRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ArticleService for ArticleServiceImpl {
|
||||
async fn list(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleListItem>, AppError> {
|
||||
let result = self.repo.find_all_paginated(page, per_page, category).await?;
|
||||
let items: Vec<ArticleListItem> = result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(ArticleListItem::from)
|
||||
.collect();
|
||||
Ok(PaginatorResponse {
|
||||
data: items,
|
||||
meta: result.meta,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<ArticleDetail, AppError> {
|
||||
let entity = self.repo.find_by_id(id).await?;
|
||||
Ok(ArticleDetail::from(entity))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<ArticleDetail, AppError> {
|
||||
let entity = self.repo.find_by_slug(slug).await?;
|
||||
Ok(ArticleDetail::from(entity))
|
||||
}
|
||||
|
||||
async fn categories(&self) -> Result<Vec<String>, AppError> {
|
||||
self.repo.find_categories().await
|
||||
}
|
||||
|
||||
async fn create(&self, cmd: CreateArticleCommand) -> Result<ArticleDetail, AppError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod article_service;
|
||||
|
||||
pub use article_service::ArticleServiceImpl;
|
||||
@@ -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<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub author_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ArticleEntity> 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<ArticleEntity> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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};
|
||||
@@ -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<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<ArticleEntity, AppError>;
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<ArticleEntity, AppError>;
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(&self, entity: ArticleEntity) -> Result<Uuid, AppError>;
|
||||
}
|
||||
@@ -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<String>,
|
||||
) -> Result<PaginatorResponse<ArticleListItem>, AppError>;
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<ArticleDetail, AppError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<ArticleDetail, AppError>;
|
||||
async fn categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(
|
||||
&self,
|
||||
cmd: CreateArticleCommand,
|
||||
) -> Result<ArticleDetail, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use request::CreateArticleRequestDto;
|
||||
pub use response::{ArticleDetailDto, ArticleListItemDto};
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for CreateArticleRequestDto {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateArticleRequestDto> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleListItem> 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<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleDetail> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
ValidatedJson(dto): ValidatedJson<CreateArticleRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let detail = service.create(dto.into()).await?;
|
||||
Ok(ApiSuccess(ArticleDetailDto::from(detail)))
|
||||
}
|
||||
@@ -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<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles",
|
||||
params(
|
||||
("page" = Option<u64>, Query, description = "Page number"),
|
||||
("per_page" = Option<u64>, Query, description = "Items per page"),
|
||||
("category" = Option<String>, Query, description = "Filter by category"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Articles retrieved successfully", body = Vec<ArticleListItemDto>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_articles_list(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Query(q): Query<ArticleListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let cats = service.categories().await?;
|
||||
Ok(ApiSuccess(cats))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -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<dyn ArticleService> {
|
||||
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<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/articles/create", post(post_create_article))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod postgres_article_queries;
|
||||
pub mod postgres_article_repository;
|
||||
|
||||
pub use postgres_article_repository::PostgresArticleRepository;
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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, QuerySelect,
|
||||
};
|
||||
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<DatabaseConnection>,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, 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 as u32, per_page as u32, total as u32);
|
||||
Ok(PaginatorResponse { data, meta })
|
||||
}
|
||||
|
||||
pub async fn find_by_id(
|
||||
db: &Arc<DatabaseConnection>,
|
||||
id: Uuid,
|
||||
) -> Result<ArticleEntity, AppError> {
|
||||
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<DatabaseConnection>,
|
||||
slug: &str,
|
||||
) -> Result<ArticleEntity, AppError> {
|
||||
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<DatabaseConnection>) -> Result<Vec<String>, AppError> {
|
||||
let rows: Vec<serde_json::Value> = 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()
|
||||
.filter_map(|r| r["category"].as_str().map(|s| s.to_string()))
|
||||
.collect())
|
||||
}
|
||||
+67
@@ -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<DatabaseConnection>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, AppError> {
|
||||
find_all_paginated(&self.db, page, per_page, category).await
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<ArticleEntity, AppError> {
|
||||
find_by_id(&self.db, id).await
|
||||
}
|
||||
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<ArticleEntity, AppError> {
|
||||
find_by_slug(&self.db, slug).await
|
||||
}
|
||||
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError> {
|
||||
find_categories(&self.db).await
|
||||
}
|
||||
|
||||
async fn create(&self, entity: ArticleEntity) -> Result<Uuid, AppError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::{articles_public_routes, articles_protected_routes};
|
||||
@@ -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};
|
||||
|
||||
@@ -13,7 +13,7 @@ use zod_rs::prelude::*;
|
||||
pub struct MentorUserRegisterRequestDto {
|
||||
#[zod(email, min_length(1))]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
|
||||
#[zod(min_length(8))]
|
||||
pub password: String,
|
||||
#[zod(min_length(2))]
|
||||
pub fullname: String,
|
||||
|
||||
+1
@@ -105,6 +105,7 @@ impl MentorRepository for PostgresMentorRepository {
|
||||
|
||||
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> {
|
||||
let active_model = MentorActiveModel {
|
||||
id: ActiveValue::Set(Uuid::new_v4()),
|
||||
user_id: ActiveValue::Set(entity.user_id),
|
||||
industries: ActiveValue::Set(Some(
|
||||
serde_json::to_value(&entity.industries)
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
let token = headers
|
||||
@@ -43,14 +49,23 @@ fn extract_user_id(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
)]
|
||||
pub async fn post_book_session(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn SessionService>>,
|
||||
Path(mentor_id): Path<String>,
|
||||
ValidatedJson(dto): ValidatedJson<BookSessionRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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))
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
#[sea_orm(nullable)]
|
||||
pub author_name: Option<String>,
|
||||
|
||||
pub is_published: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod articles;
|
||||
pub mod audit_log;
|
||||
pub mod enum_impls;
|
||||
pub mod enums;
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ pub struct AuthLoginResponsetDto {
|
||||
pub struct AuthRegisterRequestDto {
|
||||
#[zod(email, min_length(1))]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
|
||||
#[zod(min_length(8))]
|
||||
pub password: String,
|
||||
#[zod(min_length(2))]
|
||||
pub fullname: String,
|
||||
@@ -89,7 +89,7 @@ impl ZodValidate for AuthRefreshTokenRequestDto {
|
||||
pub struct AuthNewPasswordRequestDto {
|
||||
#[zod(min_length(1))]
|
||||
pub token: String,
|
||||
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
|
||||
#[zod(min_length(8))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct UsersSetNewPasswordRequestDto {
|
||||
pub struct UsersCreateRequestDto {
|
||||
#[zod(email, min_length(1))]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
|
||||
#[zod(min_length(8))]
|
||||
pub password: String,
|
||||
#[zod(min_length(2))]
|
||||
pub fullname: String,
|
||||
|
||||
@@ -107,7 +107,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
.ok()
|
||||
.or_else(|| entity.role.id.is_empty().then_some(Uuid::nil()));
|
||||
let active_model = UserActiveModel {
|
||||
id: ActiveValue::Set(Uuid::new_v4()),
|
||||
id: ActiveValue::Set(
|
||||
Uuid::parse_str(&entity.id).unwrap_or_else(|_| Uuid::new_v4()),
|
||||
),
|
||||
email: ActiveValue::Set(entity.email.clone()),
|
||||
password_hash: ActiveValue::Set(entity.password),
|
||||
username: ActiveValue::Set(entity.email.clone()),
|
||||
|
||||
Reference in New Issue
Block a user