Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5c560347 | ||
|
|
164c1860da | ||
|
|
ac8177d87e | ||
|
|
b4a9972ad7 | ||
|
|
9af1c2d163 | ||
|
|
495e043088 | ||
|
|
390f46b0e7 | ||
|
|
aa0b659b48 | ||
|
|
9214b32139 | ||
|
|
2ea6cd3d17 | ||
|
|
6d5af29de8 | ||
|
|
444c98074f | ||
|
|
c6ed5c5c19 | ||
|
|
3692b81324 | ||
|
|
9b5efeff87 | ||
|
|
1d34d29b0b | ||
|
|
7d1078f52a | ||
|
|
67d3f2fced | ||
|
|
b68e362a02 | ||
|
|
db44c5a51f | ||
|
|
6570bbf752 | ||
|
|
5667a0d608 | ||
|
|
a4bbc73c7e | ||
|
|
729335014f | ||
|
|
852e9652ee | ||
|
|
f225ee8969 | ||
|
|
4ee00f1fe5 | ||
|
|
d014a94ea4 |
Generated
+2
@@ -1810,6 +1810,7 @@ dependencies = [
|
||||
"paginator-utils",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1970,6 +1971,7 @@ dependencies = [
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"strum_macros",
|
||||
"tokio",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Dimentorin — Catatan Temuan Infra (Dev Audit, 2026-08-04)
|
||||
|
||||
Dokumen ini mencatat temuan yang membutuhkan perhatian tim sebelum produksi.
|
||||
Semua diuji lokal (Postgres `dimentorin`, backend :4099).
|
||||
|
||||
## 1. SMTP email verification broken (blocker aktivasi user baru) — ✅ FIXED (2026-08-05, Google App Password)
|
||||
|
||||
- Endpoint `POST /v1/iam/auth/send-otp` gagal: `SMTP transport error (535): Username and Password not accepted` — kredensial `.env` (`SMTP_EMAIL=dev@example.com`, `SMTP_PASSWORD=dev`) ditolak Google SMTP.
|
||||
- `POST /v1/iam/auth/verify-email` tetap butuh OTP untuk memanggil, tapi lihat poin 2.
|
||||
- **Dampak**: mentee/mentor baru tak bisa menerima OTP lewat email → tak bisa aktivasi → tak bisa login, kecuali via verify-email langsung.
|
||||
- **Diperlukan**: SMTP credential institution yang valid (Gmail App Password atau SMTP relay), sebaiknya dari BWS secret management, bukan hardcode.
|
||||
|
||||
## 2. ✅ FIXED — verify-email TIDAK memverifikasi OTP (security issue)
|
||||
|
||||
**Status: FIXED di branch feat/dimentorin-postgres (2026-08-04).**
|
||||
|
||||
`imphnen-iam/src/auth/application/mod.rs` → `verify_email()`:
|
||||
|
||||
- OTP sekarang dipersist ke tabel **`app_otp_cache`** (entity baru `imphnen-entities/src/seaorm/common/otp_cache.rs`, resource `app_otp_cache` sudah direncanakan di `ResourceEnum::OtpCache`).
|
||||
- `register()` & `resend_otp()` menyimpan `otp_hash` + `expires_at` setelah email terkirim (kalau email gagal, tidak ada OTP yatim / OTP lama tidak di-overwrite).
|
||||
- `verify_email()` memanggil `OtpManager::validate_otp_hash(stored_hash, expires_at, payload.otp)` sebelum set `is_active`. `validate_otp_hash` ditambahkan ke `OtpManager` (pure hash+expiry tanpa perlu plaintext code).
|
||||
- OTP **single-use**: di-delete setelah verifikasi sukses. Reuse / OTP tanpa cache / OTP expired semua ditolak (400).
|
||||
- Uji lokal (Postgres, :4099): OTP salah → 400 "Invalid or expired OTP", user tetap inactive; OTP benar → 200 "Email verified successfully", user aktif, OTP dihapus; verify ulang → 400 "User already active"; email tanpa OTP → 400 "No OTP issued".
|
||||
- Tabel dibuat via SQL manual (`create_schema.rs` ditambah `otp_cache` untuk bootstrap penuh).
|
||||
|
||||
## 3. (OK, sudah benar) Register mentor + booking
|
||||
|
||||
- `POST /v1/dimentorin/mentors/create` → 200, user + mentor profile dibuat, status `pending`, user tak tampil di list public sampai verified.
|
||||
- `POST /v1/dimentorin/mentors/{id}/sessions/create` → 200, session pending.
|
||||
- Kedua endpoint fungsional setelah fix UUID (commit 9b5efef).
|
||||
|
||||
## Rekomendasi
|
||||
|
||||
Tangani #1 dan #2 sebelum go-live. #2 adalah kelas bug "OTP di-generate tapi tak dipakai" — sisi verifikasi email saat ini tidak lebih dari form "set is_active=true tanpa autentikasi".
|
||||
|
||||
## 4. ✅ DONE — Payment flow (alur bisnis menjual)
|
||||
|
||||
**Status: DONE di feat/dimentorin-postgres (2026-08-05).**
|
||||
|
||||
- Tabel `app_payments`: amount (dari `mentoring_rate` mentor) + service_fee 2000 + total; method `va`/`qris`/`manual`; provider `manual` default (swap Midtrans/Xendit nanti — cukup ganti nilai `provider` dan implementasi `generate_external_ref`/notifikasi webhook).
|
||||
- Routes protected: `POST /payments/sessions/{id}/create`, `GET /payments/me`, `GET /payments/{id}`, `POST /payments/{id}/confirm`.
|
||||
- Guard: mentee hanya bisa akses payment miliknya (403 kalau bukan); confirm hanya Admin / Admin Pembayaran.
|
||||
- `confirm_payment` otomatis mengubah session terkait `pending` -> `confirmed` (loop bisnis lengkap: book -> bayar -> sesi terkonfirmasi -> feedback).
|
||||
- FE: PaymentStep pilih VA/QRIS, rate real dari `mentoring_rate`; modal appointment: book -> create payment -> tampil VA/QR dengan `external_ref` + total + expiry -> success. Service lib: `postCreatePayment/getMyPayments/getPaymentById/postConfirmPayment`.
|
||||
- E2E verified (lokal :4099): create VA dan QRIS, confirm 200, re-confirm 409, non-admin 403, akses payment orang lain 403, session auto-confirmed.
|
||||
- TODO produksi: isi kredensial payment gateway (Midtrans/Xendit) + webhook callback; SMTP masih blocker (#1).
|
||||
|
||||
## 5. Payment lifecycle complete: auto-paid refresh + dashboards (2026-08-05)
|
||||
- POST /payments/{id}/refresh: polls Midtrans v2/{order_id}/status; settlement/capture -> payment paid + session confirmed automatically (e2e verified via sandbox simulator: VA paid -> refresh -> paid + confirmed)
|
||||
- GET /payments/session/{id}: payments for one session, accessible by that session's mentee or mentor (powers both dashboards)
|
||||
- Session mentor may confirm their own payments (previously admins only) — verified e2e
|
||||
- FE: /mentoring/my-sessions (mentee) + /mentoring/mentor-dashboard (mentor), both with live payment status and refresh/confirm actions; QRIS step renders real QR from qr_string
|
||||
|
||||
|
||||
## 6. Materi + AI Agent RAG (2026-08-05, commit 164c186 BE / 7294040 FE)
|
||||
|
||||
Fitur yang "harusnya ada" menurut user (Figma hanya berisi Design System, halaman Materi/AI Agent tidak ada di file) — dibangun dari pemahaman alur bisnis mentoring.
|
||||
|
||||
**Backend — modul materials** (`imphnen-dimentorin/src/materials/`):
|
||||
- `app_materials` table: mentor_id, title, slug, category, description, content, cover_url, is_published
|
||||
- Pola articles: domain/repository/service + postgres repo + DTO ZodValidate
|
||||
- Routes: GET /materials (public, published), /materials/{id|slug|categories}, POST/PUT/DELETE (auth, author-only)
|
||||
- ENV baru: AI_LLM_BASE_URL/API_KEY/MODEL, AI_EMBEDDING_MODEL, QDRANT_URL
|
||||
|
||||
**Backend — AI agent RAG** (`imphnen-dimentorin/src/ai_agent/`):
|
||||
- Chunking materi (700 chars, overlap 80) -> embed via 9router `gemini/gemini-embedding-001` (3072 dim!)
|
||||
- Qdrant collection `dimentorin_materi` (3072d cosine, point id = u64 dari uuid xor index — Qdrant TOLAK string non-UUID)
|
||||
- Chat: embed question -> search top-4 -> LLM (`text` -> gemini-3.1-flash-lite) jawab dengan konteks + sources
|
||||
- Routes: POST /ai/chat, POST /ai/materials/{id}/index, POST /ai/reindex
|
||||
- 9router chat SELALU SSE-streaming walau tanpa stream:true — parser harus agregate `data:` lines
|
||||
|
||||
**Verified e2e**: reindex 3 chunks; chat 'ownership' -> source materi Rust 0.88; chat 'endpoint axum' -> Axum 0.82; browser: list materi, detail, chatbox jawab + sources 84/64/61%.
|
||||
|
||||
**Pitfall**: embedding Gemini = 3072 dim (bukan 768); Qdrant point id harus u64/UUID; model embedding yang berfungsi di 9router = `gemini/gemini-embedding-001` (llama-nemotron -> 'No credentials for provider: openai').
|
||||
@@ -29,6 +29,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?;
|
||||
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity)
|
||||
.await?;
|
||||
drop_and_create_table(&db, builder, "app_articles", common::articles::Entity)
|
||||
.await?;
|
||||
|
||||
drop_and_create_table(&db, builder, "events", common::events::Entity).await?;
|
||||
drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity)
|
||||
@@ -37,6 +39,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity)
|
||||
.await?;
|
||||
drop_and_create_table(&db, builder, "otp_cache", common::otp_cache::Entity).await?;
|
||||
drop_and_create_table(&db, builder, "payments", common::payments::Entity)
|
||||
.await?;
|
||||
|
||||
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity)
|
||||
.await?;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#![allow(clippy::all)]
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::seaorm::common::articles::{
|
||||
ActiveModel as ArticleActiveModel, Entity as ArticlesEntity,
|
||||
};
|
||||
use imphnen_libs::postgres::PostgresConfig;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, Database};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = PostgresConfig::from_env()?;
|
||||
let db = Database::connect(&config.database_url).await?;
|
||||
|
||||
let articles = vec![
|
||||
("Cara Memulai Karier di UI/UX Design", "cara-memulai-karier-ui-ux-design", "UI/UX & Design", "Panduan lengkap untuk masuk ke dunia UI/UX design, dari skill yang dibutuhkan hingga portofolio.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. UI/UX design adalah bidang yang menjanjikan. Artikel ini membahas langkah awal memulai karier sebagai UI/UX designer, tools yang wajib dikuasai seperti Figma, serta cara membangun portofolio yang menarik bagi perekrut."),
|
||||
("Belajar Rust: Panduan Pemula 2026", "belajar-rust-panduan-pemula-2026", "Software/Web Dev", "Bahasa pemrograman Rust sedang naik daun. Pelajari konsep ownership dan borrow checker.", "Rust adalah bahasa pemrograman yang fokus pada performa dan keamanan memori. Dalam artikel ini kita membahas ownership, borrowing, dan cara setup environment Rust di Linux dan Windows, serta contoh project sederhana."),
|
||||
("Mengenal Machine Learning untuk Data Analyst", "mengenal-machine-learning-data-analyst", "Data & AI", "Peran Data Analyst berevolusi dengan hadirnya machine learning. Simak panduannya.", "Machine learning membuka peluang besar bagi data analyst. Artikel ini menjelaskan perbedaan data analysis dan machine learning, serta roadmap belajar dari Python, pandas, sampai scikit-learn."),
|
||||
];
|
||||
|
||||
for (title, slug, category, excerpt, content) in articles {
|
||||
let am = ArticleActiveModel {
|
||||
id: ActiveValue::Set(Uuid::new_v4()),
|
||||
title: ActiveValue::Set(title.to_string()),
|
||||
slug: ActiveValue::Set(slug.to_string()),
|
||||
category: ActiveValue::Set(category.to_string()),
|
||||
excerpt: ActiveValue::Set(excerpt.to_string()),
|
||||
content: ActiveValue::Set(content.to_string()),
|
||||
cover_url: ActiveValue::Set(None),
|
||||
author_name: ActiveValue::Set(Some("IMPHNEN Editorial".to_string())),
|
||||
is_published: ActiveValue::Set(true),
|
||||
created_at: ActiveValue::Set(Utc::now()),
|
||||
updated_at: ActiveValue::Set(Utc::now()),
|
||||
};
|
||||
am.insert(&db).await?;
|
||||
println!("✅ Inserted article: {}", slug);
|
||||
}
|
||||
println!("🟢 All articles seeded");
|
||||
Ok(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -20,12 +20,25 @@ use crate::qr::{
|
||||
path = "/v1/qr/campaigns",
|
||||
request_body = CreateCampaignRequest,
|
||||
responses(
|
||||
(status = 201, description = "Create a QR campaign"),
|
||||
(status = 201, description = "Create a QR campaign",
|
||||
example = json!({
|
||||
"data": {
|
||||
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
|
||||
"name": "Imphnen Hackathon 2025",
|
||||
"url": "https://imphnen.dev/register",
|
||||
"is_active": false,
|
||||
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"expires_at": "2025-12-31T23:59:59Z",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Campaigns",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn create_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
@@ -47,12 +60,27 @@ pub async fn create_campaign_handler(
|
||||
get,
|
||||
path = "/v1/qr/campaigns",
|
||||
responses(
|
||||
(status = 200, description = "Admin: list all QR campaigns"),
|
||||
(status = 200, description = "Admin: list all QR campaigns",
|
||||
example = json!({
|
||||
"data": [
|
||||
{
|
||||
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
|
||||
"name": "Imphnen Hackathon 2025",
|
||||
"url": "https://imphnen.dev/register",
|
||||
"is_active": true,
|
||||
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"expires_at": "2025-12-31T23:59:59Z",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-05T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Campaigns",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_campaigns_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
@@ -72,12 +100,25 @@ pub async fn list_campaigns_handler(
|
||||
path = "/v1/qr/campaigns/{id}/activate",
|
||||
params(("id" = Uuid, Path, description = "Campaign ID")),
|
||||
responses(
|
||||
(status = 200, description = "Admin: activate a campaign"),
|
||||
(status = 200, description = "Admin: activate a campaign (deactivates all others)",
|
||||
example = json!({
|
||||
"data": {
|
||||
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
|
||||
"name": "Imphnen Hackathon 2025",
|
||||
"url": "https://imphnen.dev/register",
|
||||
"is_active": true,
|
||||
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"expires_at": "2025-12-31T23:59:59Z",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-05T10:00:00Z"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Campaigns",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn activate_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
@@ -98,12 +139,13 @@ pub async fn activate_campaign_handler(
|
||||
path = "/v1/qr/campaigns/{id}",
|
||||
params(("id" = Uuid, Path, description = "Campaign ID")),
|
||||
responses(
|
||||
(status = 200, description = "Admin: delete a campaign"),
|
||||
(status = 200, description = "Admin: delete a campaign",
|
||||
example = json!({"message": "Campaign deleted successfully", "version": "0.3.0"})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Campaigns",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn delete_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
@@ -126,11 +168,13 @@ pub async fn delete_campaign_handler(
|
||||
post,
|
||||
path = "/v1/qr/campaigns/process-image",
|
||||
responses(
|
||||
(status = 200, description = "Process QR code image (multipart/form-data with 'file' field)"),
|
||||
(status = 200, description = "Process QR code image — send multipart/form-data with field 'file'. Returns PNG image bytes.",
|
||||
content_type = "image/png"),
|
||||
(status = 400, description = "No file provided or invalid image"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
tag = "QR - Campaigns",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn process_image_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
|
||||
@@ -36,6 +36,6 @@ pub fn qr_campaigns_routes(pool: Arc<PgPool>) -> Router {
|
||||
.route("/campaigns/{id}", delete(delete_campaign_handler))
|
||||
.route("/campaigns/process-image", post(process_image_handler))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension(pool))
|
||||
.layer(from_fn(qr_auth_middleware))
|
||||
.layer(Extension(pool))
|
||||
}
|
||||
|
||||
@@ -19,11 +19,23 @@ use crate::qr::{
|
||||
get,
|
||||
path = "/v1/qr/users/me",
|
||||
responses(
|
||||
(status = 200, description = "Get my QR user profile"),
|
||||
(status = 200, description = "Get my QR user profile",
|
||||
example = json!({
|
||||
"data": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"email": "user@example.com",
|
||||
"name": "Budi Santoso",
|
||||
"role": "user",
|
||||
"provider": "google",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
tag = "QR - Users",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_me_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
@@ -38,11 +50,23 @@ pub async fn get_me_handler(
|
||||
path = "/v1/qr/users/me",
|
||||
request_body = UpdateProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Update my QR user profile"),
|
||||
(status = 200, description = "Update my QR user profile",
|
||||
example = json!({
|
||||
"data": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"email": "updated@example.com",
|
||||
"name": "Budi Santoso Updated",
|
||||
"role": "user",
|
||||
"provider": "google",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-15T00:00:00Z"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
tag = "QR - Users",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_me_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
@@ -61,12 +85,35 @@ pub async fn update_me_handler(
|
||||
get,
|
||||
path = "/v1/qr/users",
|
||||
responses(
|
||||
(status = 200, description = "Admin: list all QR users"),
|
||||
(status = 200, description = "Admin: list all QR users",
|
||||
example = json!({
|
||||
"data": [
|
||||
{
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"email": "user@example.com",
|
||||
"name": "Budi Santoso",
|
||||
"role": "user",
|
||||
"provider": "google",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "4gb96g75-6828-5673-c4gd-3d074g77bgb7",
|
||||
"email": "admin@example.com",
|
||||
"name": "Admin User",
|
||||
"role": "admin",
|
||||
"provider": "google",
|
||||
"created_at": "2024-12-01T00:00:00Z",
|
||||
"updated_at": "2024-12-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Users",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_users_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
@@ -87,12 +134,24 @@ pub async fn list_users_handler(
|
||||
params(("id" = Uuid, Path, description = "User ID")),
|
||||
request_body = UpdateRoleRequest,
|
||||
responses(
|
||||
(status = 200, description = "Admin: update user role"),
|
||||
(status = 200, description = "Admin: update user role",
|
||||
example = json!({
|
||||
"data": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"email": "user@example.com",
|
||||
"name": "Budi Santoso",
|
||||
"role": "admin",
|
||||
"provider": "google",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-20T00:00:00Z"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Users",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_role_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
@@ -114,12 +173,13 @@ pub async fn update_role_handler(
|
||||
path = "/v1/qr/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "User ID")),
|
||||
responses(
|
||||
(status = 200, description = "Admin: delete QR user"),
|
||||
(status = 200, description = "Admin: delete QR user",
|
||||
example = json!({"message": "User deleted successfully", "version": "0.3.0"})),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only")
|
||||
),
|
||||
tag = "QR - Users",
|
||||
security(("bearer_auth" = []))
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn delete_user_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
|
||||
@@ -32,6 +32,6 @@ pub fn qr_users_routes(pool: Arc<PgPool>) -> Router {
|
||||
.route("/users/{id}/role", put(update_role_handler))
|
||||
.route("/users/{id}", delete(delete_user_handler))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension(pool))
|
||||
.layer(from_fn(qr_auth_middleware))
|
||||
.layer(Extension(pool))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod roadmap_service;
|
||||
|
||||
pub use roadmap_service::RoadmapServiceImpl;
|
||||
@@ -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<dyn RoadmapRepository>,
|
||||
}
|
||||
|
||||
impl RoadmapServiceImpl {
|
||||
pub fn new(repo: Arc<dyn RoadmapRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RoadmapService for RoadmapServiceImpl {
|
||||
async fn list(
|
||||
&self,
|
||||
params: PaginationParams,
|
||||
) -> Result<PaginatorResponse<RoadmapEntity>, AppError> {
|
||||
self.repo.find_all(params).await
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<RoadmapEntity, AppError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<PaginatorResponse<RoadmapEntity>, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<RoadmapEntity, AppError>;
|
||||
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>;
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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<PaginatorResponse<RoadmapEntity>, AppError>;
|
||||
async fn get(&self, id: Uuid) -> Result<RoadmapEntity, AppError>;
|
||||
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>;
|
||||
}
|
||||
@@ -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<Self, String> {
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RoadmapCreateRequestDto> 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<Self, String> {
|
||||
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<RoadmapEntity> 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<RoadmapEntity> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<i64>, Query, description = "Page number"),
|
||||
("per_page" = Option<i64>, Query, description = "Items per page"),
|
||||
("search" = Option<String>, Query, description = "Search keyword"),
|
||||
("sort_by" = Option<String>, Query, description = "Sort by field"),
|
||||
("order" = Option<String>, 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<Arc<dyn RoadmapService>>,
|
||||
PaginationQuery(params): PaginationQuery,
|
||||
) -> Response {
|
||||
match service.list(params).await {
|
||||
Ok(result) => {
|
||||
let mapped = PaginatorResponse {
|
||||
data: result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(RoadmapListItemDto::from)
|
||||
.collect::<Vec<_>>(),
|
||||
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<RoadmapDetailItemDto>)
|
||||
),
|
||||
tag = "Roadmap"
|
||||
)]
|
||||
pub async fn get_roadmap_by_id(
|
||||
Extension(service): Extension<Arc<dyn RoadmapService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> 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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn RoadmapService>>,
|
||||
ValidatedJson(payload): ValidatedJson<RoadmapCreateRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn RoadmapService>>,
|
||||
Path(id): Path<String>,
|
||||
ValidatedJson(payload): ValidatedJson<RoadmapUpdateRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppState>,
|
||||
Extension(service): Extension<Arc<dyn RoadmapService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<dyn RoadmapService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> 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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::{roadmap_protected_routes, roadmap_public_routes};
|
||||
@@ -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<dyn RoadmapService> {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_roadmap_repository;
|
||||
|
||||
pub use postgres_roadmap_repository::PostgresRoadmapRepository;
|
||||
@@ -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<DatabaseConnection>,
|
||||
}
|
||||
|
||||
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<PaginatorResponse<RoadmapEntity>, 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<RoadmapEntity, 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()))?;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::{roadmap_protected_routes, roadmap_public_routes};
|
||||
@@ -19,6 +19,7 @@ regex.workspace = true
|
||||
zod-rs.workspace = true
|
||||
zod-rs-util.workspace = true
|
||||
axum-test.workspace = true
|
||||
reqwest.workspace = true
|
||||
rand.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod rag_service;
|
||||
|
||||
pub use rag_service::RagServiceImpl;
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::domain::{
|
||||
ChatRequest, ChatResponse, RagDocument, RagRepository, RagService, RagSource,
|
||||
};
|
||||
use crate::ai_agent::infrastructure::llm_provider::{chat_completion, embed_text};
|
||||
use crate::materials::domain::MaterialRepository;
|
||||
use imphnen_entities::seaorm::common::materials::{
|
||||
Column as MaterialColumn, Entity as MaterialsEntity,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
const CHUNK_CHARS: usize = 700;
|
||||
const CHUNK_OVERLAP: usize = 80;
|
||||
const SEARCH_LIMIT: u64 = 4;
|
||||
const MAX_ANSWER_TOKENS: u32 = 600;
|
||||
|
||||
/// Stable u64 point id for a material chunk (uuid bytes xor chunk index).
|
||||
fn qdrant_point_id(material_id: Uuid, chunk_index: usize) -> u64 {
|
||||
let bytes = material_id.as_bytes();
|
||||
let mut val: u64 = 0;
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
val ^= (*b as u64) << ((i % 8) * 8);
|
||||
}
|
||||
val ^ (chunk_index as u64)
|
||||
}
|
||||
|
||||
pub struct RagServiceImpl {
|
||||
repo: Arc<dyn RagRepository>,
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl RagServiceImpl {
|
||||
pub fn new(repo: Arc<dyn RagRepository>, db: DatabaseConnection) -> Self {
|
||||
Self { repo, db }
|
||||
}
|
||||
|
||||
fn split_chunks(title: &str, content: &str) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
let text = format!("{}\n{}", title, content);
|
||||
let bytes = text.as_bytes();
|
||||
let mut start = 0usize;
|
||||
while start < bytes.len() {
|
||||
let end = (start + CHUNK_CHARS).min(bytes.len());
|
||||
// don't split mid-utf8 char
|
||||
let mut cut = end;
|
||||
while cut > start && !bytes[cut - 1].is_ascii() && cut < end + 3 {
|
||||
cut = end;
|
||||
break;
|
||||
}
|
||||
let chunk = &text[start..cut];
|
||||
if !chunk.trim().is_empty() {
|
||||
chunks.push(chunk.to_string());
|
||||
}
|
||||
if end >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(CHUNK_OVERLAP);
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
async fn index_entity(&self, material_id: Uuid) -> Result<u64, AppError> {
|
||||
let material = MaterialsEntity::find_by_id(material_id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
|
||||
if !material.is_published {
|
||||
return Ok(0);
|
||||
}
|
||||
// re-index: clear old chunks first
|
||||
self.repo.delete_material(material_id).await?;
|
||||
|
||||
let chunks = Self::split_chunks(&material.title, &material.content);
|
||||
let mut indexed = 0u64;
|
||||
let now = Utc::now();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let embedding = embed_text(chunk).await?;
|
||||
let doc = RagDocument {
|
||||
material_id,
|
||||
title: material.title.clone(),
|
||||
chunk: chunk.clone(),
|
||||
indexed_at: now,
|
||||
};
|
||||
let point_id = qdrant_point_id(material_id, i);
|
||||
self.repo.upsert_document(point_id, &doc, embedding).await?;
|
||||
indexed += 1;
|
||||
}
|
||||
Ok(indexed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RagService for RagServiceImpl {
|
||||
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError> {
|
||||
self.index_entity(material_id).await
|
||||
}
|
||||
|
||||
async fn reindex_all(&self) -> Result<u64, AppError> {
|
||||
let materials = MaterialsEntity::find()
|
||||
.filter(MaterialColumn::IsPublished.eq(true))
|
||||
.all(&self.db)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let mut total = 0u64;
|
||||
for m in materials {
|
||||
total += self.index_entity(m.id).await?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError> {
|
||||
let question = request.question.trim().to_string();
|
||||
if question.is_empty() {
|
||||
return Err(AppError::BadRequestError("question tidak boleh kosong".into()));
|
||||
}
|
||||
|
||||
let query_emb = embed_text(&question).await?;
|
||||
let hits = self
|
||||
.repo
|
||||
.search(query_emb, SEARCH_LIMIT, request.material_id)
|
||||
.await?;
|
||||
|
||||
if hits.is_empty() {
|
||||
// No indexed context: answer without RAG context but stay honest.
|
||||
let answer = chat_completion(
|
||||
"Kamu adalah asisten AI Dimentorin. Jawab pertanyaan singkat dan jelas. Jika tidak tahu, akui tidak tahu.",
|
||||
&question,
|
||||
MAX_ANSWER_TOKENS,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ChatResponse {
|
||||
answer,
|
||||
sources: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let context: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|(doc, _)| format!("[{}]\n{}", doc.title, doc.chunk))
|
||||
.collect();
|
||||
let context_block = context.join("\n\n---\n\n");
|
||||
let system = format!(
|
||||
"Kamu adalah asisten AI Dimentorin yang menjawab berdasarkan materi mentoring berikut.\n\
|
||||
Jawab dalam bahasa Indonesia, singkat, jelas, dan berfokus pada konteks yang diberikan.\n\
|
||||
Jika pertanyaan di luar materi, katakan bahwa hal itu di luar materi yang tersedia.\n\n\
|
||||
=== MATERI ===\n{}",
|
||||
context_block
|
||||
);
|
||||
let answer = chat_completion(&system, &question, MAX_ANSWER_TOKENS).await?;
|
||||
|
||||
let sources = hits
|
||||
.into_iter()
|
||||
.map(|(doc, score)| RagSource {
|
||||
material_id: doc.material_id,
|
||||
title: doc.title,
|
||||
score,
|
||||
snippet: doc.chunk.chars().take(180).collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ChatResponse { answer, sources })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub question: String,
|
||||
#[serde(default)]
|
||||
pub material_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub answer: String,
|
||||
pub sources: Vec<RagSource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RagSource {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub score: f32,
|
||||
pub snippet: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod chat_types;
|
||||
pub mod rag_document;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
pub use chat_types::{ChatRequest, ChatResponse, RagSource};
|
||||
pub use rag_document::RagDocument;
|
||||
pub use repository::RagRepository;
|
||||
pub use service::RagService;
|
||||
@@ -0,0 +1,10 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RagDocument {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub chunk: String,
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::rag_document::RagDocument;
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait RagRepository: Send + Sync {
|
||||
/// Upsert a document chunk into the vector store.
|
||||
async fn upsert_document(
|
||||
&self,
|
||||
point_id: u64,
|
||||
doc: &RagDocument,
|
||||
embedding: Vec<f32>,
|
||||
) -> Result<(), AppError>;
|
||||
/// Search the vector store for the closest chunks to `embedding`.
|
||||
async fn search(
|
||||
&self,
|
||||
embedding: Vec<f32>,
|
||||
limit: u64,
|
||||
material_id: Option<Uuid>,
|
||||
) -> Result<Vec<(RagDocument, f32)>, AppError>;
|
||||
/// Remove all chunks for a material (re-index support).
|
||||
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::chat_types::{ChatRequest, ChatResponse};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait RagService: Send + Sync {
|
||||
/// Embed + store one material (split into chunks).
|
||||
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError>;
|
||||
/// Re-index all published materials (full refresh).
|
||||
async fn reindex_all(&self) -> Result<u64, AppError>;
|
||||
/// Retrieve context + generate answer for a question.
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
use imphnen_libs::ZodValidate;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatRequestDto {
|
||||
#[zod(min_length(1), max_length(2000))]
|
||||
pub question: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub material_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for ChatRequestDto {
|
||||
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 ChatResponseDto {
|
||||
pub answer: String,
|
||||
pub sources: Vec<SourceDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SourceDto {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub score: f32,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexResultDto {
|
||||
pub material_id: Uuid,
|
||||
pub chunks_indexed: u64,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::dto::{ChatRequestDto, IndexResultDto};
|
||||
use crate::ai_agent::domain::{ChatRequest, RagService};
|
||||
use axum::{
|
||||
Extension, extract::Path,
|
||||
http::HeaderMap, response::IntoResponse,
|
||||
};
|
||||
use imphnen_libs::{ValidatedJson, decode_access_token};
|
||||
use imphnen_utils::{ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn post_chat(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
ValidatedJson(body): ValidatedJson<ChatRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let material_id = match body.material_id {
|
||||
Some(m) => Some(Uuid::parse_str(&m).map_err(|_| {
|
||||
AppError::BadRequestError("material_id tidak valid".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let result = service
|
||||
.chat(ChatRequest {
|
||||
question: body.question,
|
||||
material_id,
|
||||
})
|
||||
.await?;
|
||||
Ok(ApiSuccess(result))
|
||||
}
|
||||
|
||||
pub async fn post_index_material(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let indexed = service.index_material(id).await?;
|
||||
Ok(ApiSuccess(IndexResultDto {
|
||||
material_id: id,
|
||||
chunks_indexed: indexed,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn post_reindex_all(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let indexed = service.reindex_all().await?;
|
||||
Ok(ApiSuccess(IndexResultDto {
|
||||
material_id: Uuid::nil(),
|
||||
chunks_indexed: indexed,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,18 @@
|
||||
use super::handlers::{post_chat, post_index_material, post_reindex_all};
|
||||
use crate::ai_agent::application::RagServiceImpl;
|
||||
use crate::ai_agent::domain::RagService;
|
||||
use crate::ai_agent::infrastructure::QdrantRagRepository;
|
||||
use axum::{Extension, Router, routing::post};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn ai_agent_routes(db: DatabaseConnection, _state: Arc<AppState>) -> Router {
|
||||
let repo = Arc::new(QdrantRagRepository::new());
|
||||
let service: Arc<dyn RagService> = Arc::new(RagServiceImpl::new(repo, db));
|
||||
Router::new()
|
||||
.route("/ai/chat", post(post_chat))
|
||||
.route("/ai/materials/{id}/index", post(post_index_material))
|
||||
.route("/ai/reindex", post(post_reindex_all))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use imphnen_libs::environment::ENV;
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
|
||||
/// Call the LLM router /embeddings endpoint. Returns the embedding vector.
|
||||
pub async fn embed_text(input: &str) -> Result<Vec<f32>, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/embeddings", ENV.ai_llm_base_url);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Encoding", "identity")
|
||||
.bearer_auth(&ENV.ai_llm_api_key)
|
||||
.json(&json!({
|
||||
"model": ENV.ai_embedding_model,
|
||||
"input": input,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("embed request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("embed read failed: {e}")))?;
|
||||
let payload: serde_json::Value = serde_json::from_str(&text)
|
||||
.map_err(|e| {
|
||||
AppError::InternalServerError(format!(
|
||||
"embed parse failed: {e} (http {status}, body-len {})",
|
||||
text.len()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"embedding error (http {status}): {}",
|
||||
payload
|
||||
)));
|
||||
}
|
||||
let emb = payload["data"][0]["embedding"]
|
||||
.as_array()
|
||||
.ok_or_else(|| {
|
||||
AppError::InternalServerError("embedding response missing data[0].embedding".into())
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_f64().map(|f| f as f32))
|
||||
.collect::<Vec<f32>>();
|
||||
Ok(emb)
|
||||
}
|
||||
|
||||
/// Call the LLM router /chat/completions with a system prompt + user message.
|
||||
/// Uses streaming-safe parsing (router always streams); we read the full body.
|
||||
pub async fn chat_completion(
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
max_tokens: u32,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", ENV.ai_llm_base_url);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Encoding", "identity")
|
||||
.bearer_auth(&ENV.ai_llm_api_key)
|
||||
.json(&json!({
|
||||
"model": ENV.ai_llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system_prompt },
|
||||
{ "role": "user", "content": user_message }
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("chat request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("chat read failed: {e}")))?;
|
||||
|
||||
// 9router always returns SSE chunks; aggregate `data:` JSON lines.
|
||||
if status.is_success() {
|
||||
let mut full = String::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(payload) = line.strip_prefix("data:") {
|
||||
let payload = payload.trim();
|
||||
if payload == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
|
||||
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
|
||||
full.push_str(delta);
|
||||
}
|
||||
}
|
||||
} else if line.starts_with('{') {
|
||||
// non-streaming fallback
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(c) = v["choices"][0]["message"]["content"].as_str() {
|
||||
full.push_str(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !full.trim().is_empty() {
|
||||
return Ok(full);
|
||||
}
|
||||
// If no content extracted but status OK, return raw text as fallback.
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
Err(AppError::InternalServerError(format!(
|
||||
"chat error (http {status}): {}",
|
||||
payload
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod http;
|
||||
pub mod llm_provider;
|
||||
pub mod qdrant_rag_repository;
|
||||
|
||||
pub use llm_provider::{chat_completion, embed_text};
|
||||
pub use qdrant_rag_repository::QdrantRagRepository;
|
||||
@@ -0,0 +1,208 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_libs::environment::ENV;
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_agent::domain::{RagDocument, RagRepository};
|
||||
|
||||
pub const COLLECTION: &str = "dimentorin_materi";
|
||||
pub const VECTOR_SIZE: u64 = 3072; // gemini-embedding-001
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QdrantRagRepository {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl QdrantRagRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collection_url(&self) -> String {
|
||||
format!("{}/collections/{}", ENV.qdrant_url, COLLECTION)
|
||||
}
|
||||
|
||||
async fn ensure_collection(&self) -> Result<(), AppError> {
|
||||
let url = self.collection_url();
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant get collection: {e}")))?;
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let create = self
|
||||
.http
|
||||
.put(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"vectors": {
|
||||
"size": VECTOR_SIZE,
|
||||
"distance": "Cosine",
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::InternalServerError(format!("qdrant create collection: {e}"))
|
||||
})?;
|
||||
if !create.status().is_success() {
|
||||
let body = create.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant create collection failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
} else if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant check collection failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QdrantRagRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RagRepository for QdrantRagRepository {
|
||||
async fn upsert_document(
|
||||
&self,
|
||||
point_id: u64,
|
||||
doc: &RagDocument,
|
||||
embedding: Vec<f32>,
|
||||
) -> Result<(), AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points?wait=true", self.collection_url());
|
||||
let payload = json!({
|
||||
"points": [{
|
||||
"id": point_id,
|
||||
"vector": embedding,
|
||||
"payload": {
|
||||
"material_id": doc.material_id.to_string(),
|
||||
"title": doc.title,
|
||||
"chunk": doc.chunk,
|
||||
"indexed_at": doc.indexed_at.to_rfc3339(),
|
||||
},
|
||||
}]
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.put(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant upsert: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant upsert failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
embedding: Vec<f32>,
|
||||
limit: u64,
|
||||
material_id: Option<Uuid>,
|
||||
) -> Result<Vec<(RagDocument, f32)>, AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points/search", self.collection_url());
|
||||
let mut payload = json!({
|
||||
"vector": embedding,
|
||||
"limit": limit,
|
||||
"with_payload": true,
|
||||
});
|
||||
if let Some(mid) = material_id {
|
||||
payload["filter"] = json!({
|
||||
"must": [{ "key": "material_id", "match": { "value": mid.to_string() } }]
|
||||
});
|
||||
}
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant search: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant search failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
AppError::InternalServerError(format!("qdrant search parse: {e}"))
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
if let Some(points) = body["result"].as_array() {
|
||||
for p in points {
|
||||
let payload = &p["payload"];
|
||||
let material_id = payload["material_id"]
|
||||
.as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
let title = payload["title"].as_str().unwrap_or("").to_string();
|
||||
let chunk = payload["chunk"].as_str().unwrap_or("").to_string();
|
||||
let score = p["score"].as_f64().unwrap_or(0.0) as f32;
|
||||
if let Some(mid) = material_id {
|
||||
results.push((
|
||||
RagDocument {
|
||||
material_id: mid,
|
||||
title,
|
||||
chunk,
|
||||
indexed_at: Utc::now(),
|
||||
},
|
||||
score,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points/delete?wait=true", self.collection_url());
|
||||
let payload = json!({
|
||||
"filter": {
|
||||
"must": [{ "key": "material_id", "match": { "value": material_id.to_string() } }]
|
||||
}
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant delete: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant delete failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep this type alias for callers that need the concrete repo.
|
||||
pub type QdrantRepo = QdrantRagRepository;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use application::RagServiceImpl;
|
||||
pub use infrastructure::http::routes::ai_agent_routes;
|
||||
@@ -0,0 +1,73 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::domain::article::ArticleEntity;
|
||||
use super::super::domain::article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand};
|
||||
use super::super::domain::repository::ArticleRepository;
|
||||
use super::super::domain::service::ArticleService;
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
pub struct ArticleServiceImpl {
|
||||
repo: Arc<dyn ArticleRepository>,
|
||||
}
|
||||
|
||||
impl ArticleServiceImpl {
|
||||
pub fn new(repo: Arc<dyn ArticleRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ArticleService for ArticleServiceImpl {
|
||||
async fn list(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleListItem>, AppError> {
|
||||
let result = self.repo.find_all_paginated(page, per_page, category).await?;
|
||||
let items: Vec<ArticleListItem> = result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(ArticleListItem::from)
|
||||
.collect();
|
||||
Ok(PaginatorResponse {
|
||||
data: items,
|
||||
meta: result.meta,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<ArticleDetail, AppError> {
|
||||
let entity = self.repo.find_by_id(id).await?;
|
||||
Ok(ArticleDetail::from(entity))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<ArticleDetail, AppError> {
|
||||
let entity = self.repo.find_by_slug(slug).await?;
|
||||
Ok(ArticleDetail::from(entity))
|
||||
}
|
||||
|
||||
async fn categories(&self) -> Result<Vec<String>, AppError> {
|
||||
self.repo.find_categories().await
|
||||
}
|
||||
|
||||
async fn create(&self, cmd: CreateArticleCommand) -> Result<ArticleDetail, AppError> {
|
||||
let entity = ArticleEntity {
|
||||
id: Uuid::new_v4(),
|
||||
title: cmd.title,
|
||||
slug: cmd.slug,
|
||||
category: cmd.category,
|
||||
excerpt: cmd.excerpt,
|
||||
content: cmd.content,
|
||||
cover_url: cmd.cover_url,
|
||||
author_name: cmd.author_name,
|
||||
is_published: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
self.repo.create(entity.clone()).await?;
|
||||
Ok(ArticleDetail::from(entity))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod article_service;
|
||||
|
||||
pub use article_service::ArticleServiceImpl;
|
||||
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArticleEntity {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::article::ArticleEntity;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArticleListItem {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArticleDetail {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateArticleCommand {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ArticleEntity> for ArticleListItem {
|
||||
fn from(e: ArticleEntity) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
slug: e.slug,
|
||||
category: e.category,
|
||||
excerpt: e.excerpt,
|
||||
cover_url: e.cover_url,
|
||||
author_name: e.author_name,
|
||||
created_at: e.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArticleEntity> for ArticleDetail {
|
||||
fn from(e: ArticleEntity) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
slug: e.slug,
|
||||
category: e.category,
|
||||
excerpt: e.excerpt,
|
||||
content: e.content,
|
||||
cover_url: e.cover_url,
|
||||
author_name: e.author_name,
|
||||
is_published: e.is_published,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod article;
|
||||
pub mod article_types;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
pub use repository::ArticleRepository;
|
||||
pub use service::ArticleService;
|
||||
pub use article::ArticleEntity;
|
||||
pub use article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand};
|
||||
@@ -0,0 +1,20 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::article::ArticleEntity;
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ArticleRepository: Send + Sync {
|
||||
async fn find_all_paginated(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<ArticleEntity, AppError>;
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<ArticleEntity, AppError>;
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(&self, entity: ArticleEntity) -> Result<Uuid, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ArticleService: Send + Sync {
|
||||
async fn list(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleListItem>, AppError>;
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<ArticleDetail, AppError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<ArticleDetail, AppError>;
|
||||
async fn categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(
|
||||
&self,
|
||||
cmd: CreateArticleCommand,
|
||||
) -> Result<ArticleDetail, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use request::CreateArticleRequestDto;
|
||||
pub use response::{ArticleDetailDto, ArticleListItemDto};
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::articles::domain::article_types::CreateArticleCommand;
|
||||
use imphnen_libs::ZodValidate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
pub struct CreateArticleRequestDto {
|
||||
#[zod(min_length(3), max_length(200))]
|
||||
pub title: String,
|
||||
#[zod(min_length(3), max_length(200))]
|
||||
pub slug: String,
|
||||
#[zod(min_length(1), max_length(100))]
|
||||
pub category: String,
|
||||
#[zod(min_length(3), max_length(500))]
|
||||
pub excerpt: String,
|
||||
#[zod(min_length(10))]
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for CreateArticleRequestDto {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateArticleRequestDto> for CreateArticleCommand {
|
||||
fn from(dto: CreateArticleRequestDto) -> Self {
|
||||
Self {
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
category: dto.category,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
cover_url: dto.cover_url,
|
||||
author_name: dto.author_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::articles::domain::article_types::{ArticleDetail, ArticleListItem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleListItemDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleListItem> for ArticleListItemDto {
|
||||
fn from(a: ArticleListItem) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
category: a.category,
|
||||
excerpt: a.excerpt,
|
||||
cover_url: a.cover_url,
|
||||
author_name: a.author_name,
|
||||
created_at: a.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleDetailDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleDetail> for ArticleDetailDto {
|
||||
fn from(a: ArticleDetail) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
category: a.category,
|
||||
excerpt: a.excerpt,
|
||||
content: a.content,
|
||||
cover_url: a.cover_url,
|
||||
author_name: a.author_name,
|
||||
is_published: a.is_published,
|
||||
created_at: a.created_at.to_rfc3339(),
|
||||
updated_at: a.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod mutation_handlers;
|
||||
pub mod query_handlers;
|
||||
|
||||
pub use mutation_handlers::post_create_article;
|
||||
pub use query_handlers::{
|
||||
get_article_by_id, get_article_by_slug, get_article_categories,
|
||||
get_articles_list,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::super::dto::{ArticleDetailDto, CreateArticleRequestDto};
|
||||
use crate::articles::domain::ArticleService;
|
||||
use axum::{response::IntoResponse, Extension};
|
||||
use imphnen_libs::{AppState, ValidatedJson};
|
||||
use imphnen_utils::{ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/dimentorin/articles/create",
|
||||
request_body = CreateArticleRequestDto,
|
||||
responses(
|
||||
(status = 201, description = "Article created successfully", body = ArticleDetailDto),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles",
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn post_create_article(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
ValidatedJson(dto): ValidatedJson<CreateArticleRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let detail = service.create(dto.into()).await?;
|
||||
Ok(ApiSuccess(ArticleDetailDto::from(detail)))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::super::dto::{ArticleDetailDto, ArticleListItemDto};
|
||||
use crate::articles::domain::ArticleService;
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{ApiPaginated, ApiSuccess, AppError};
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArticleListQuery {
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles",
|
||||
params(
|
||||
("page" = Option<u64>, Query, description = "Page number"),
|
||||
("per_page" = Option<u64>, Query, description = "Items per page"),
|
||||
("category" = Option<String>, Query, description = "Filter by category"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Articles retrieved successfully", body = Vec<ArticleListItemDto>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_articles_list(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Query(q): Query<ArticleListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let per_page = q.per_page.unwrap_or(10).clamp(1, 100);
|
||||
let result = service.list(page, per_page, q.category).await?;
|
||||
let mapped = PaginatorResponse {
|
||||
data: result.data.into_iter().map(ArticleListItemDto::from).collect(),
|
||||
meta: result.meta,
|
||||
};
|
||||
Ok(ApiPaginated(mapped))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/{id}",
|
||||
params(("id" = String, Path, description = "Article ID")),
|
||||
responses(
|
||||
(status = 200, description = "Article retrieved successfully", body = ArticleDetailDto),
|
||||
(status = 404, description = "Article not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_by_id(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let uuid = Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid article ID".to_string()))?;
|
||||
let dto = ArticleDetailDto::from(service.get_by_id(uuid).await?);
|
||||
Ok(ApiSuccess(dto))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/slug/{slug}",
|
||||
params(("slug" = String, Path, description = "Article slug")),
|
||||
responses(
|
||||
(status = 200, description = "Article retrieved successfully", body = ArticleDetailDto),
|
||||
(status = 404, description = "Article not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_by_slug(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let dto = ArticleDetailDto::from(service.get_by_slug(&slug).await?);
|
||||
Ok(ApiSuccess(dto))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/categories",
|
||||
responses(
|
||||
(status = 200, description = "Article categories retrieved successfully"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_categories(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let cats = service.categories().await?;
|
||||
Ok(ApiSuccess(cats))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::handlers::{
|
||||
get_article_by_id, get_article_by_slug, get_article_categories,
|
||||
get_articles_list, post_create_article,
|
||||
};
|
||||
use crate::articles::application::ArticleServiceImpl;
|
||||
use crate::articles::domain::ArticleService;
|
||||
use crate::articles::infrastructure::persistence::PostgresArticleRepository;
|
||||
use axum::{Extension, Router, routing::{get, post}};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn ArticleService> {
|
||||
let repo = Arc::new(PostgresArticleRepository::new(db));
|
||||
Arc::new(ArticleServiceImpl::new(repo))
|
||||
}
|
||||
|
||||
pub fn articles_public_routes(db: DatabaseConnection) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/articles", get(get_articles_list))
|
||||
.route("/articles/categories", get(get_article_categories))
|
||||
.route("/articles/slug/{slug}", get(get_article_by_slug))
|
||||
.route("/articles/{id}", get(get_article_by_id))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
|
||||
pub fn articles_protected_routes(
|
||||
db: DatabaseConnection,
|
||||
state: Arc<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/articles/create", post(post_create_article))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod postgres_article_queries;
|
||||
pub mod postgres_article_repository;
|
||||
|
||||
pub use postgres_article_repository::PostgresArticleRepository;
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
use super::super::super::domain::article::ArticleEntity;
|
||||
use imphnen_entities::seaorm::common::articles::{
|
||||
Column as ArticleColumn, Entity as ArticlesEntity, Model as ArticleModel,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::{
|
||||
EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn model_to_entity(model: ArticleModel) -> ArticleEntity {
|
||||
ArticleEntity {
|
||||
id: model.id,
|
||||
title: model.title,
|
||||
slug: model.slug,
|
||||
category: model.category,
|
||||
excerpt: model.excerpt,
|
||||
content: model.content,
|
||||
cover_url: model.cover_url,
|
||||
author_name: model.author_name,
|
||||
is_published: model.is_published,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_all_paginated(
|
||||
db: &Arc<DatabaseConnection>,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, AppError> {
|
||||
let mut query = ArticlesEntity::find().filter(ArticleColumn::IsPublished.eq(true));
|
||||
|
||||
if let Some(cat) = category.filter(|c| !c.is_empty()) {
|
||||
query = query.filter(ArticleColumn::Category.eq(cat));
|
||||
}
|
||||
|
||||
query = query.order_by(ArticleColumn::CreatedAt, Order::Desc);
|
||||
|
||||
let paginator = query.paginate(db.as_ref(), per_page);
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let articles = paginator
|
||||
.fetch_page(page.saturating_sub(1))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let data = articles.into_iter().map(model_to_entity).collect();
|
||||
let meta = PaginatorResponseMeta::new(page as u32, per_page as u32, total as u32);
|
||||
Ok(PaginatorResponse { data, meta })
|
||||
}
|
||||
|
||||
pub async fn find_by_id(
|
||||
db: &Arc<DatabaseConnection>,
|
||||
id: Uuid,
|
||||
) -> Result<ArticleEntity, AppError> {
|
||||
let model = ArticlesEntity::find_by_id(id)
|
||||
.filter(ArticleColumn::IsPublished.eq(true))
|
||||
.one(db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Article not found".to_string()))?;
|
||||
Ok(model_to_entity(model))
|
||||
}
|
||||
|
||||
pub async fn find_by_slug(
|
||||
db: &Arc<DatabaseConnection>,
|
||||
slug: &str,
|
||||
) -> Result<ArticleEntity, AppError> {
|
||||
let model = ArticlesEntity::find()
|
||||
.filter(ArticleColumn::Slug.eq(slug))
|
||||
.filter(ArticleColumn::IsPublished.eq(true))
|
||||
.one(db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Article not found".to_string()))?;
|
||||
Ok(model_to_entity(model))
|
||||
}
|
||||
|
||||
pub async fn find_categories(db: &Arc<DatabaseConnection>) -> Result<Vec<String>, AppError> {
|
||||
let rows: Vec<serde_json::Value> = ArticlesEntity::find()
|
||||
.select_only()
|
||||
.column(ArticleColumn::Category)
|
||||
.distinct()
|
||||
.into_json()
|
||||
.all(db.as_ref())
|
||||
.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())
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use super::postgres_article_queries::{
|
||||
find_all_paginated, find_by_id, find_by_slug, find_categories,
|
||||
};
|
||||
use super::super::super::domain::article::ArticleEntity;
|
||||
use super::super::super::domain::repository::ArticleRepository;
|
||||
use imphnen_entities::seaorm::common::articles::{ActiveModel, Column as ArticleColumn};
|
||||
use imphnen_utils::AppError;
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PostgresArticleRepository {
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PostgresArticleRepository {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db: Arc::new(db) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ArticleRepository for PostgresArticleRepository {
|
||||
async fn find_all_paginated(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
) -> Result<PaginatorResponse<ArticleEntity>, AppError> {
|
||||
find_all_paginated(&self.db, page, per_page, category).await
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<ArticleEntity, AppError> {
|
||||
find_by_id(&self.db, id).await
|
||||
}
|
||||
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<ArticleEntity, AppError> {
|
||||
find_by_slug(&self.db, slug).await
|
||||
}
|
||||
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError> {
|
||||
find_categories(&self.db).await
|
||||
}
|
||||
|
||||
async fn create(&self, entity: ArticleEntity) -> Result<Uuid, AppError> {
|
||||
let active = ActiveModel {
|
||||
id: sea_orm::ActiveValue::Set(entity.id),
|
||||
title: Set(entity.title),
|
||||
slug: Set(entity.slug),
|
||||
category: Set(entity.category),
|
||||
excerpt: Set(entity.excerpt),
|
||||
content: Set(entity.content),
|
||||
cover_url: Set(entity.cover_url),
|
||||
author_name: Set(entity.author_name),
|
||||
is_published: Set(entity.is_published),
|
||||
created_at: Set(entity.created_at),
|
||||
updated_at: Set(entity.updated_at),
|
||||
};
|
||||
active
|
||||
.insert(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(entity.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::{articles_public_routes, articles_protected_routes};
|
||||
@@ -1,5 +1,13 @@
|
||||
pub mod ai_agent;
|
||||
pub mod articles;
|
||||
pub mod materials;
|
||||
pub mod mentors;
|
||||
pub mod payments;
|
||||
pub mod sessions;
|
||||
|
||||
pub use ai_agent::ai_agent_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 sessions::{sessions_protected_routes, sessions_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,
|
||||
};
|
||||
@@ -13,7 +13,7 @@ use zod_rs::prelude::*;
|
||||
pub struct MentorUserRegisterRequestDto {
|
||||
#[zod(email, min_length(1))]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
|
||||
#[zod(min_length(8))]
|
||||
pub password: String,
|
||||
#[zod(min_length(2))]
|
||||
pub fullname: String,
|
||||
|
||||
@@ -7,4 +7,5 @@ pub use mutation_handlers::{
|
||||
};
|
||||
pub use query_handlers::{
|
||||
get_mentor_by_id, get_mentor_list, get_mentor_me, get_mentor_status,
|
||||
get_public_mentor_by_id, get_public_mentor_list,
|
||||
};
|
||||
|
||||
@@ -151,3 +151,66 @@ pub async fn get_mentor_status(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/mentors/public",
|
||||
params(
|
||||
("page" = Option<u64>, Query, description = "Page number"),
|
||||
("per_page" = Option<u64>, Query, description = "Items per page"),
|
||||
("search" = Option<String>, Query, description = "Search query"),
|
||||
("sort_by" = Option<String>, Query, description = "Sort by field"),
|
||||
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Get list of verified mentors", body = Vec<MentorListResponseDto>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Mentors"
|
||||
)]
|
||||
pub async fn get_public_mentor_list(
|
||||
Extension(state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn MentorService>>,
|
||||
PaginationQuery(params): PaginationQuery,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let _ = state;
|
||||
let result = service.list(params).await?;
|
||||
let mapped = PaginatorResponse {
|
||||
data: result
|
||||
.data
|
||||
.into_iter()
|
||||
.filter(|item| item.status.eq_ignore_ascii_case("verified"))
|
||||
.map(MentorListResponseDto::from)
|
||||
.collect(),
|
||||
meta: result.meta,
|
||||
};
|
||||
Ok(ApiPaginated(mapped))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/mentors/public/{id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Mentor ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Get verified mentor by ID", body = MentorDetailResponseDto),
|
||||
(status = 404, description = "Mentor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Mentors"
|
||||
)]
|
||||
pub async fn get_public_mentor_by_id(
|
||||
Extension(state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn MentorService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let _ = state;
|
||||
let mentor_uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
AppError::BadRequestError(
|
||||
"Invalid mentor ID format. Must be a valid UUID.".to_string(),
|
||||
)
|
||||
})?;
|
||||
let dto = MentorDetailResponseDto::from(service.get_by_id(mentor_uuid).await?);
|
||||
Ok(ApiSuccess(dto))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::handlers::{
|
||||
delete_mentor, get_mentor_by_id, get_mentor_list, get_mentor_me,
|
||||
get_mentor_status, post_register_mentor, put_update_mentor, put_update_mentor_me,
|
||||
get_mentor_status, get_public_mentor_by_id, get_public_mentor_list,
|
||||
post_register_mentor, put_update_mentor, put_update_mentor_me,
|
||||
put_update_mentor_no_id, put_verify_mentor,
|
||||
};
|
||||
use crate::mentors::application::MentorServiceImpl;
|
||||
@@ -33,6 +34,8 @@ pub fn mentors_public_routes(
|
||||
let service = build_service(db, state);
|
||||
Router::new()
|
||||
.route("/mentors/create", post(post_register_mentor))
|
||||
.route("/mentors/public", get(get_public_mentor_list))
|
||||
.route("/mentors/public/{id}", get(get_public_mentor_by_id))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -105,6 +105,7 @@ impl MentorRepository for PostgresMentorRepository {
|
||||
|
||||
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> {
|
||||
let active_model = MentorActiveModel {
|
||||
id: ActiveValue::Set(Uuid::new_v4()),
|
||||
user_id: ActiveValue::Set(entity.user_id),
|
||||
industries: ActiveValue::Set(Some(
|
||||
serde_json::to_value(&entity.industries)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Midtrans payment provider — Core API (v2/charge).
|
||||
//!
|
||||
//! Gateway-agnostic design: the payments service calls into this module when
|
||||
//! Midtrans credentials are configured (`MIDTRANS_*` env). It returns the
|
||||
//! provider-specific reference that gets persisted into `app_payments.external_ref`:
|
||||
//! a VA number for `va`, or the QR string payload for `qris`.
|
||||
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
|
||||
/// Query transaction status for an order via Midtrans Core API.
|
||||
///
|
||||
/// Returns the raw `transaction_status` string (e.g. "capture", "settlement",
|
||||
/// "pending", "expire", ...).
|
||||
pub async fn get_status(
|
||||
order_id: &str,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/{}/status", status_base(), order_id);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.basic_auth(server_key, Some(""))
|
||||
// Midtrans/istio compresses with gzip even when the client cannot
|
||||
// decompress; reqwest auto-decompress can return an empty body here,
|
||||
// so ask for identity explicitly.
|
||||
.header(reqwest::header::ACCEPT_ENCODING, "identity")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans status request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let hdrs = format!("{:?}", resp.headers());
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans status read failed: {e} (http {}, hdrs {})", status, hdrs)))?;
|
||||
let payload: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
||||
AppError::InternalServerError(format!(
|
||||
"Midtrans status parse failed: {e} (http {}, hdrs {}, body-len {})",
|
||||
status,
|
||||
hdrs,
|
||||
text.len()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans status error ({}): {}",
|
||||
status,
|
||||
payload["status_message"].as_str().unwrap_or("unknown")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(
|
||||
payload["transaction_status"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_base() -> &'static str {
|
||||
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
||||
"https://api.midtrans.com/v2"
|
||||
} else {
|
||||
"https://api.sandbox.midtrans.com/v2"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox vs production endpoint for charge. Sandbox is the default and safe for demo.
|
||||
fn charge_url() -> &'static str {
|
||||
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
||||
"https://api.midtrans.com/v2/charge"
|
||||
} else {
|
||||
"https://api.sandbox.midtrans.com/v2/charge"
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bank-transfer (Virtual Account) charge via Midtrans Core API.
|
||||
///
|
||||
/// Returns the VA number to display to the mentee.
|
||||
pub async fn create_va_charge(
|
||||
order_id: &str,
|
||||
gross_amount: i64,
|
||||
bank: &str,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = json!({
|
||||
"payment_type": "bank_transfer",
|
||||
"transaction_details": {
|
||||
"order_id": order_id,
|
||||
"gross_amount": gross_amount,
|
||||
},
|
||||
"bank_transfer": {
|
||||
"bank": bank,
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(charge_url())
|
||||
.basic_auth(server_key, Some(""))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let payload: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans charge error ({}): {}",
|
||||
status,
|
||||
payload["status_message"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown error")
|
||||
)));
|
||||
}
|
||||
|
||||
payload["va_numbers"][0]["va_number"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing va_number".into()))
|
||||
}
|
||||
|
||||
/// Create a QRIS charge via Midtrans Core API.
|
||||
///
|
||||
/// Returns the QR string payload (renderable as a QR code).
|
||||
pub async fn create_qris_charge(
|
||||
order_id: &str,
|
||||
gross_amount: i64,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = json!({
|
||||
"payment_type": "qris",
|
||||
"transaction_details": {
|
||||
"order_id": order_id,
|
||||
"gross_amount": gross_amount,
|
||||
},
|
||||
"qris": {
|
||||
"acquirer": "gopay",
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(charge_url())
|
||||
.basic_auth(server_key, Some(""))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let payload: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans charge error ({}): {}",
|
||||
status,
|
||||
payload["status_message"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown error")
|
||||
)));
|
||||
}
|
||||
|
||||
payload["qr_string"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing qr_string".into()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod midtrans_provider;
|
||||
pub mod payment_service;
|
||||
|
||||
pub use payment_service::PaymentServiceImpl;
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::super::domain::{
|
||||
CreatePaymentCommand, PaymentEntity, PaymentRepository, PaymentService, SERVICE_FEE,
|
||||
};
|
||||
use crate::sessions::domain::SessionRepository;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use imphnen_entities::seaorm::auth::mentors::Entity as MentorsEntity;
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PaymentServiceImpl {
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PaymentServiceImpl {
|
||||
pub fn new(
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
payment_repo,
|
||||
session_repo,
|
||||
db,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_external_ref(method: &str, session_id: Uuid) -> String {
|
||||
match method {
|
||||
"va" => format!("VA-{}-{}", session_id.to_string().split('-').next().unwrap_or("X"), Utc::now().format("%Y%m%d%H%M%S")),
|
||||
"qris" => format!("QR-{}", session_id.to_string().replace('-', "").chars().take(16).collect::<String>()),
|
||||
_ => format!("MANUAL-{}", Utc::now().format("%Y%m%d%H%M%S")),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentService for PaymentServiceImpl {
|
||||
async fn create_payment(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
mentee_id: Uuid,
|
||||
cmd: CreatePaymentCommand,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Only a valid session can be paid for.
|
||||
let session = self
|
||||
.session_repo
|
||||
.find_by_id(session_id)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFoundError("Session not found".into()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Session not found".into()))?;
|
||||
|
||||
// The mentee paying must be the session's mentee.
|
||||
if session.mentee_id != mentee_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only pay for your own sessions".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load mentor rate from the mentors table (mentors.user_id = the session's
|
||||
// mentor user id).
|
||||
let mentor_uuid = session.mentor_id;
|
||||
let mentor = MentorsEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::mentors::Column::UserId.eq(mentor_uuid))
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Mentor not found".into()))?;
|
||||
|
||||
let rate = mentor.mentoring_rate.unwrap_or(50_000.0).round() as i64;
|
||||
let total = rate + SERVICE_FEE;
|
||||
let method = cmd.method.clone();
|
||||
// Use Midtrans when credentials are configured; fall back to manual refs.
|
||||
let midtrans = imphnen_libs::environment::ENV.midtrans_merchant_id.clone();
|
||||
let order_id = format!("DM-{}", Uuid::new_v4());
|
||||
let (provider, external_ref) = if !midtrans.is_empty() {
|
||||
let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone();
|
||||
match method.as_str() {
|
||||
"va" => {
|
||||
let va =
|
||||
crate::payments::application::midtrans_provider::create_va_charge(
|
||||
&order_id,
|
||||
total,
|
||||
"bca",
|
||||
&server_key,
|
||||
)
|
||||
.await?;
|
||||
("midtrans".to_string(), Some(va))
|
||||
}
|
||||
"qris" => {
|
||||
let qr = crate::payments::application::midtrans_provider::create_qris_charge(
|
||||
&order_id,
|
||||
total,
|
||||
&server_key,
|
||||
)
|
||||
.await?;
|
||||
("midtrans".to_string(), Some(qr))
|
||||
}
|
||||
_ => ("manual".to_string(), Some(generate_external_ref(&method, session_id))),
|
||||
}
|
||||
} else {
|
||||
("manual".to_string(), Some(generate_external_ref(&method, session_id)))
|
||||
};
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
let provider_order_id = if provider == "midtrans" {
|
||||
Some(order_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payment = PaymentEntity {
|
||||
id: Uuid::new_v4(),
|
||||
session_id,
|
||||
mentee_id,
|
||||
mentor_id: mentor_uuid,
|
||||
amount: rate,
|
||||
service_fee: SERVICE_FEE,
|
||||
total,
|
||||
method: method.clone(),
|
||||
provider,
|
||||
status: "pending".into(),
|
||||
external_ref,
|
||||
provider_order_id,
|
||||
expires_at,
|
||||
created_at: Utc::now(),
|
||||
paid_at: None,
|
||||
};
|
||||
self.payment_repo.create(payment).await
|
||||
}
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.mentee_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only view your own payments".into(),
|
||||
));
|
||||
}
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Payment can be confirmed by the session's mentor (they see the
|
||||
// transfer arrive) or by an Admin / "Admin Pembayaran".
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.status != "pending" {
|
||||
return Err(AppError::ConflictError(
|
||||
"Payment is not pending".into(),
|
||||
));
|
||||
}
|
||||
let user = UsersEntity::find_by_id(actor_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Actor not found".into()))?;
|
||||
// Mentor of the linked session may confirm; otherwise an
|
||||
// Admin / "Admin Pembayaran" role is required.
|
||||
let is_mentor = payment.mentor_id == actor_id;
|
||||
if !is_mentor {
|
||||
let role_id = user.role_id.ok_or_else(|| {
|
||||
AppError::ForbiddenError("User has no role assigned".into())
|
||||
})?;
|
||||
let roles =
|
||||
imphnen_entities::seaorm::auth::roles::Entity::find_by_id(role_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::ForbiddenError("Role not found".into()))?;
|
||||
if roles.name != "Admin" && roles.name != "Admin Pembayaran" {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"Only the session mentor or a payment admin can confirm payments".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let paid = self.payment_repo
|
||||
.update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default()))
|
||||
.await?;
|
||||
// Confirm the linked session so mentor/mentee can proceed with the call.
|
||||
if let Some(mut session) = self.session_repo.find_by_id(payment.session_id).await? {
|
||||
session.status = "confirmed".to_string();
|
||||
session.updated_at = Utc::now();
|
||||
self.session_repo.update(payment.session_id, session).await?;
|
||||
}
|
||||
Ok(paid)
|
||||
}
|
||||
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
self.payment_repo.find_by_mentee(mentee_id).await
|
||||
}
|
||||
|
||||
async fn refresh_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.mentee_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only refresh your own payments".into(),
|
||||
));
|
||||
}
|
||||
if payment.status == "paid" {
|
||||
return Ok(payment);
|
||||
}
|
||||
let Some(order_id) = payment.provider_order_id.clone() else {
|
||||
// Manual payments have no provider to poll — nothing to do.
|
||||
return Ok(payment);
|
||||
};
|
||||
let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone();
|
||||
let midtrans_status =
|
||||
crate::payments::application::midtrans_provider::get_status(&order_id, &server_key)
|
||||
.await?;
|
||||
|
||||
if midtrans_status == "capture" || midtrans_status == "settlement" {
|
||||
let paid = self
|
||||
.payment_repo
|
||||
.update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default()))
|
||||
.await?;
|
||||
if let Some(mut session) = self.session_repo.find_by_id(payment.session_id).await? {
|
||||
session.status = "confirmed".to_string();
|
||||
session.updated_at = Utc::now();
|
||||
self.session_repo.update(payment.session_id, session).await?;
|
||||
}
|
||||
return Ok(paid);
|
||||
}
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
async fn get_session_payments(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
// Only the session's mentee or mentor may see its payments.
|
||||
let session = self.session_repo.find_by_id(session_id).await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Session not found".into()))?;
|
||||
if session.mentee_id != user_id && session.mentor_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only view payments of your own sessions".into(),
|
||||
));
|
||||
}
|
||||
self.payment_repo.find_by_session(session_id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
pub mod service;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use imphnen_utils::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use service::PaymentService;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreatePaymentCommand {
|
||||
pub method: String, // "va" | "qris" | "manual"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PaymentEntity {
|
||||
pub id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub mentee_id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub amount: i64,
|
||||
pub service_fee: i64,
|
||||
pub total: i64,
|
||||
pub method: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub const SERVICE_FEE: i64 = 2_000;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentRepository: Send + Sync {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_session(&self, session_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn find_by_mentee(&self, mentee_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
external_ref: Option<String>,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::{CreatePaymentCommand, PaymentEntity};
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentService: Send + Sync {
|
||||
/// Create a payment record for a booked session. Computes amount from the
|
||||
/// mentor's mentoring_rate, adds service fee, and (for the default manual
|
||||
/// provider) generates a deterministic external reference.
|
||||
async fn create_payment(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
mentee_id: Uuid,
|
||||
cmd: CreatePaymentCommand,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// Confirm a pending payment (admin / "Admin Pembayaran"). Marks paid.
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// List payments for the current mentee.
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
|
||||
/// Refresh a payment's status against the provider (Midtrans).
|
||||
/// If the provider reports paid (capture/settlement), the payment is
|
||||
/// marked paid and the linked session is auto-confirmed.
|
||||
async fn refresh_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// List payments for one session. Only the session's mentee or mentor may
|
||||
/// access.
|
||||
async fn get_session_payments(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use imphnen_libs::ZodValidate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
pub struct CreatePaymentRequestDto {
|
||||
// "va" | "qris" | "manual"
|
||||
#[serde(default = "default_method")]
|
||||
#[zod(min_length(1), max_length(20))]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
fn default_method() -> String {
|
||||
"manual".into()
|
||||
}
|
||||
|
||||
impl ZodValidate for CreatePaymentRequestDto {
|
||||
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, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentResponseDto {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub mentor_id: String,
|
||||
pub amount: i64,
|
||||
pub service_fee: i64,
|
||||
pub total: i64,
|
||||
pub method: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::dto::{CreatePaymentRequestDto, PaymentResponseDto};
|
||||
use crate::payments::domain::{CreatePaymentCommand, PaymentEntity, PaymentService};
|
||||
use axum::Extension;
|
||||
use axum::extract::Path;
|
||||
use axum::http::{HeaderMap, header::AUTHORIZATION};
|
||||
use imphnen_libs::ValidatedJson;
|
||||
use imphnen_libs::decode_access_token;
|
||||
use imphnen_utils::{ApiMessage, ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn extract_user_id(headers: &HeaderMap) -> Result<uuid::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::Uuid::parse_str(&claims.claims.user_id)
|
||||
.map_err(|_| AppError::AuthenticationError("Invalid token subject".into()))
|
||||
}
|
||||
|
||||
fn to_dto(p: &PaymentEntity) -> PaymentResponseDto {
|
||||
PaymentResponseDto {
|
||||
id: p.id.to_string(),
|
||||
session_id: p.session_id.to_string(),
|
||||
mentor_id: p.mentor_id.to_string(),
|
||||
amount: p.amount,
|
||||
service_fee: p.service_fee,
|
||||
total: p.total,
|
||||
method: p.method.clone(),
|
||||
provider: p.provider.clone(),
|
||||
status: p.status.clone(),
|
||||
external_ref: p.external_ref.clone(),
|
||||
provider_order_id: p.provider_order_id.clone(),
|
||||
expires_at: p.expires_at.to_rfc3339(),
|
||||
created_at: p.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/sessions/{id}/create
|
||||
pub async fn post_create_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(session_id): Path<String>,
|
||||
ValidatedJson(dto): ValidatedJson<CreatePaymentRequestDto>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let session_uuid = uuid::Uuid::parse_str(&session_id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid session ID".into()))?;
|
||||
let payment = service
|
||||
.create_payment(
|
||||
session_uuid,
|
||||
user_id,
|
||||
CreatePaymentCommand { method: dto.method },
|
||||
)
|
||||
.await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
|
||||
/// GET /v1/dimentorin/payments/me
|
||||
pub async fn get_my_payments(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payments = service.get_mentee_payments(user_id).await?;
|
||||
let items: Vec<PaymentResponseDto> = payments.iter().map(to_dto).collect();
|
||||
Ok(ApiSuccess(items))
|
||||
}
|
||||
|
||||
pub async fn get_session_payments(
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::extract::Path(session_id): axum::extract::Path<Uuid>,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payments = service.get_session_payments(session_id, user_id).await?;
|
||||
let items: Vec<PaymentResponseDto> = payments.iter().map(to_dto).collect();
|
||||
Ok(ApiSuccess(items))
|
||||
}
|
||||
|
||||
/// GET /v1/dimentorin/payments/{id}
|
||||
pub async fn get_payment_by_id(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.get_payment_by_id(payment_uuid, user_id).await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/{id}/confirm (Admin / Admin Pembayaran)
|
||||
pub async fn post_confirm_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let actor_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.confirm_payment(payment_uuid, actor_id).await?;
|
||||
Ok(ApiMessage::ok(format!(
|
||||
"Payment {} confirmed",
|
||||
payment.external_ref.clone().unwrap_or_else(|| payment.id.to_string())
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/{id}/refresh (mentee)
|
||||
/// Polls the provider (Midtrans) and auto-marks the payment paid + session
|
||||
/// confirmed when the transaction settles.
|
||||
pub async fn post_refresh_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.refresh_status(payment_uuid, user_id).await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::payments_protected_routes;
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::handlers::{
|
||||
get_my_payments, get_payment_by_id, get_session_payments, post_confirm_payment,
|
||||
post_create_payment, post_refresh_payment,
|
||||
};
|
||||
use crate::payments::application::PaymentServiceImpl;
|
||||
use crate::payments::domain::PaymentService;
|
||||
use crate::payments::infrastructure::persistence::PostgresPaymentRepository;
|
||||
use crate::sessions::infrastructure::persistence::PostgresSessionRepository;
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn PaymentService> {
|
||||
let db_arc = Arc::new(db);
|
||||
let payment_repo =
|
||||
Arc::new(PostgresPaymentRepository::new(Arc::clone(&db_arc)));
|
||||
let session_repo =
|
||||
Arc::new(PostgresSessionRepository::new(Arc::clone(&db_arc)));
|
||||
Arc::new(PaymentServiceImpl::new(payment_repo, session_repo, db_arc))
|
||||
}
|
||||
|
||||
pub fn payments_protected_routes(
|
||||
db: DatabaseConnection,
|
||||
state: Arc<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/payments/sessions/{id}/create", post(post_create_payment))
|
||||
.route("/payments/me", get(get_my_payments))
|
||||
.route("/payments/session/{id}", get(get_session_payments))
|
||||
.route("/payments/{id}", get(get_payment_by_id))
|
||||
.route("/payments/{id}/confirm", post(post_confirm_payment))
|
||||
.route("/payments/{id}/refresh", post(post_refresh_payment))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
pub use persistence::PostgresPaymentRepository;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_payment_repository;
|
||||
|
||||
pub use postgres_payment_repository::PostgresPaymentRepository;
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
use crate::payments::domain::{PaymentEntity, PaymentRepository};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::seaorm::common::payments::{
|
||||
ActiveModel as PaymentActiveModel, Column as PaymentColumn, Entity as PaymentEntityOrm,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn map(row: imphnen_entities::seaorm::common::payments::Model) -> PaymentEntity {
|
||||
PaymentEntity {
|
||||
id: row.id,
|
||||
session_id: row.session_id,
|
||||
mentee_id: row.mentee_id,
|
||||
mentor_id: row.mentor_id,
|
||||
amount: row.amount,
|
||||
service_fee: row.service_fee,
|
||||
total: row.total,
|
||||
method: row.method,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
external_ref: row.external_ref,
|
||||
provider_order_id: row.provider_order_id,
|
||||
expires_at: row.expires_at,
|
||||
created_at: row.created_at,
|
||||
paid_at: row.paid_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresPaymentRepository {
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PostgresPaymentRepository {
|
||||
pub fn new(db: Arc<DatabaseConnection>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentRepository for PostgresPaymentRepository {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError> {
|
||||
let now = Utc::now();
|
||||
let model = PaymentActiveModel {
|
||||
id: Set(payment.id),
|
||||
session_id: Set(payment.session_id),
|
||||
mentee_id: Set(payment.mentee_id),
|
||||
mentor_id: Set(payment.mentor_id),
|
||||
amount: Set(payment.amount),
|
||||
service_fee: Set(payment.service_fee),
|
||||
total: Set(payment.total),
|
||||
method: Set(payment.method),
|
||||
provider: Set(payment.provider),
|
||||
status: Set(payment.status),
|
||||
external_ref: Set(payment.external_ref),
|
||||
provider_order_id: Set(payment.provider_order_id),
|
||||
paid_at: Set(payment.paid_at),
|
||||
expires_at: Set(payment.expires_at),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
let row = PaymentEntityOrm::insert(model)
|
||||
.exec_with_returning(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<PaymentEntity, AppError> {
|
||||
let row = PaymentEntityOrm::find_by_id(id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
|
||||
async fn find_by_session(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
let rows = PaymentEntityOrm::find()
|
||||
.filter(PaymentColumn::SessionId.eq(session_id))
|
||||
.all(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(map).collect())
|
||||
}
|
||||
|
||||
async fn find_by_mentee(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
let rows = PaymentEntityOrm::find()
|
||||
.filter(PaymentColumn::MenteeId.eq(mentee_id))
|
||||
.order_by_desc(PaymentColumn::CreatedAt)
|
||||
.all(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(map).collect())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
external_ref: Option<String>,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let existing = PaymentEntityOrm::find_by_id(id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?;
|
||||
let mut update: PaymentActiveModel = existing.clone().into();
|
||||
update.status = Set(status.to_string());
|
||||
if external_ref.is_some() {
|
||||
update.external_ref = Set(external_ref);
|
||||
}
|
||||
if status == "paid" {
|
||||
update.paid_at = Set(Some(Utc::now()));
|
||||
}
|
||||
update.updated_at = Set(Utc::now());
|
||||
let row = update
|
||||
.update(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use application::PaymentServiceImpl;
|
||||
pub use infrastructure::http::routes::payments_protected_routes;
|
||||
@@ -3,12 +3,17 @@ use crate::sessions::domain::{
|
||||
SessionRepository,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use imphnen_entities::seaorm::auth::mentors::{
|
||||
Column as MentorColumn, Entity as MentorsEntity,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SessionQueryService {
|
||||
pub repo: Arc<dyn SessionRepository>,
|
||||
pub db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl SessionQueryService {
|
||||
@@ -20,14 +25,20 @@ impl SessionQueryService {
|
||||
let mentor_uuid = Uuid::parse_str(&mentor_id)
|
||||
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?;
|
||||
|
||||
// Resolve mentor profile id -> user id (sessions.mentor_id FK ke app_users)
|
||||
let mentor = MentorsEntity::find_by_id(mentor_uuid)
|
||||
.one(self.db.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
|
||||
|
||||
let count = self
|
||||
.repo
|
||||
.count_by_mentor(mentor_uuid, status_filter.clone())
|
||||
.count_by_mentor(mentor.user_id, status_filter.clone())
|
||||
.await?;
|
||||
|
||||
let sessions = self
|
||||
.repo
|
||||
.find_by_mentor_id(mentor_uuid, status_filter)
|
||||
.find_by_mentor_id(mentor.user_id, status_filter)
|
||||
.await?;
|
||||
|
||||
let items: Vec<SessionListItem> = sessions
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::session_booking_service::SessionBookingService;
|
||||
use super::session_query_service::SessionQueryService;
|
||||
use crate::sessions::domain::{
|
||||
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail,
|
||||
SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionRepository,
|
||||
SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
SessionRepository, SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::AppError;
|
||||
@@ -15,12 +15,15 @@ pub struct SessionServiceImpl {
|
||||
}
|
||||
|
||||
impl SessionServiceImpl {
|
||||
pub fn new(repo: Arc<dyn SessionRepository>) -> Self {
|
||||
pub fn new(
|
||||
repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<sea_orm::DatabaseConnection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
booking: SessionBookingService {
|
||||
repo: Arc::clone(&repo),
|
||||
},
|
||||
query: SessionQueryService { repo },
|
||||
query: SessionQueryService { repo, db },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +50,34 @@ impl SessionService for SessionServiceImpl {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_mentor_stats(
|
||||
&self,
|
||||
mentor_id: String,
|
||||
) -> Result<MentorStats, AppError> {
|
||||
let list = self.query.get_mentor_sessions(mentor_id.clone(), None).await?;
|
||||
let mut mentees = std::collections::HashSet::new();
|
||||
let mut rating_sum = 0i64;
|
||||
let mut rating_count = 0i64;
|
||||
for s in &list.sessions {
|
||||
mentees.insert(s.mentee_id.clone());
|
||||
if let Some(r) = s.rating {
|
||||
rating_sum += r as i64;
|
||||
rating_count += 1;
|
||||
}
|
||||
}
|
||||
let avg = if rating_count > 0 {
|
||||
rating_sum as f64 / rating_count as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok(MentorStats {
|
||||
mentor_id,
|
||||
total_sessions: list.total as u64,
|
||||
unique_mentees: mentees.len() as u64,
|
||||
avg_rating: (avg * 10.0).round() / 10.0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_user_sessions(
|
||||
&self,
|
||||
user_id: String,
|
||||
|
||||
@@ -8,6 +8,6 @@ pub use service::SessionService;
|
||||
pub use session::SessionEntity;
|
||||
pub use session_types::{
|
||||
AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
MentorStats, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult,
|
||||
SessionList, SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,13 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<SessionEntity>, AppError>;
|
||||
|
||||
/// Resolve mentor *profile* id (app_mentors.id) to the owning user id
|
||||
/// (app_users.id) — session rows store the user id.
|
||||
async fn find_mentor_user_id(
|
||||
&self,
|
||||
profile_id: Uuid,
|
||||
) -> Result<Option<Uuid>, AppError>;
|
||||
|
||||
async fn find_by_mentor_id(
|
||||
&self,
|
||||
mentor_id: Uuid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::session_types::{
|
||||
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail,
|
||||
SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
@@ -32,6 +32,11 @@ pub trait SessionService: Send + Sync {
|
||||
mentor_id: String,
|
||||
) -> Result<MentorAvailability, AppError>;
|
||||
|
||||
async fn get_mentor_stats(
|
||||
&self,
|
||||
mentor_id: String,
|
||||
) -> Result<MentorStats, AppError>;
|
||||
|
||||
async fn update_session_status(
|
||||
&self,
|
||||
session_id: String,
|
||||
|
||||
@@ -73,6 +73,14 @@ pub struct MentorAvailability {
|
||||
pub booked_dates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MentorStats {
|
||||
pub mentor_id: String,
|
||||
pub total_sessions: u64,
|
||||
pub unique_mentees: u64,
|
||||
pub avg_rating: f64,
|
||||
}
|
||||
|
||||
pub struct UpdateSessionStatusCommand {
|
||||
pub status: String,
|
||||
pub meeting_link: Option<String>,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub use request::{
|
||||
};
|
||||
pub use response::{
|
||||
AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto,
|
||||
SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto,
|
||||
SessionListResponseDto, UpdateSessionStatusResponseDto,
|
||||
MentorStatsDto, SessionDetailDto, SessionFeedbackResponseDto,
|
||||
SessionListItemDto, SessionListResponseDto,
|
||||
UpdateSessionStatusResponseDto,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user