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 articles;
|
||||||
|
pub mod materials;
|
||||||
pub mod mentors;
|
pub mod mentors;
|
||||||
pub mod payments;
|
pub mod payments;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|
||||||
pub use articles::{articles_protected_routes, articles_public_routes};
|
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 mentors::{mentors_protected_routes, mentors_public_routes};
|
||||||
pub use payments::payments_protected_routes;
|
pub use payments::payments_protected_routes;
|
||||||
pub use sessions::{sessions_protected_routes, sessions_public_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 enum_impls;
|
||||||
pub mod enums;
|
pub mod enums;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod materials;
|
||||||
pub mod otp_cache;
|
pub mod otp_cache;
|
||||||
pub mod payments;
|
pub mod payments;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use imphnen_cms::{
|
|||||||
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
|
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
|
||||||
};
|
};
|
||||||
use imphnen_dimentorin::{
|
use imphnen_dimentorin::{
|
||||||
articles_protected_routes, articles_public_routes, mentors_protected_routes,
|
articles_protected_routes, articles_public_routes, materials_protected_routes,
|
||||||
mentors_public_routes, payments_protected_routes, sessions_protected_routes,
|
materials_public_routes, mentors_protected_routes, mentors_public_routes,
|
||||||
sessions_public_routes,
|
payments_protected_routes, sessions_protected_routes, sessions_public_routes,
|
||||||
};
|
};
|
||||||
use imphnen_gacha::gacha_router;
|
use imphnen_gacha::gacha_router;
|
||||||
use imphnen_hackathon::hackathon_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(mentors_public_routes(db.clone(), Arc::clone(&state_arc)))
|
||||||
.merge(sessions_public_routes(db.clone()))
|
.merge(sessions_public_routes(db.clone()))
|
||||||
.merge(articles_public_routes(db.clone()))
|
.merge(articles_public_routes(db.clone()))
|
||||||
|
.merge(materials_public_routes(db.clone()))
|
||||||
.merge(
|
.merge(
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc)))
|
.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(),
|
db.clone(),
|
||||||
Arc::clone(&state_arc),
|
Arc::clone(&state_arc),
|
||||||
))
|
))
|
||||||
|
.merge(materials_protected_routes(
|
||||||
|
db.clone(),
|
||||||
|
Arc::clone(&state_arc),
|
||||||
|
))
|
||||||
.layer(from_fn(auth_middleware)),
|
.layer(from_fn(auth_middleware)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user