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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user