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
This commit is contained in:
@@ -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};
|
||||
@@ -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<dyn MaterialRepository>,
|
||||
}
|
||||
|
||||
impl MaterialServiceImpl {
|
||||
pub fn new(repo: Box<dyn MaterialRepository>) -> 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<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialListItem>, 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<MaterialEntity, AppError> {
|
||||
self.repo.find_by_id(id).await
|
||||
}
|
||||
|
||||
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError> {
|
||||
self.repo.find_by_slug(slug).await
|
||||
}
|
||||
|
||||
async fn list_categories(&self) -> Result<Vec<String>, AppError> {
|
||||
self.repo.find_categories().await
|
||||
}
|
||||
|
||||
async fn create_material(
|
||||
&self,
|
||||
cmd: CreateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError> {
|
||||
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<MaterialListItem, AppError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod material_service;
|
||||
|
||||
pub use material_service::MaterialServiceImpl;
|
||||
@@ -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<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpdateMaterialCommand {
|
||||
pub title: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialEntity>, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(&self, entity: MaterialEntity) -> Result<Uuid, AppError>;
|
||||
async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -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<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialListItem>, AppError>;
|
||||
async fn get_material_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
|
||||
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
|
||||
async fn list_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create_material(
|
||||
&self,
|
||||
cmd: CreateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError>;
|
||||
async fn update_material(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
cmd: UpdateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError>;
|
||||
async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for CreateMaterialRequest {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_published: Option<bool>,
|
||||
}
|
||||
|
||||
impl ZodValidate for UpdateMaterialRequest {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
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<String>,
|
||||
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<String>,
|
||||
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<MaterialListItemResponse>;
|
||||
@@ -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<Uuid, AppError> {
|
||||
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<Arc<dyn MaterialService>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<MaterialListItemResponse> =
|
||||
res.data.iter().map(|e| e.into()).collect();
|
||||
Ok(ApiSuccess(PaginatorResponse {
|
||||
data,
|
||||
meta: res.meta,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_material_categories(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let categories = service.list_categories().await?;
|
||||
Ok(ApiSuccess(categories))
|
||||
}
|
||||
|
||||
pub async fn get_material_by_slug(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<dyn MaterialService>>,
|
||||
ValidatedJson(body): ValidatedJson<CreateMaterialRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
ValidatedJson(body): ValidatedJson<UpdateMaterialRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
service.delete_material(id, user_id).await?;
|
||||
Ok(ApiSuccess(serde_json::json!({
|
||||
"message": format!("Material {} deleted", id)
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -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<dyn MaterialService> {
|
||||
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<AppState>,
|
||||
) -> 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()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_material_repository;
|
||||
|
||||
pub use postgres_material_repository::PostgresMaterialRepository;
|
||||
+152
@@ -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<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialEntity>, 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<MaterialEntity, AppError> {
|
||||
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<MaterialEntity, AppError> {
|
||||
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<Vec<String>, AppError> {
|
||||
let rows: Vec<serde_json::Value> = 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<Uuid, AppError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::{
|
||||
materials_protected_routes, materials_public_routes,
|
||||
};
|
||||
@@ -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<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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user