From db44c5a51f7eb9e27bc788921adfb75817ccef9f Mon Sep 17 00:00:00 2001 From: maulanasdqn Date: Fri, 10 Apr 2026 21:42:15 +0700 Subject: [PATCH] feat: add roadmap CRUD module to CMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints under /v1/landing/cms: - GET /roadmap — public, paginated list - GET /roadmap/detail/{id} — public, detail - POST /roadmap/vote/{id} — public, increment votes - POST /roadmap/create — protected (Administrator) - PATCH /roadmap/update/{id} — protected (Administrator) - DELETE /roadmap/delete/{id} — protected (Administrator) Table: roadmap_items (id, title, description, status, votes, is_deleted, created_at, updated_at) Status values: upcoming, in_progress, completed Co-Authored-By: Claude Opus 4.6 (1M context) --- imphnen-cms/src/lib.rs | 2 + imphnen-cms/src/roadmap/application/mod.rs | 3 + .../roadmap/application/roadmap_service.rs | 47 ++++ imphnen-cms/src/roadmap/domain/mod.rs | 7 + imphnen-cms/src/roadmap/domain/repository.rs | 19 ++ imphnen-cms/src/roadmap/domain/roadmap.rs | 14 ++ imphnen-cms/src/roadmap/domain/service.rs | 19 ++ .../src/roadmap/infrastructure/http/dto.rs | 98 +++++++++ .../roadmap/infrastructure/http/handlers.rs | 208 ++++++++++++++++++ .../src/roadmap/infrastructure/http/mod.rs | 5 + .../src/roadmap/infrastructure/http/routes.rs | 36 +++ imphnen-cms/src/roadmap/infrastructure/mod.rs | 2 + .../roadmap/infrastructure/persistence/mod.rs | 3 + .../postgres_roadmap_repository.rs | 171 ++++++++++++++ imphnen-cms/src/roadmap/mod.rs | 5 + imphnen-entities/src/seaorm/common/mod.rs | 1 + .../src/seaorm/common/roadmap_items.rs | 37 ++++ imphnen-gateway/src/docs/openapi.rs | 11 + imphnen-gateway/src/lib.rs | 6 +- 19 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 imphnen-cms/src/roadmap/application/mod.rs create mode 100644 imphnen-cms/src/roadmap/application/roadmap_service.rs create mode 100644 imphnen-cms/src/roadmap/domain/mod.rs create mode 100644 imphnen-cms/src/roadmap/domain/repository.rs create mode 100644 imphnen-cms/src/roadmap/domain/roadmap.rs create mode 100644 imphnen-cms/src/roadmap/domain/service.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/http/dto.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/http/handlers.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/http/mod.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/http/routes.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/mod.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/persistence/mod.rs create mode 100644 imphnen-cms/src/roadmap/infrastructure/persistence/postgres_roadmap_repository.rs create mode 100644 imphnen-cms/src/roadmap/mod.rs create mode 100644 imphnen-entities/src/seaorm/common/roadmap_items.rs diff --git a/imphnen-cms/src/lib.rs b/imphnen-cms/src/lib.rs index 5bd8c58..d46a67a 100644 --- a/imphnen-cms/src/lib.rs +++ b/imphnen-cms/src/lib.rs @@ -1,7 +1,9 @@ pub mod events; +pub mod roadmap; pub mod testimonials; pub mod qr; pub use events::{events_protected_routes, events_public_routes}; +pub use roadmap::{roadmap_protected_routes, roadmap_public_routes}; pub use testimonials::{testimonials_protected_routes, testimonials_public_routes}; pub use qr::qr_router; diff --git a/imphnen-cms/src/roadmap/application/mod.rs b/imphnen-cms/src/roadmap/application/mod.rs new file mode 100644 index 0000000..edfb4f2 --- /dev/null +++ b/imphnen-cms/src/roadmap/application/mod.rs @@ -0,0 +1,3 @@ +pub mod roadmap_service; + +pub use roadmap_service::RoadmapServiceImpl; diff --git a/imphnen-cms/src/roadmap/application/roadmap_service.rs b/imphnen-cms/src/roadmap/application/roadmap_service.rs new file mode 100644 index 0000000..9970263 --- /dev/null +++ b/imphnen-cms/src/roadmap/application/roadmap_service.rs @@ -0,0 +1,47 @@ +use crate::roadmap::domain::{RoadmapEntity, RoadmapRepository, RoadmapService}; +use async_trait::async_trait; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; + +pub struct RoadmapServiceImpl { + repo: Arc, +} + +impl RoadmapServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl RoadmapService for RoadmapServiceImpl { + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } + + async fn get(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } + + async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError> { + self.repo.create(entity).await + } + + async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError> { + self.repo.update(entity).await + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } + + async fn vote(&self, id: Uuid) -> Result<(), AppError> { + self.repo.increment_votes(id).await + } +} diff --git a/imphnen-cms/src/roadmap/domain/mod.rs b/imphnen-cms/src/roadmap/domain/mod.rs new file mode 100644 index 0000000..dcba53a --- /dev/null +++ b/imphnen-cms/src/roadmap/domain/mod.rs @@ -0,0 +1,7 @@ +pub mod roadmap; +pub mod repository; +pub mod service; + +pub use roadmap::RoadmapEntity; +pub use repository::RoadmapRepository; +pub use service::RoadmapService; diff --git a/imphnen-cms/src/roadmap/domain/repository.rs b/imphnen-cms/src/roadmap/domain/repository.rs new file mode 100644 index 0000000..466948f --- /dev/null +++ b/imphnen-cms/src/roadmap/domain/repository.rs @@ -0,0 +1,19 @@ +use super::roadmap::RoadmapEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +#[async_trait] +pub trait RoadmapRepository: Send + Sync { + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError>; + async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn increment_votes(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-cms/src/roadmap/domain/roadmap.rs b/imphnen-cms/src/roadmap/domain/roadmap.rs new file mode 100644 index 0000000..6160834 --- /dev/null +++ b/imphnen-cms/src/roadmap/domain/roadmap.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct RoadmapEntity { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: String, + pub votes: i32, + pub is_deleted: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/imphnen-cms/src/roadmap/domain/service.rs b/imphnen-cms/src/roadmap/domain/service.rs new file mode 100644 index 0000000..736fe5a --- /dev/null +++ b/imphnen-cms/src/roadmap/domain/service.rs @@ -0,0 +1,19 @@ +use super::roadmap::RoadmapEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use uuid::Uuid; + +#[async_trait] +pub trait RoadmapService: Send + Sync { + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: Uuid) -> Result; + async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError>; + async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn vote(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-cms/src/roadmap/infrastructure/http/dto.rs b/imphnen-cms/src/roadmap/infrastructure/http/dto.rs new file mode 100644 index 0000000..df4aeee --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/http/dto.rs @@ -0,0 +1,98 @@ +use crate::roadmap::domain::roadmap::RoadmapEntity; +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RoadmapCreateRequestDto { + pub title: String, + pub description: String, + #[schema(example = "upcoming")] + pub status: String, +} + +impl ZodValidate for RoadmapCreateRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } +} + +impl From for RoadmapEntity { + fn from(dto: RoadmapCreateRequestDto) -> Self { + RoadmapEntity { + id: Uuid::new_v4(), + title: dto.title, + description: dto.description, + status: dto.status, + votes: 0, + is_deleted: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RoadmapUpdateRequestDto { + pub title: String, + pub description: String, + #[schema(example = "upcoming")] + pub status: String, +} + +impl ZodValidate for RoadmapUpdateRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RoadmapListItemDto { + pub id: String, + pub title: String, + pub description: String, + pub status: String, + pub votes: i32, + pub is_deleted: bool, + pub created_at: String, +} + +impl From for RoadmapListItemDto { + fn from(e: RoadmapEntity) -> Self { + RoadmapListItemDto { + id: e.id.to_string(), + title: e.title, + description: e.description, + status: e.status, + votes: e.votes, + is_deleted: e.is_deleted, + created_at: e.created_at.to_rfc3339(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RoadmapDetailItemDto { + pub id: String, + pub title: String, + pub description: String, + pub status: String, + pub votes: i32, + pub created_at: String, + pub updated_at: String, +} + +impl From for RoadmapDetailItemDto { + fn from(e: RoadmapEntity) -> Self { + RoadmapDetailItemDto { + id: e.id.to_string(), + title: e.title, + description: e.description, + status: e.status, + votes: e.votes, + created_at: e.created_at.to_rfc3339(), + updated_at: e.updated_at.to_rfc3339(), + } + } +} diff --git a/imphnen-cms/src/roadmap/infrastructure/http/handlers.rs b/imphnen-cms/src/roadmap/infrastructure/http/handlers.rs new file mode 100644 index 0000000..ae575fd --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/http/handlers.rs @@ -0,0 +1,208 @@ +use super::dto::{ + RoadmapCreateRequestDto, RoadmapDetailItemDto, RoadmapListItemDto, + RoadmapUpdateRequestDto, +}; +use crate::roadmap::domain::RoadmapService; +use axum::{ + Extension, + extract::Path, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[utoipa::path( + get, + path = "/v1/landing/cms/roadmap", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ), + responses( + (status = 200, description = "[PUBLIC] Get roadmap list") + ), + tag = "Roadmap" +)] +pub async fn get_roadmap_list( + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, +) -> Response { + match service.list(params).await { + Ok(result) => { + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(RoadmapListItemDto::from) + .collect::>(), + meta: result.meta, + }; + ApiPaginated(mapped).into_response() + } + Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string()) + .into_response(), + } +} + +#[utoipa::path( + get, + path = "/v1/landing/cms/roadmap/detail/{id}", + params( + ("id" = String, Path, description = "Roadmap item ID") + ), + responses( + (status = 200, description = "[PUBLIC] Get roadmap item by ID", body = ResponseSuccessDto) + ), + tag = "Roadmap" +)] +pub async fn get_roadmap_by_id( + Extension(service): Extension>, + Path(id): Path, +) -> Response { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(e) => { + return ApiMessage::new( + axum::http::StatusCode::BAD_REQUEST, + format!("Invalid UUID: {e}"), + ) + .into_response(); + } + }; + match service.get(uuid).await { + Ok(item) => ApiSuccess(RoadmapDetailItemDto::from(item)).into_response(), + Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string()) + .into_response(), + } +} + +#[utoipa::path( + post, + security(("Bearer" = [])), + path = "/v1/landing/cms/roadmap/create", + request_body = RoadmapCreateRequestDto, + responses( + (status = 201, description = "[ADMIN] Create new roadmap item") + ), + tag = "Roadmap" +)] +pub async fn post_create_roadmap( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let entity = payload.into(); + service.create(entity).await?; + Ok(ApiMessage::created("Roadmap item created")) + }) +} + +#[utoipa::path( + patch, + security(("Bearer" = [])), + path = "/v1/landing/cms/roadmap/update/{id}", + params( + ("id" = String, Path, description = "Roadmap item ID") + ), + request_body = RoadmapUpdateRequestDto, + responses( + (status = 200, description = "[ADMIN] Update roadmap item") + ), + tag = "Roadmap" +)] +pub async fn patch_update_roadmap( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(payload): ValidatedJson, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let existing = service.get(uuid).await?; + let entity = crate::roadmap::domain::RoadmapEntity { + id: existing.id, + title: payload.title, + description: payload.description, + status: payload.status, + votes: existing.votes, + is_deleted: existing.is_deleted, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + service.update(entity).await?; + Ok(ApiMessage::ok("Roadmap item updated")) + }) +} + +#[utoipa::path( + delete, + security(("Bearer" = [])), + path = "/v1/landing/cms/roadmap/delete/{id}", + params( + ("id" = String, Path, description = "Roadmap item ID") + ), + responses( + (status = 200, description = "[ADMIN] Soft delete roadmap item") + ), + tag = "Roadmap" +)] +pub async fn delete_roadmap( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + service.delete(uuid).await?; + Ok(ApiMessage::ok("Roadmap item deleted")) + }) +} + +#[utoipa::path( + post, + path = "/v1/landing/cms/roadmap/vote/{id}", + params( + ("id" = String, Path, description = "Roadmap item ID") + ), + responses( + (status = 200, description = "[PUBLIC] Vote for a roadmap item") + ), + tag = "Roadmap" +)] +pub async fn post_vote_roadmap( + Extension(service): Extension>, + Path(id): Path, +) -> Response { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(e) => { + return ApiMessage::new( + axum::http::StatusCode::BAD_REQUEST, + format!("Invalid UUID: {e}"), + ) + .into_response(); + } + }; + match service.vote(uuid).await { + Ok(()) => ApiMessage::ok("Vote recorded").into_response(), + Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string()) + .into_response(), + } +} diff --git a/imphnen-cms/src/roadmap/infrastructure/http/mod.rs b/imphnen-cms/src/roadmap/infrastructure/http/mod.rs new file mode 100644 index 0000000..360b355 --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/http/mod.rs @@ -0,0 +1,5 @@ +pub mod dto; +pub mod handlers; +pub mod routes; + +pub use routes::{roadmap_protected_routes, roadmap_public_routes}; diff --git a/imphnen-cms/src/roadmap/infrastructure/http/routes.rs b/imphnen-cms/src/roadmap/infrastructure/http/routes.rs new file mode 100644 index 0000000..f40f61c --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/http/routes.rs @@ -0,0 +1,36 @@ +use super::handlers::{ + delete_roadmap, get_roadmap_by_id, get_roadmap_list, patch_update_roadmap, + post_create_roadmap, post_vote_roadmap, +}; +use crate::roadmap::application::RoadmapServiceImpl; +use crate::roadmap::domain::RoadmapService; +use crate::roadmap::infrastructure::persistence::PostgresRoadmapRepository; +use axum::{ + Extension, Router, + routing::{delete, get, patch, post}, +}; +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +fn build_service(db: DatabaseConnection) -> Arc { + let repo = Arc::new(PostgresRoadmapRepository::new(db)); + Arc::new(RoadmapServiceImpl::new(repo)) +} + +pub fn roadmap_public_routes(db: DatabaseConnection) -> Router { + let service = build_service(db); + Router::new() + .route("/roadmap", get(get_roadmap_list)) + .route("/roadmap/detail/{id}", get(get_roadmap_by_id)) + .route("/roadmap/vote/{id}", post(post_vote_roadmap)) + .layer(Extension(service)) +} + +pub fn roadmap_protected_routes(db: DatabaseConnection) -> Router { + let service = build_service(db); + Router::new() + .route("/roadmap/create", post(post_create_roadmap)) + .route("/roadmap/update/{id}", patch(patch_update_roadmap)) + .route("/roadmap/delete/{id}", delete(delete_roadmap)) + .layer(Extension(service)) +} diff --git a/imphnen-cms/src/roadmap/infrastructure/mod.rs b/imphnen-cms/src/roadmap/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-cms/src/roadmap/infrastructure/persistence/mod.rs b/imphnen-cms/src/roadmap/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..9dc8021 --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/persistence/mod.rs @@ -0,0 +1,3 @@ +pub mod postgres_roadmap_repository; + +pub use postgres_roadmap_repository::PostgresRoadmapRepository; diff --git a/imphnen-cms/src/roadmap/infrastructure/persistence/postgres_roadmap_repository.rs b/imphnen-cms/src/roadmap/infrastructure/persistence/postgres_roadmap_repository.rs new file mode 100644 index 0000000..45dbae5 --- /dev/null +++ b/imphnen-cms/src/roadmap/infrastructure/persistence/postgres_roadmap_repository.rs @@ -0,0 +1,171 @@ +use crate::roadmap::domain::{roadmap::RoadmapEntity, repository::RoadmapRepository}; +use async_trait::async_trait; +use imphnen_entities::seaorm::common::roadmap_items::{ + ActiveModel as RoadmapActiveModel, Column as RoadmapColumn, Entity as RoadmapEntity_, + Model as RoadmapModel, +}; +use imphnen_utils::AppError; +use paginator_rs::{PaginationParams, SortDirection}; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; +use uuid::Uuid; + +fn to_entity(model: RoadmapModel) -> RoadmapEntity { + RoadmapEntity { + id: model.id, + title: model.title, + description: model.description, + status: model.status, + votes: model.votes, + is_deleted: model.is_deleted, + created_at: model.created_at, + updated_at: model.updated_at, + } +} + +pub struct PostgresRoadmapRepository { + db: Arc, +} + +impl PostgresRoadmapRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } +} + +#[async_trait] +impl RoadmapRepository for PostgresRoadmapRepository { + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); + + let mut query = RoadmapEntity_::find().filter(RoadmapColumn::IsDeleted.eq(false)); + + if let Some(ref search) = params.search { + query = query.filter(RoadmapColumn::Title.contains(&search.query)); + } + + query = match params.sort_by.as_deref() { + Some("title") => match params.sort_direction { + Some(SortDirection::Desc) => query.order_by(RoadmapColumn::Title, Order::Desc), + _ => query.order_by(RoadmapColumn::Title, Order::Asc), + }, + Some("votes") => match params.sort_direction { + Some(SortDirection::Asc) => query.order_by(RoadmapColumn::Votes, Order::Asc), + _ => query.order_by(RoadmapColumn::Votes, Order::Desc), + }, + _ => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by(RoadmapColumn::CreatedAt, Order::Asc) + } + _ => query.order_by(RoadmapColumn::CreatedAt, Order::Desc), + }, + }; + + let paginator = query.paginate(self.db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let items = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let data = items.into_iter().map(to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) + } + + async fn find_by_id(&self, id: Uuid) -> Result { + let item = RoadmapEntity_::find_by_id(id) + .filter(RoadmapColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))?; + + Ok(to_entity(item)) + } + + async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError> { + let active_model = RoadmapActiveModel { + id: ActiveValue::Set(entity.id), + title: ActiveValue::Set(entity.title), + description: ActiveValue::Set(entity.description), + status: ActiveValue::Set(entity.status), + votes: ActiveValue::Set(0), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + }; + + RoadmapEntity_::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(()) + } + + async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError> { + let mut active_model: RoadmapActiveModel = RoadmapEntity_::find_by_id(entity.id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))? + .into(); + + active_model.title = ActiveValue::Set(entity.title); + active_model.description = ActiveValue::Set(entity.description); + active_model.status = ActiveValue::Set(entity.status); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let mut active_model: RoadmapActiveModel = RoadmapEntity_::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))? + .into(); + + active_model.is_deleted = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn increment_votes(&self, id: Uuid) -> Result<(), AppError> { + let item = RoadmapEntity_::find_by_id(id) + .filter(RoadmapColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))?; + + let new_votes = item.votes + 1; + let mut active_model: RoadmapActiveModel = item.into(); + active_model.votes = ActiveValue::Set(new_votes); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/imphnen-cms/src/roadmap/mod.rs b/imphnen-cms/src/roadmap/mod.rs new file mode 100644 index 0000000..edc8bc1 --- /dev/null +++ b/imphnen-cms/src/roadmap/mod.rs @@ -0,0 +1,5 @@ +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use infrastructure::http::{roadmap_protected_routes, roadmap_public_routes}; diff --git a/imphnen-entities/src/seaorm/common/mod.rs b/imphnen-entities/src/seaorm/common/mod.rs index 6ffc802..7f9eed6 100644 --- a/imphnen-entities/src/seaorm/common/mod.rs +++ b/imphnen-entities/src/seaorm/common/mod.rs @@ -3,6 +3,7 @@ pub mod enum_impls; pub mod enums; pub mod events; pub mod rate_limit; +pub mod roadmap_items; pub mod testimonials; pub mod types; pub mod utils; diff --git a/imphnen-entities/src/seaorm/common/roadmap_items.rs b/imphnen-entities/src/seaorm/common/roadmap_items.rs new file mode 100644 index 0000000..98be835 --- /dev/null +++ b/imphnen-entities/src/seaorm/common/roadmap_items.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "roadmap_items")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null)] + pub title: String, + + #[sea_orm(not_null)] + pub description: String, + + #[sea_orm(not_null, default = "'upcoming'")] + pub status: String, + + #[sea_orm(not_null, default = "0")] + pub votes: i32, + + #[sea_orm(default = "false")] + pub is_deleted: bool, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-gateway/src/docs/openapi.rs b/imphnen-gateway/src/docs/openapi.rs index f7f0167..de64331 100644 --- a/imphnen-gateway/src/docs/openapi.rs +++ b/imphnen-gateway/src/docs/openapi.rs @@ -3,6 +3,11 @@ use imphnen_cms::events::infrastructure::http::dto::{ EventsDetailItemDto, EventsListItemDto, }; use imphnen_cms::events::infrastructure::http::handlers as events_controller; +use imphnen_cms::roadmap::infrastructure::http::dto::{ + RoadmapCreateRequestDto, RoadmapDetailItemDto, RoadmapListItemDto, + RoadmapUpdateRequestDto, +}; +use imphnen_cms::roadmap::infrastructure::http::handlers as roadmap_controller; use imphnen_cms::qr::campaigns::infrastructure::http::dto::CreateCampaignRequest; use imphnen_cms::qr::campaigns::infrastructure::http::handlers as qr_campaigns_controller; use imphnen_cms::qr::users::infrastructure::http::dto::{ @@ -137,6 +142,9 @@ use utoipa::OpenApi; events_controller::get_event_list, events_controller::get_event_by_id, events_controller::post_create_event, events_controller::patch_update_event, events_controller::delete_event, + roadmap_controller::get_roadmap_list, roadmap_controller::get_roadmap_by_id, + roadmap_controller::post_create_roadmap, roadmap_controller::patch_update_roadmap, + roadmap_controller::delete_roadmap, roadmap_controller::post_vote_roadmap, testimonials_controller::get_testimonial_list, testimonials_controller::get_testimonial_by_id, testimonials_controller::post_create_testimonial, testimonials_controller::patch_update_testimonial, testimonials_controller::delete_testimonial, @@ -214,6 +222,8 @@ use utoipa::OpenApi; ResponseListSuccessDto>, ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto, + RoadmapCreateRequestDto, RoadmapUpdateRequestDto, RoadmapListItemDto, RoadmapDetailItemDto, + ResponseListSuccessDto>, ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto, TestimonialsCreateRequestDto, TestimonialsUpdateRequestDto, MentorUserRegisterRequestDto, MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto, @@ -255,6 +265,7 @@ use utoipa::OpenApi; (name = "Roles", description = "IAM — Role management (/v1/iam/roles)"), (name = "Permissions", description = "IAM — Permission management (/v1/iam/permissions)"), (name = "Events", description = "Landing CMS — Event management (/v1/landing/cms/events)"), + (name = "Roadmap", description = "Landing CMS — Roadmap management (/v1/landing/cms/roadmap)"), (name = "Testimonials", description = "Landing CMS — Testimonial management (/v1/landing/cms/testimonials)"), (name = "Mentors", description = "Dimentorin — Mentor management (/v1/dimentorin/mentors)"), (name = "Mentors - Admin", description = "Dimentorin — Mentor admin endpoints (/v1/dimentorin/mentors)"), diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 2d2f71b..a959757 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -2,8 +2,8 @@ use axum::{ Extension, Router, middleware::from_fn, response::Redirect, routing::get, }; use imphnen_cms::{ - events_protected_routes, events_public_routes, qr_router, testimonials_protected_routes, - testimonials_public_routes, + events_protected_routes, events_public_routes, qr_router, roadmap_protected_routes, + roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes, }; use imphnen_dimentorin::{ mentors_protected_routes, mentors_public_routes, sessions_protected_routes, @@ -62,10 +62,12 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { let cms_routes = Router::new() .merge(testimonials_public_routes(db.clone())) .merge(events_public_routes(db.clone())) + .merge(roadmap_public_routes(db.clone())) .merge( Router::new() .merge(events_protected_routes(db.clone())) .merge(testimonials_protected_routes(db.clone())) + .merge(roadmap_protected_routes(db.clone())) .layer(from_fn(auth_middleware)), );