Files
imphnen-backend-service/imphnen-dimentorin/src/materials/application/material_service.rs
T
asepharyana ac8177d87e 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
2026-08-05 17:01:59 +07:00

135 lines
3.5 KiB
Rust

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
}
}