From ac8177d87e8eaed4e5c9f6a61af568aad15e738b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 5 Aug 2026 17:01:59 +0700 Subject: [PATCH] feat(dimentorin): materials module (CRUD konten mentoring, public+protected routes) - app_materials table: mentor_id, title, slug, category, description, content - domain/application/infrastructure pola articles: repo postgres, service, DTO ZodValidate - routes: GET /materials (public, published only), GET /materials/{id|slug|categories}, POST/PUT/DELETE (auth, author-only) - verified e2e: create 3 materi sebagai mentor, list, slug, categories --- imphnen-dimentorin/src/lib.rs | 2 + .../materials/application/material_service.rs | 135 ++++++++++++++++ .../src/materials/application/mod.rs | 3 + .../src/materials/domain/material.rs | 17 ++ .../src/materials/domain/material_types.rs | 55 +++++++ .../src/materials/domain/mod.rs | 11 ++ .../src/materials/domain/repository.rs | 23 +++ .../src/materials/domain/service.rs | 34 ++++ .../src/materials/infrastructure/http/dto.rs | 137 ++++++++++++++++ .../materials/infrastructure/http/handlers.rs | 130 +++++++++++++++ .../src/materials/infrastructure/http/mod.rs | 3 + .../materials/infrastructure/http/routes.rs | 41 +++++ .../src/materials/infrastructure/mod.rs | 2 + .../infrastructure/persistence/mod.rs | 3 + .../postgres_material_repository.rs | 152 ++++++++++++++++++ imphnen-dimentorin/src/materials/mod.rs | 7 + .../src/seaorm/common/materials.rs | 37 +++++ imphnen-entities/src/seaorm/common/mod.rs | 1 + imphnen-gateway/src/lib.rs | 11 +- 19 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 imphnen-dimentorin/src/materials/application/material_service.rs create mode 100644 imphnen-dimentorin/src/materials/application/mod.rs create mode 100644 imphnen-dimentorin/src/materials/domain/material.rs create mode 100644 imphnen-dimentorin/src/materials/domain/material_types.rs create mode 100644 imphnen-dimentorin/src/materials/domain/mod.rs create mode 100644 imphnen-dimentorin/src/materials/domain/repository.rs create mode 100644 imphnen-dimentorin/src/materials/domain/service.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/http/dto.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/http/handlers.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/http/mod.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/http/routes.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/mod.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/persistence/mod.rs create mode 100644 imphnen-dimentorin/src/materials/infrastructure/persistence/postgres_material_repository.rs create mode 100644 imphnen-dimentorin/src/materials/mod.rs create mode 100644 imphnen-entities/src/seaorm/common/materials.rs diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index 30918bc..5391cf7 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -1,9 +1,11 @@ pub mod articles; +pub mod materials; pub mod mentors; pub mod payments; pub mod sessions; pub use articles::{articles_protected_routes, articles_public_routes}; +pub use materials::{materials_protected_routes, materials_public_routes}; pub use mentors::{mentors_protected_routes, mentors_public_routes}; pub use payments::payments_protected_routes; pub use sessions::{sessions_protected_routes, sessions_public_routes}; \ No newline at end of file diff --git a/imphnen-dimentorin/src/materials/application/material_service.rs b/imphnen-dimentorin/src/materials/application/material_service.rs new file mode 100644 index 0000000..95aefd4 --- /dev/null +++ b/imphnen-dimentorin/src/materials/application/material_service.rs @@ -0,0 +1,135 @@ +use paginator_utils::PaginatorResponse; +use chrono::Utc; +use uuid::Uuid; + +use super::super::domain::{ + CreateMaterialCommand, MaterialEntity, MaterialListItem, + MaterialRepository, MaterialService, UpdateMaterialCommand, +}; +use crate::materials::domain::{MaterialRepository as _}; +use imphnen_utils::AppError; + +pub struct MaterialServiceImpl { + repo: Box, +} + +impl MaterialServiceImpl { + pub fn new(repo: Box) -> Self { + Self { repo } + } + + fn slugify(title: &str) -> String { + let slug: String = title + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c + } else if c.is_whitespace() { + '-' + } else { + '-' + } + }) + .collect(); + slug.trim_matches('-').to_string() + } +} + +#[async_trait::async_trait] +impl MaterialService for MaterialServiceImpl { + async fn list_materials( + &self, + page: u64, + per_page: u64, + category: Option, + published_only: bool, + ) -> Result, AppError> { + let res = self + .repo + .find_all_paginated(page, per_page, category, published_only) + .await?; + Ok(PaginatorResponse { + data: res + .data + .into_iter() + .map(|e| MaterialListItem::from_entity(&e)) + .collect(), + meta: res.meta, + }) + } + + async fn get_material_by_id(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } + + async fn get_material_by_slug(&self, slug: &str) -> Result { + self.repo.find_by_slug(slug).await + } + + async fn list_categories(&self) -> Result, AppError> { + self.repo.find_categories().await + } + + async fn create_material( + &self, + cmd: CreateMaterialCommand, + ) -> Result { + let now = Utc::now(); + let entity = MaterialEntity { + id: Uuid::new_v4(), + mentor_id: cmd.mentor_id, + title: cmd.title.clone(), + slug: format!("{}-{}", Self::slugify(&cmd.title), Uuid::new_v4().to_string()[..8].to_string()), + category: cmd.category, + description: cmd.description, + content: cmd.content, + cover_url: cmd.cover_url, + is_published: true, + created_at: now, + updated_at: now, + }; + self.repo.create(entity.clone()).await?; + Ok(MaterialListItem::from_entity(&entity)) + } + + async fn update_material( + &self, + id: Uuid, + actor_id: Uuid, + cmd: UpdateMaterialCommand, + ) -> Result { + let existing = self.repo.find_by_id(id).await?; + if existing.mentor_id != actor_id { + return Err(AppError::ForbiddenError( + "Only the author can update this material".into(), + )); + } + // do not change mentor on update + let entity = MaterialEntity { + id, + mentor_id: existing.mentor_id, + title: cmd.title.unwrap_or(existing.title), + slug: existing.slug, + category: cmd.category.unwrap_or(existing.category), + description: cmd.description.unwrap_or(existing.description), + content: cmd.content.unwrap_or(existing.content), + cover_url: cmd.cover_url.or(existing.cover_url), + is_published: cmd.is_published.unwrap_or(existing.is_published), + created_at: existing.created_at, + updated_at: Utc::now(), + }; + self.repo.update(id, entity.clone()).await?; + Ok(MaterialListItem::from_entity(&entity)) + } + + async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError> { + let existing = self.repo.find_by_id(id).await?; + if existing.mentor_id != actor_id { + return Err(AppError::ForbiddenError( + "Only the author can delete this material".into(), + )); + } + self.repo.delete(id).await + } +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/materials/application/mod.rs b/imphnen-dimentorin/src/materials/application/mod.rs new file mode 100644 index 0000000..b75d2da --- /dev/null +++ b/imphnen-dimentorin/src/materials/application/mod.rs @@ -0,0 +1,3 @@ +pub mod material_service; + +pub use material_service::MaterialServiceImpl; \ No newline at end of file diff --git a/imphnen-dimentorin/src/materials/domain/material.rs b/imphnen-dimentorin/src/materials/domain/material.rs new file mode 100644 index 0000000..b3b6613 --- /dev/null +++ b/imphnen-dimentorin/src/materials/domain/material.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct MaterialEntity { + pub id: Uuid, + pub mentor_id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub description: String, + pub content: String, + pub cover_url: Option, + pub is_published: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/imphnen-dimentorin/src/materials/domain/material_types.rs b/imphnen-dimentorin/src/materials/domain/material_types.rs new file mode 100644 index 0000000..418f1e8 --- /dev/null +++ b/imphnen-dimentorin/src/materials/domain/material_types.rs @@ -0,0 +1,55 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct CreateMaterialCommand { + pub mentor_id: Uuid, + pub title: String, + pub category: String, + pub description: String, + pub content: String, + pub cover_url: Option, +} + +#[derive(Clone, Debug)] +pub struct UpdateMaterialCommand { + pub title: Option, + pub category: Option, + pub description: Option, + pub content: Option, + pub cover_url: Option, + pub is_published: Option, +} + +#[derive(Clone, Debug)] +pub struct MaterialListItem { + pub id: Uuid, + pub mentor_id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub description: String, + pub cover_url: Option, + pub is_published: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl MaterialListItem { + pub fn from_entity(e: &MaterialEntity) -> Self { + Self { + id: e.id, + mentor_id: e.mentor_id, + title: e.title.clone(), + slug: e.slug.clone(), + category: e.category.clone(), + description: e.description.clone(), + cover_url: e.cover_url.clone(), + is_published: e.is_published, + created_at: e.created_at, + updated_at: e.updated_at, + } + } +} + +pub use crate::materials::domain::material::MaterialEntity; diff --git a/imphnen-dimentorin/src/materials/domain/mod.rs b/imphnen-dimentorin/src/materials/domain/mod.rs new file mode 100644 index 0000000..80d54d3 --- /dev/null +++ b/imphnen-dimentorin/src/materials/domain/mod.rs @@ -0,0 +1,11 @@ +pub mod material; +pub mod material_types; +pub mod repository; +pub mod service; + +pub use material::MaterialEntity; +pub use material_types::{ + CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand, +}; +pub use repository::MaterialRepository; +pub use service::MaterialService; diff --git a/imphnen-dimentorin/src/materials/domain/repository.rs b/imphnen-dimentorin/src/materials/domain/repository.rs new file mode 100644 index 0000000..9519325 --- /dev/null +++ b/imphnen-dimentorin/src/materials/domain/repository.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +use super::material::MaterialEntity; +use imphnen_utils::AppError; + +#[async_trait] +pub trait MaterialRepository: Send + Sync { + async fn find_all_paginated( + &self, + page: u64, + per_page: u64, + category: Option, + published_only: bool, + ) -> 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: MaterialEntity) -> Result; + async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-dimentorin/src/materials/domain/service.rs b/imphnen-dimentorin/src/materials/domain/service.rs new file mode 100644 index 0000000..115a935 --- /dev/null +++ b/imphnen-dimentorin/src/materials/domain/service.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +use super::material::MaterialEntity; +use super::material_types::{ + CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand, +}; +use imphnen_utils::AppError; + +#[async_trait] +pub trait MaterialService: Send + Sync { + async fn list_materials( + &self, + page: u64, + per_page: u64, + category: Option, + published_only: bool, + ) -> Result, AppError>; + async fn get_material_by_id(&self, id: Uuid) -> Result; + async fn get_material_by_slug(&self, slug: &str) -> Result; + async fn list_categories(&self) -> Result, AppError>; + async fn create_material( + &self, + cmd: CreateMaterialCommand, + ) -> Result; + async fn update_material( + &self, + id: Uuid, + actor_id: Uuid, + cmd: UpdateMaterialCommand, + ) -> Result; + async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-dimentorin/src/materials/infrastructure/http/dto.rs b/imphnen-dimentorin/src/materials/infrastructure/http/dto.rs new file mode 100644 index 0000000..6c99fd0 --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/http/dto.rs @@ -0,0 +1,137 @@ +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use zod_rs::prelude::*; + +use crate::materials::domain::{MaterialEntity, MaterialListItem}; +use imphnen_libs::ZodValidate; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateMaterialRequest { + #[zod(min_length(3), max_length(200))] + pub title: String, + #[zod(min_length(1), max_length(100))] + pub category: String, + #[zod(min_length(3), max_length(500))] + pub description: String, + #[zod(min_length(10))] + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, +} + +impl ZodValidate for CreateMaterialRequest { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMaterialRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_published: Option, +} + +impl ZodValidate for UpdateMaterialRequest { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Serialize, Debug, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MaterialResponse { + pub id: Uuid, + pub mentor_id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub description: String, + pub content: String, + pub cover_url: Option, + pub is_published: bool, + pub created_at: String, + pub updated_at: String, +} + +impl MaterialResponse { + pub fn from_entity(e: &MaterialEntity) -> Self { + Self { + id: e.id, + mentor_id: e.mentor_id, + title: e.title.clone(), + slug: e.slug.clone(), + category: e.category.clone(), + description: e.description.clone(), + content: e.content.clone(), + cover_url: e.cover_url.clone(), + is_published: e.is_published, + created_at: e.created_at.to_rfc3339(), + updated_at: e.updated_at.to_rfc3339(), + } + } +} + +#[derive(Serialize, Debug, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MaterialListItemResponse { + pub id: Uuid, + pub mentor_id: Uuid, + pub title: String, + pub slug: String, + pub category: String, + pub description: String, + pub cover_url: Option, + pub is_published: bool, + pub created_at: String, + pub updated_at: String, +} + +impl From<&MaterialEntity> for MaterialListItemResponse { + fn from(e: &MaterialEntity) -> Self { + Self { + id: e.id, + mentor_id: e.mentor_id, + title: e.title.clone(), + slug: e.slug.clone(), + category: e.category.clone(), + description: e.description.clone(), + cover_url: e.cover_url.clone(), + is_published: e.is_published, + created_at: e.created_at.to_rfc3339(), + updated_at: e.updated_at.to_rfc3339(), + } + } +} + +impl From<&MaterialListItem> for MaterialListItemResponse { + fn from(e: &MaterialListItem) -> Self { + Self { + id: e.id, + mentor_id: e.mentor_id, + title: e.title.clone(), + slug: e.slug.clone(), + category: e.category.clone(), + description: e.description.clone(), + cover_url: e.cover_url.clone(), + is_published: e.is_published, + created_at: e.created_at.to_rfc3339(), + updated_at: e.updated_at.to_rfc3339(), + } + } +} + +pub type MaterialListResponse = PaginatorResponse; diff --git a/imphnen-dimentorin/src/materials/infrastructure/http/handlers.rs b/imphnen-dimentorin/src/materials/infrastructure/http/handlers.rs new file mode 100644 index 0000000..93dcfd3 --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/http/handlers.rs @@ -0,0 +1,130 @@ +use super::dto::{ + CreateMaterialRequest, MaterialListItemResponse, MaterialResponse, + UpdateMaterialRequest, +}; +use crate::materials::domain::{ + CreateMaterialCommand, MaterialService, UpdateMaterialCommand, +}; +use axum::{ + Extension, extract::{Path, Query}, + http::{HeaderMap, header::AUTHORIZATION}, + response::IntoResponse, +}; +use imphnen_libs::{ValidatedJson, decode_access_token}; +use imphnen_utils::{ApiSuccess, AppError}; +use paginator_utils::PaginatorResponse; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +fn extract_user_id(headers: &HeaderMap) -> Result { + let token = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let claims = decode_access_token(token) + .map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))?; + Uuid::parse_str(&claims.claims.user_id) + .map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string())) +} + +pub async fn get_materials_list( + Extension(service): Extension>, + Query(params): Query>, +) -> Result { + let page: u64 = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(1); + let per_page: u64 = params + .get("per_page") + .and_then(|p| p.parse().ok()) + .unwrap_or(10); + let category = params.get("category").cloned().filter(|c| !c.is_empty()); + // public listing always shows published only + let res = service + .list_materials(page, per_page, category, true) + .await?; + let data: Vec = + res.data.iter().map(|e| e.into()).collect(); + Ok(ApiSuccess(PaginatorResponse { + data, + meta: res.meta, + })) +} + +pub async fn get_material_categories( + Extension(service): Extension>, +) -> Result { + let categories = service.list_categories().await?; + Ok(ApiSuccess(categories)) +} + +pub async fn get_material_by_slug( + Extension(service): Extension>, + Path(slug): Path, +) -> Result { + let material = service.get_material_by_slug(&slug).await?; + if !material.is_published { + return Err(AppError::NotFoundError("Material not found".into())); + } + Ok(ApiSuccess(MaterialResponse::from_entity(&material))) +} + +pub async fn get_material_by_id( + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let material = service.get_material_by_id(id).await?; + if !material.is_published { + return Err(AppError::NotFoundError("Material not found".into())); + } + Ok(ApiSuccess(MaterialResponse::from_entity(&material))) +} + +pub async fn post_create_material( + headers: HeaderMap, + Extension(service): Extension>, + ValidatedJson(body): ValidatedJson, +) -> Result { + let user_id = extract_user_id(&headers)?; + let cmd = CreateMaterialCommand { + mentor_id: user_id, + title: body.title, + category: body.category, + description: body.description, + content: body.content, + cover_url: body.cover_url, + }; + let material = service.create_material(cmd).await?; + Ok(ApiSuccess(MaterialListItemResponse::from(&material))) +} + +pub async fn put_update_material( + headers: HeaderMap, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(body): ValidatedJson, +) -> Result { + let user_id = extract_user_id(&headers)?; + let cmd = UpdateMaterialCommand { + title: body.title, + category: body.category, + description: body.description, + content: body.content, + cover_url: body.cover_url, + is_published: body.is_published, + }; + let material = service.update_material(id, user_id, cmd).await?; + Ok(ApiSuccess(MaterialListItemResponse::from(&material))) +} + +pub async fn delete_material( + headers: HeaderMap, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let user_id = extract_user_id(&headers)?; + service.delete_material(id, user_id).await?; + Ok(ApiSuccess(serde_json::json!({ + "message": format!("Material {} deleted", id) + }))) +} diff --git a/imphnen-dimentorin/src/materials/infrastructure/http/mod.rs b/imphnen-dimentorin/src/materials/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-dimentorin/src/materials/infrastructure/http/routes.rs b/imphnen-dimentorin/src/materials/infrastructure/http/routes.rs new file mode 100644 index 0000000..6cb707f --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/http/routes.rs @@ -0,0 +1,41 @@ +use super::handlers::{ + delete_material, get_material_by_id, get_material_by_slug, get_material_categories, + get_materials_list, post_create_material, put_update_material, +}; +use crate::materials::application::MaterialServiceImpl; +use crate::materials::domain::MaterialService; +use crate::materials::infrastructure::persistence::PostgresMaterialRepository; +use axum::{ + Extension, Router, routing::{delete, get, post, put}, +}; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +fn build_service(db: DatabaseConnection) -> Arc { + let repo = Box::new(PostgresMaterialRepository::new(db)); + Arc::new(MaterialServiceImpl::new(repo)) +} + +pub fn materials_public_routes(db: DatabaseConnection) -> Router { + let service = build_service(db); + Router::new() + .route("/materials", get(get_materials_list)) + .route("/materials/categories", get(get_material_categories)) + .route("/materials/slug/{slug}", get(get_material_by_slug)) + .route("/materials/{id}", get(get_material_by_id)) + .layer(Extension(service)) +} + +pub fn materials_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/materials", post(post_create_material)) + .route("/materials/{id}", put(put_update_material)) + .route("/materials/{id}", delete(delete_material)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) +} diff --git a/imphnen-dimentorin/src/materials/infrastructure/mod.rs b/imphnen-dimentorin/src/materials/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-dimentorin/src/materials/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/materials/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..add896f --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/persistence/mod.rs @@ -0,0 +1,3 @@ +pub mod postgres_material_repository; + +pub use postgres_material_repository::PostgresMaterialRepository; diff --git a/imphnen-dimentorin/src/materials/infrastructure/persistence/postgres_material_repository.rs b/imphnen-dimentorin/src/materials/infrastructure/persistence/postgres_material_repository.rs new file mode 100644 index 0000000..06a7bb7 --- /dev/null +++ b/imphnen-dimentorin/src/materials/infrastructure/persistence/postgres_material_repository.rs @@ -0,0 +1,152 @@ +use async_trait::async_trait; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, + ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use uuid::Uuid; + +use crate::materials::domain::{MaterialEntity, MaterialRepository}; +use imphnen_entities::seaorm::common::materials::{ + ActiveModel, Column, Entity, Model, +}; +use imphnen_utils::AppError; + +#[derive(Clone)] +pub struct PostgresMaterialRepository { + db: DatabaseConnection, +} + +impl PostgresMaterialRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + fn to_entity(model: Model) -> MaterialEntity { + MaterialEntity { + id: model.id, + mentor_id: model.mentor_id, + title: model.title, + slug: model.slug, + category: model.category, + description: model.description, + content: model.content, + cover_url: model.cover_url, + is_published: model.is_published, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +#[async_trait] +impl MaterialRepository for PostgresMaterialRepository { + async fn find_all_paginated( + &self, + page: u64, + per_page: u64, + category: Option, + published_only: bool, + ) -> Result, AppError> { + let mut query = Entity::find(); + if published_only { + query = query.filter(Column::IsPublished.eq(true)); + } + if let Some(cat) = category.filter(|c| !c.is_empty()) { + query = query.filter(Column::Category.eq(cat)); + } + query = query.order_by_desc(Column::CreatedAt); + + let paginator = query.paginate(&self.db, per_page); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let items = paginator + .fetch_page(page.saturating_sub(1)) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let data = items.into_iter().map(Self::to_entity).collect(); + let meta = + PaginatorResponseMeta::new(page as u32, per_page as u32, total as u32); + Ok(PaginatorResponse { data, meta }) + } + + async fn find_by_id(&self, id: Uuid) -> Result { + let model = Entity::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| AppError::NotFoundError("Material not found".into()))?; + Ok(Self::to_entity(model)) + } + + async fn find_by_slug(&self, slug: &str) -> Result { + let model = Entity::find() + .filter(Column::Slug.eq(slug)) + .one(&self.db) + .await? + .ok_or_else(|| AppError::NotFoundError("Material not found".into()))?; + Ok(Self::to_entity(model)) + } + + async fn find_categories(&self) -> Result, AppError> { + let rows: Vec = Entity::find() + .select_only() + .column(Column::Category) + .distinct() + .into_json() + .all(&self.db) + .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()) + } + + async fn create(&self, entity: MaterialEntity) -> Result { + let model = ActiveModel { + id: Set(entity.id), + mentor_id: Set(entity.mentor_id), + title: Set(entity.title), + slug: Set(entity.slug), + category: Set(entity.category), + description: Set(entity.description), + content: Set(entity.content), + cover_url: Set(entity.cover_url), + is_published: Set(entity.is_published), + created_at: Set(entity.created_at), + updated_at: Set(entity.updated_at), + }; + model.insert(&self.db).await?; + Ok(entity.id) + } + + async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError> { + let model = ActiveModel { + id: Set(id), + mentor_id: Set(entity.mentor_id), + title: Set(entity.title), + slug: Set(entity.slug), + category: Set(entity.category), + description: Set(entity.description), + content: Set(entity.content), + cover_url: Set(entity.cover_url), + is_published: Set(entity.is_published), + created_at: Set(entity.created_at), + updated_at: Set(entity.updated_at), + }; + model.update(&self.db).await?; + Ok(()) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let model = Entity::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| AppError::NotFoundError("Material not found".into()))?; + model.delete(&self.db).await?; + Ok(()) + } +} diff --git a/imphnen-dimentorin/src/materials/mod.rs b/imphnen-dimentorin/src/materials/mod.rs new file mode 100644 index 0000000..0466441 --- /dev/null +++ b/imphnen-dimentorin/src/materials/mod.rs @@ -0,0 +1,7 @@ +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use infrastructure::http::routes::{ + materials_protected_routes, materials_public_routes, +}; diff --git a/imphnen-entities/src/seaorm/common/materials.rs b/imphnen-entities/src/seaorm/common/materials.rs new file mode 100644 index 0000000..1f16fa8 --- /dev/null +++ b/imphnen-entities/src/seaorm/common/materials.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_materials")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(indexed)] + pub mentor_id: Uuid, + + pub title: String, + + pub slug: String, + + pub category: String, + + pub description: String, + + pub content: String, + + #[sea_orm(nullable)] + pub cover_url: 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 f532f3b..81d0288 100644 --- a/imphnen-entities/src/seaorm/common/mod.rs +++ b/imphnen-entities/src/seaorm/common/mod.rs @@ -3,6 +3,7 @@ pub mod audit_log; pub mod enum_impls; pub mod enums; pub mod events; +pub mod materials; pub mod otp_cache; pub mod payments; pub mod rate_limit; diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 7072367..806ff8c 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -6,9 +6,9 @@ use imphnen_cms::{ roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes, }; use imphnen_dimentorin::{ - articles_protected_routes, articles_public_routes, mentors_protected_routes, - mentors_public_routes, payments_protected_routes, sessions_protected_routes, - sessions_public_routes, + articles_protected_routes, articles_public_routes, materials_protected_routes, + materials_public_routes, mentors_protected_routes, mentors_public_routes, + payments_protected_routes, sessions_protected_routes, sessions_public_routes, }; use imphnen_gacha::gacha_router; use imphnen_hackathon::hackathon_router; @@ -76,6 +76,7 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { .merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc))) .merge(sessions_public_routes(db.clone())) .merge(articles_public_routes(db.clone())) + .merge(materials_public_routes(db.clone())) .merge( Router::new() .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) @@ -85,6 +86,10 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { db.clone(), Arc::clone(&state_arc), )) + .merge(materials_protected_routes( + db.clone(), + Arc::clone(&state_arc), + )) .layer(from_fn(auth_middleware)), );