Compare commits

...
Author SHA1 Message Date
asepharyana 7a5c560347 docs: materi + AI agent RAG (finding #6) 2026-08-05 17:26:48 +07:00
asepharyana 164c1860da feat(dimentorin): AI agent RAG (embedding materi -> Qdrant + chat via 9router)
- ENV: AI_LLM_BASE_URL/API_KEY/MODEL, AI_EMBEDDING_MODEL (gemini-embedding-001), QDRANT_URL
- ai_agent module: chunking materi, embed_text + chat_completion (9router, SSE parse), Qdrant repo (dimentorin_materi collection, 3072d cosine)
- routes: POST /ai/chat (RAG answer + sources), POST /ai/materials/{id}/index, POST /ai/reindex
- e2e verified: reindex 3 chunks; chat 'ownership' -> materi Rust paling relevan 0.88; chat 'endpoint axum' -> materi Axum 0.82; jawaban gronding konteks
2026-08-05 17:15:11 +07:00
asepharyana ac8177d87e feat(dimentorin): materials module (CRUD konten mentoring, public+protected routes)
- app_materials table: mentor_id, title, slug, category, description, content
- domain/application/infrastructure pola articles: repo postgres, service, DTO ZodValidate
- routes: GET /materials (public, published only), GET /materials/{id|slug|categories}, POST/PUT/DELETE (auth, author-only)
- verified e2e: create 3 materi sebagai mentor, list, slug, categories
2026-08-05 17:01:59 +07:00
asepharyana b4a9972ad7 docs: payment lifecycle complete (auto-paid refresh + dashboards) 2026-08-05 15:58:47 +07:00
asepharyana 9af1c2d163 feat(dimentorin): session mentor can confirm their payments (not just admins)
- mentor can confirm payments of sessions they own (see transfer arrive)
- admin/Admin Pembayaran still allowed; others forbidden
- e2e verified: mentor confirm QRIS payment -> paid + session confirmed
2026-08-05 15:56:35 +07:00
asepharyana 495e043088 feat(dimentorin): GET /payments/session/{id} for mentee/mentor dashboard 2026-08-05 15:24:41 +07:00
asepharyana 390f46b0e7 feat(dimentorin): payment status refresh via Midtrans + auto-paid auto-confirm
- store provider_order_id (Midtrans order id) on payment create
- POST /payments/{id}/refresh: query Midtrans v2/{order}/status (settlement/capture → paid + confirm session)
- fix: status URL must end with /status; force Accept-Encoding: identity (istio gzip mangling)
- e2e verified: simulated VA payment in sandbox → refresh → paid + session confirmed
2026-08-05 15:15:43 +07:00
asepharyana aa0b659b48 docs: SMTP fixed + Midtrans provider status in audit findings 2026-08-05 13:38:02 +07:00
asepharyana 9214b32139 feat(dimentorin): Midtrans payment provider (sandbox VA + QRIS)
- Env: MIDTRANS_MERCHANT_ID / CLIENT_KEY / SERVER_KEY (masked in Debug)
- midtrans_provider: Core API v2/charge for bank_transfer (VA BCA) and qris (gopay)
- PaymentServiceImpl: when MIDTRANS_MERCHANT_ID set -> provider=midtrans, external_ref = real VA number / QR string; falls back to manual refs otherwise
- SMTP: Google app password working (send-otp 200, OTP stored)
- E2E verified (sandbox): VA externalRef=47329093597744219189188, QRIS qr_string EMVCo, confirm->paid
- Credentials stored in BWS (dimentorin_midtrans_*, dimentorin_smtp_*)
2026-08-05 13:34:59 +07:00
asepharyana 2ea6cd3d17 docs: payment flow status in audit findings 2026-08-05 09:32:37 +07:00
asepharyana 6d5af29de8 feat(dimentorin): confirm payment auto-confirms linked session
- confirm_payment now transitions linked session pending->confirmed
- e2e verified: book -> create payment -> admin confirm -> session confirmed + payment paid
2026-08-05 09:32:04 +07:00
asepharyana 444c98074f feat(dimentorin): payment module - gateway-agnostic VA/QRIS with admin confirm
- app_payments table + PaymentEntity (amount from mentor mentoring_rate + service fee 2000)
- PaymentRepository (postgres) + PaymentServiceImpl (create/get/confirm/list)
- Routes: POST /payments/sessions/{id}/create, GET /payments/me, GET /payments/{id}, POST /payments/{id}/confirm
- confirm guarded by Admin/Admin Pembayaran role; ownership guard mentee-only view
- provider=manual default (swap midtrans/xendit later), e2e verified: VA+QRIS create, confirm 200, re-confirm 409, non-admin 403, foreign payment 403
2026-08-05 09:10:39 +07:00
asepharyana c6ed5c5c19 fix(dimentorin): verify-email validates OTP before activating user
- new app_otp_cache table + OtpCache entity (ResourceEnum::OtpCache)
- PostgresOtpRepository upsert/find/delete keyed by email
- register/resend persist otp_hash+expiry after email sent (no orphan OTP)
- verify_email validates via OtpManager::validate_otp_hash, single-use delete
- 8 unit tests pass, e2e verified: wrong OTP 400, correct OTP 200
2026-08-04 23:35:34 +07:00
asepharyana 3692b81324 feat(dimentorin): mentor stats endpoint public - total sessions, unique mentees, avg rating
- GET /mentors/{id}/stats (public, no auth)
- resolve mentor profile id -> user id in get_mentor_sessions (FK uses app_users.id)
2026-08-04 23:09:08 +07:00
asepharyana 9b5efeff87 fix(dimentorin): register mentor flow - zod regex attr, uuid id override in user & mentor repos
- Remove broken zod_rs regex() attribute (always fails, blocks password validation)
- user repo create: honor entity.id instead of silently generating new v4 (broke FK mentor.user_id)
- mentor repo create: set id explicitly (was NULL not-null violation)
2026-08-04 20:03:08 +07:00
asepharyana 1d34d29b0b feat(dimentorin): complete articles module - HTTP layer, routes, categories fix, seed 2026-08-04 19:16:45 +07:00
asepharyana 7d1078f52a fix(dimentorin): resolve mentor profile id to user id before booking session 2026-08-04 18:34:13 +07:00
102 changed files with 3771 additions and 34 deletions
Generated
+1
View File
@@ -1810,6 +1810,7 @@ dependencies = [
"paginator-utils", "paginator-utils",
"rand 0.9.2", "rand 0.9.2",
"regex", "regex",
"reqwest",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",
+74
View File
@@ -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').
+5
View File
@@ -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_mentors", auth::mentors::Entity).await?;
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity) drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity)
.await?; .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, "events", common::events::Entity).await?;
drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity) drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity)
@@ -37,6 +39,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity) drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity)
.await?; .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) drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity)
.await?; .await?;
+40
View File
@@ -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
View File
@@ -19,6 +19,7 @@ regex.workspace = true
zod-rs.workspace = true zod-rs.workspace = true
zod-rs-util.workspace = true zod-rs-util.workspace = true
axum-test.workspace = true axum-test.workspace = true
reqwest.workspace = true
rand.workspace = true rand.workspace = true
tokio.workspace = true tokio.workspace = true
chrono.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;
+6
View File
@@ -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;
@@ -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())
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::{articles_public_routes, articles_protected_routes};
+8
View File
@@ -1,5 +1,13 @@
pub mod ai_agent;
pub mod articles;
pub mod materials;
pub mod mentors; pub mod mentors;
pub mod payments;
pub mod sessions; 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 mentors::{mentors_protected_routes, mentors_public_routes};
pub use payments::payments_protected_routes;
pub use sessions::{sessions_protected_routes, sessions_public_routes}; pub use sessions::{sessions_protected_routes, sessions_public_routes};
@@ -0,0 +1,135 @@
use paginator_utils::PaginatorResponse;
use chrono::Utc;
use uuid::Uuid;
use super::super::domain::{
CreateMaterialCommand, MaterialEntity, MaterialListItem,
MaterialRepository, MaterialService, UpdateMaterialCommand,
};
use crate::materials::domain::{MaterialRepository as _};
use imphnen_utils::AppError;
pub struct MaterialServiceImpl {
repo: Box<dyn MaterialRepository>,
}
impl MaterialServiceImpl {
pub fn new(repo: Box<dyn MaterialRepository>) -> Self {
Self { repo }
}
fn slugify(title: &str) -> String {
let slug: String = title
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c
} else if c.is_whitespace() {
'-'
} else {
'-'
}
})
.collect();
slug.trim_matches('-').to_string()
}
}
#[async_trait::async_trait]
impl MaterialService for MaterialServiceImpl {
async fn list_materials(
&self,
page: u64,
per_page: u64,
category: Option<String>,
published_only: bool,
) -> Result<PaginatorResponse<MaterialListItem>, AppError> {
let res = self
.repo
.find_all_paginated(page, per_page, category, published_only)
.await?;
Ok(PaginatorResponse {
data: res
.data
.into_iter()
.map(|e| MaterialListItem::from_entity(&e))
.collect(),
meta: res.meta,
})
}
async fn get_material_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError> {
self.repo.find_by_slug(slug).await
}
async fn list_categories(&self) -> Result<Vec<String>, AppError> {
self.repo.find_categories().await
}
async fn create_material(
&self,
cmd: CreateMaterialCommand,
) -> Result<MaterialListItem, AppError> {
let now = Utc::now();
let entity = MaterialEntity {
id: Uuid::new_v4(),
mentor_id: cmd.mentor_id,
title: cmd.title.clone(),
slug: format!("{}-{}", Self::slugify(&cmd.title), Uuid::new_v4().to_string()[..8].to_string()),
category: cmd.category,
description: cmd.description,
content: cmd.content,
cover_url: cmd.cover_url,
is_published: true,
created_at: now,
updated_at: now,
};
self.repo.create(entity.clone()).await?;
Ok(MaterialListItem::from_entity(&entity))
}
async fn update_material(
&self,
id: Uuid,
actor_id: Uuid,
cmd: UpdateMaterialCommand,
) -> Result<MaterialListItem, AppError> {
let existing = self.repo.find_by_id(id).await?;
if existing.mentor_id != actor_id {
return Err(AppError::ForbiddenError(
"Only the author can update this material".into(),
));
}
// do not change mentor on update
let entity = MaterialEntity {
id,
mentor_id: existing.mentor_id,
title: cmd.title.unwrap_or(existing.title),
slug: existing.slug,
category: cmd.category.unwrap_or(existing.category),
description: cmd.description.unwrap_or(existing.description),
content: cmd.content.unwrap_or(existing.content),
cover_url: cmd.cover_url.or(existing.cover_url),
is_published: cmd.is_published.unwrap_or(existing.is_published),
created_at: existing.created_at,
updated_at: Utc::now(),
};
self.repo.update(id, entity.clone()).await?;
Ok(MaterialListItem::from_entity(&entity))
}
async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError> {
let existing = self.repo.find_by_id(id).await?;
if existing.mentor_id != actor_id {
return Err(AppError::ForbiddenError(
"Only the author can delete this material".into(),
));
}
self.repo.delete(id).await
}
}
@@ -0,0 +1,3 @@
pub mod material_service;
pub use material_service::MaterialServiceImpl;
@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct MaterialEntity {
pub id: Uuid,
pub mentor_id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub description: String,
pub content: String,
pub cover_url: Option<String>,
pub is_published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,55 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct CreateMaterialCommand {
pub mentor_id: Uuid,
pub title: String,
pub category: String,
pub description: String,
pub content: String,
pub cover_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UpdateMaterialCommand {
pub title: Option<String>,
pub category: Option<String>,
pub description: Option<String>,
pub content: Option<String>,
pub cover_url: Option<String>,
pub is_published: Option<bool>,
}
#[derive(Clone, Debug)]
pub struct MaterialListItem {
pub id: Uuid,
pub mentor_id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub description: String,
pub cover_url: Option<String>,
pub is_published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl MaterialListItem {
pub fn from_entity(e: &MaterialEntity) -> Self {
Self {
id: e.id,
mentor_id: e.mentor_id,
title: e.title.clone(),
slug: e.slug.clone(),
category: e.category.clone(),
description: e.description.clone(),
cover_url: e.cover_url.clone(),
is_published: e.is_published,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}
pub use crate::materials::domain::material::MaterialEntity;
@@ -0,0 +1,11 @@
pub mod material;
pub mod material_types;
pub mod repository;
pub mod service;
pub use material::MaterialEntity;
pub use material_types::{
CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand,
};
pub use repository::MaterialRepository;
pub use service::MaterialService;
@@ -0,0 +1,23 @@
use async_trait::async_trait;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use super::material::MaterialEntity;
use imphnen_utils::AppError;
#[async_trait]
pub trait MaterialRepository: Send + Sync {
async fn find_all_paginated(
&self,
page: u64,
per_page: u64,
category: Option<String>,
published_only: bool,
) -> Result<PaginatorResponse<MaterialEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
async fn find_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
async fn find_categories(&self) -> Result<Vec<String>, AppError>;
async fn create(&self, entity: MaterialEntity) -> Result<Uuid, AppError>;
async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,34 @@
use async_trait::async_trait;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use super::material::MaterialEntity;
use super::material_types::{
CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand,
};
use imphnen_utils::AppError;
#[async_trait]
pub trait MaterialService: Send + Sync {
async fn list_materials(
&self,
page: u64,
per_page: u64,
category: Option<String>,
published_only: bool,
) -> Result<PaginatorResponse<MaterialListItem>, AppError>;
async fn get_material_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
async fn list_categories(&self) -> Result<Vec<String>, AppError>;
async fn create_material(
&self,
cmd: CreateMaterialCommand,
) -> Result<MaterialListItem, AppError>;
async fn update_material(
&self,
id: Uuid,
actor_id: Uuid,
cmd: UpdateMaterialCommand,
) -> Result<MaterialListItem, AppError>;
async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,137 @@
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use zod_rs::prelude::*;
use crate::materials::domain::{MaterialEntity, MaterialListItem};
use imphnen_libs::ZodValidate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateMaterialRequest {
#[zod(min_length(3), max_length(200))]
pub title: String,
#[zod(min_length(1), max_length(100))]
pub category: String,
#[zod(min_length(3), max_length(500))]
pub description: String,
#[zod(min_length(10))]
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_url: Option<String>,
}
impl ZodValidate for CreateMaterialRequest {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMaterialRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_published: Option<bool>,
}
impl ZodValidate for UpdateMaterialRequest {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Serialize, Debug, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MaterialResponse {
pub id: Uuid,
pub mentor_id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub description: String,
pub content: String,
pub cover_url: Option<String>,
pub is_published: bool,
pub created_at: String,
pub updated_at: String,
}
impl MaterialResponse {
pub fn from_entity(e: &MaterialEntity) -> Self {
Self {
id: e.id,
mentor_id: e.mentor_id,
title: e.title.clone(),
slug: e.slug.clone(),
category: e.category.clone(),
description: e.description.clone(),
content: e.content.clone(),
cover_url: e.cover_url.clone(),
is_published: e.is_published,
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
}
}
}
#[derive(Serialize, Debug, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MaterialListItemResponse {
pub id: Uuid,
pub mentor_id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub description: String,
pub cover_url: Option<String>,
pub is_published: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<&MaterialEntity> for MaterialListItemResponse {
fn from(e: &MaterialEntity) -> Self {
Self {
id: e.id,
mentor_id: e.mentor_id,
title: e.title.clone(),
slug: e.slug.clone(),
category: e.category.clone(),
description: e.description.clone(),
cover_url: e.cover_url.clone(),
is_published: e.is_published,
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
}
}
}
impl From<&MaterialListItem> for MaterialListItemResponse {
fn from(e: &MaterialListItem) -> Self {
Self {
id: e.id,
mentor_id: e.mentor_id,
title: e.title.clone(),
slug: e.slug.clone(),
category: e.category.clone(),
description: e.description.clone(),
cover_url: e.cover_url.clone(),
is_published: e.is_published,
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
}
}
}
pub type MaterialListResponse = PaginatorResponse<MaterialListItemResponse>;
@@ -0,0 +1,130 @@
use super::dto::{
CreateMaterialRequest, MaterialListItemResponse, MaterialResponse,
UpdateMaterialRequest,
};
use crate::materials::domain::{
CreateMaterialCommand, MaterialService, UpdateMaterialCommand,
};
use axum::{
Extension, extract::{Path, Query},
http::{HeaderMap, header::AUTHORIZATION},
response::IntoResponse,
};
use imphnen_libs::{ValidatedJson, decode_access_token};
use imphnen_utils::{ApiSuccess, AppError};
use paginator_utils::PaginatorResponse;
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
fn extract_user_id(headers: &HeaderMap) -> Result<Uuid, AppError> {
let token = headers
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let claims = decode_access_token(token)
.map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))?;
Uuid::parse_str(&claims.claims.user_id)
.map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))
}
pub async fn get_materials_list(
Extension(service): Extension<Arc<dyn MaterialService>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, AppError> {
let page: u64 = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
let per_page: u64 = params
.get("per_page")
.and_then(|p| p.parse().ok())
.unwrap_or(10);
let category = params.get("category").cloned().filter(|c| !c.is_empty());
// public listing always shows published only
let res = service
.list_materials(page, per_page, category, true)
.await?;
let data: Vec<MaterialListItemResponse> =
res.data.iter().map(|e| e.into()).collect();
Ok(ApiSuccess(PaginatorResponse {
data,
meta: res.meta,
}))
}
pub async fn get_material_categories(
Extension(service): Extension<Arc<dyn MaterialService>>,
) -> Result<impl IntoResponse, AppError> {
let categories = service.list_categories().await?;
Ok(ApiSuccess(categories))
}
pub async fn get_material_by_slug(
Extension(service): Extension<Arc<dyn MaterialService>>,
Path(slug): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let material = service.get_material_by_slug(&slug).await?;
if !material.is_published {
return Err(AppError::NotFoundError("Material not found".into()));
}
Ok(ApiSuccess(MaterialResponse::from_entity(&material)))
}
pub async fn get_material_by_id(
Extension(service): Extension<Arc<dyn MaterialService>>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let material = service.get_material_by_id(id).await?;
if !material.is_published {
return Err(AppError::NotFoundError("Material not found".into()));
}
Ok(ApiSuccess(MaterialResponse::from_entity(&material)))
}
pub async fn post_create_material(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn MaterialService>>,
ValidatedJson(body): ValidatedJson<CreateMaterialRequest>,
) -> Result<impl IntoResponse, AppError> {
let user_id = extract_user_id(&headers)?;
let cmd = CreateMaterialCommand {
mentor_id: user_id,
title: body.title,
category: body.category,
description: body.description,
content: body.content,
cover_url: body.cover_url,
};
let material = service.create_material(cmd).await?;
Ok(ApiSuccess(MaterialListItemResponse::from(&material)))
}
pub async fn put_update_material(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn MaterialService>>,
Path(id): Path<Uuid>,
ValidatedJson(body): ValidatedJson<UpdateMaterialRequest>,
) -> Result<impl IntoResponse, AppError> {
let user_id = extract_user_id(&headers)?;
let cmd = UpdateMaterialCommand {
title: body.title,
category: body.category,
description: body.description,
content: body.content,
cover_url: body.cover_url,
is_published: body.is_published,
};
let material = service.update_material(id, user_id, cmd).await?;
Ok(ApiSuccess(MaterialListItemResponse::from(&material)))
}
pub async fn delete_material(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn MaterialService>>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let user_id = extract_user_id(&headers)?;
service.delete_material(id, user_id).await?;
Ok(ApiSuccess(serde_json::json!({
"message": format!("Material {} deleted", id)
})))
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,41 @@
use super::handlers::{
delete_material, get_material_by_id, get_material_by_slug, get_material_categories,
get_materials_list, post_create_material, put_update_material,
};
use crate::materials::application::MaterialServiceImpl;
use crate::materials::domain::MaterialService;
use crate::materials::infrastructure::persistence::PostgresMaterialRepository;
use axum::{
Extension, Router, routing::{delete, get, post, put},
};
use imphnen_libs::AppState;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
fn build_service(db: DatabaseConnection) -> Arc<dyn MaterialService> {
let repo = Box::new(PostgresMaterialRepository::new(db));
Arc::new(MaterialServiceImpl::new(repo))
}
pub fn materials_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/materials", get(get_materials_list))
.route("/materials/categories", get(get_material_categories))
.route("/materials/slug/{slug}", get(get_material_by_slug))
.route("/materials/{id}", get(get_material_by_id))
.layer(Extension(service))
}
pub fn materials_protected_routes(
db: DatabaseConnection,
state: Arc<AppState>,
) -> Router {
let service = build_service(db);
Router::new()
.route("/materials", post(post_create_material))
.route("/materials/{id}", put(put_update_material))
.route("/materials/{id}", delete(delete_material))
.layer(Extension(service))
.layer(Extension((*state).clone()))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_material_repository;
pub use postgres_material_repository::PostgresMaterialRepository;
@@ -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(())
}
}
+7
View File
@@ -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 { pub struct MentorUserRegisterRequestDto {
#[zod(email, min_length(1))] #[zod(email, min_length(1))]
pub email: String, pub email: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] #[zod(min_length(8))]
pub password: String, pub password: String,
#[zod(min_length(2))] #[zod(min_length(2))]
pub fullname: String, pub fullname: String,
@@ -105,6 +105,7 @@ impl MentorRepository for PostgresMentorRepository {
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> { async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> {
let active_model = MentorActiveModel { let active_model = MentorActiveModel {
id: ActiveValue::Set(Uuid::new_v4()),
user_id: ActiveValue::Set(entity.user_id), user_id: ActiveValue::Set(entity.user_id),
industries: ActiveValue::Set(Some( industries: ActiveValue::Set(Some(
serde_json::to_value(&entity.industries) 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;
@@ -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))
}
}
+6
View File
@@ -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, SessionRepository,
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use imphnen_entities::seaorm::auth::mentors::{
Column as MentorColumn, Entity as MentorsEntity,
};
use imphnen_utils::AppError; use imphnen_utils::AppError;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
pub struct SessionQueryService { pub struct SessionQueryService {
pub repo: Arc<dyn SessionRepository>, pub repo: Arc<dyn SessionRepository>,
pub db: Arc<DatabaseConnection>,
} }
impl SessionQueryService { impl SessionQueryService {
@@ -20,14 +25,20 @@ impl SessionQueryService {
let mentor_uuid = Uuid::parse_str(&mentor_id) let mentor_uuid = Uuid::parse_str(&mentor_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; .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 let count = self
.repo .repo
.count_by_mentor(mentor_uuid, status_filter.clone()) .count_by_mentor(mentor.user_id, status_filter.clone())
.await?; .await?;
let sessions = self let sessions = self
.repo .repo
.find_by_mentor_id(mentor_uuid, status_filter) .find_by_mentor_id(mentor.user_id, status_filter)
.await?; .await?;
let items: Vec<SessionListItem> = sessions let items: Vec<SessionListItem> = sessions
@@ -1,9 +1,9 @@
use super::session_booking_service::SessionBookingService; use super::session_booking_service::SessionBookingService;
use super::session_query_service::SessionQueryService; use super::session_query_service::SessionQueryService;
use crate::sessions::domain::{ use crate::sessions::domain::{
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionRepository, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus, SessionRepository, SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use imphnen_utils::AppError; use imphnen_utils::AppError;
@@ -15,12 +15,15 @@ pub struct SessionServiceImpl {
} }
impl 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 { Self {
booking: SessionBookingService { booking: SessionBookingService {
repo: Arc::clone(&repo), repo: Arc::clone(&repo),
}, },
query: SessionQueryService { repo }, query: SessionQueryService { repo, db },
} }
} }
} }
@@ -47,6 +50,34 @@ impl SessionService for SessionServiceImpl {
.await .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( async fn get_user_sessions(
&self, &self,
user_id: String, user_id: String,
@@ -8,6 +8,6 @@ pub use service::SessionService;
pub use session::SessionEntity; pub use session::SessionEntity;
pub use session_types::{ pub use session_types::{
AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability, AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability,
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList, MentorStats, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult,
SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus, 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>; 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( async fn find_by_mentor_id(
&self, &self,
mentor_id: Uuid, mentor_id: Uuid,
@@ -1,6 +1,6 @@
use super::session_types::{ use super::session_types::{
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
UpdateSessionStatusCommand, UpdatedSessionStatus, UpdateSessionStatusCommand, UpdatedSessionStatus,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -32,6 +32,11 @@ pub trait SessionService: Send + Sync {
mentor_id: String, mentor_id: String,
) -> Result<MentorAvailability, AppError>; ) -> Result<MentorAvailability, AppError>;
async fn get_mentor_stats(
&self,
mentor_id: String,
) -> Result<MentorStats, AppError>;
async fn update_session_status( async fn update_session_status(
&self, &self,
session_id: String, session_id: String,
@@ -73,6 +73,14 @@ pub struct MentorAvailability {
pub booked_dates: Vec<String>, 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 struct UpdateSessionStatusCommand {
pub status: String, pub status: String,
pub meeting_link: Option<String>, pub meeting_link: Option<String>,
@@ -6,6 +6,7 @@ pub use request::{
}; };
pub use response::{ pub use response::{
AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto, AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto, MentorStatsDto, SessionDetailDto, SessionFeedbackResponseDto,
SessionListResponseDto, UpdateSessionStatusResponseDto, SessionListItemDto, SessionListResponseDto,
UpdateSessionStatusResponseDto,
}; };
@@ -1,5 +1,5 @@
use crate::sessions::domain::{ use crate::sessions::domain::{
AvailabilitySlot, BookedSession, MentorAvailability, SessionDetail, AvailabilitySlot, BookedSession, MentorAvailability, MentorStats, SessionDetail,
SessionFeedbackResult, SessionList, SessionListItem, UpdatedSessionStatus, SessionFeedbackResult, SessionList, SessionListItem, UpdatedSessionStatus,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -173,6 +173,25 @@ impl From<MentorAvailability> for MentorAvailabilityDto {
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorStatsDto {
pub mentor_id: String,
pub total_sessions: u64,
pub unique_mentees: u64,
pub avg_rating: f64,
}
impl From<MentorStats> for MentorStatsDto {
fn from(s: MentorStats) -> Self {
Self {
mentor_id: s.mentor_id,
total_sessions: s.total_sessions,
unique_mentees: s.unique_mentees,
avg_rating: s.avg_rating,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateSessionStatusResponseDto { pub struct UpdateSessionStatusResponseDto {
pub id: String, pub id: String,
@@ -5,5 +5,5 @@ pub use mutation_handlers::{
post_book_session, post_submit_feedback, put_update_session_status, post_book_session, post_submit_feedback, put_update_session_status,
}; };
pub use query_handlers::{ pub use query_handlers::{
get_mentor_availability, get_mentor_sessions, get_my_sessions, get_mentor_availability, get_mentor_sessions, get_mentor_stats, get_my_sessions,
}; };
@@ -12,7 +12,13 @@ use axum::{
use imphnen_libs::{ValidatedJson, decode_access_token}; use imphnen_libs::{ValidatedJson, decode_access_token};
use imphnen_utils::AppError; use imphnen_utils::AppError;
use imphnen_utils::ApiSuccess; use imphnen_utils::ApiSuccess;
use imphnen_entities::seaorm::auth::mentors::{
Column as MentorColumn, Entity as MentorsEntity,
};
use imphnen_libs::AppState;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid;
fn extract_user_id(headers: &HeaderMap) -> Result<String, AppError> { fn extract_user_id(headers: &HeaderMap) -> Result<String, AppError> {
let token = headers let token = headers
@@ -43,14 +49,23 @@ fn extract_user_id(headers: &HeaderMap) -> Result<String, AppError> {
)] )]
pub async fn post_book_session( pub async fn post_book_session(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn SessionService>>, Extension(service): Extension<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>, Path(mentor_id): Path<String>,
ValidatedJson(dto): ValidatedJson<BookSessionRequestDto>, ValidatedJson(dto): ValidatedJson<BookSessionRequestDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = extract_user_id(&headers)?; let user_id = extract_user_id(&headers)?;
let mentor_uuid = Uuid::parse_str(&mentor_id).map_err(|_| {
AppError::BadRequestError("Invalid mentor ID format".to_string())
})?;
// Resolve mentor profile id -> user id (sessions.mentor_id FK ke app_users)
let mentor = MentorsEntity::find_by_id(mentor_uuid)
.one(&state.postgres_connection.conn)
.await?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
let resp = BookSessionResponseDto::from( let resp = BookSessionResponseDto::from(
service service
.book_session(mentor_id, user_id, dto.into()) .book_session(mentor.user_id.to_string(), user_id, dto.into())
.await?, .await?,
); );
Ok(ApiSuccess(resp)) Ok(ApiSuccess(resp))
@@ -1,4 +1,4 @@
use super::super::dto::{MentorAvailabilityDto, SessionListResponseDto}; use super::super::dto::{MentorAvailabilityDto, MentorStatsDto, SessionListResponseDto};
use crate::sessions::domain::SessionService; use crate::sessions::domain::SessionService;
use axum::{ use axum::{
extract::{Extension, Path, Query}, extract::{Extension, Path, Query},
@@ -68,6 +68,25 @@ pub async fn get_mentor_availability(
Ok(ApiSuccess(resp)) Ok(ApiSuccess(resp))
} }
#[utoipa::path(
get,
path = "/v1/dimentorin/mentors/{id}/stats",
tag = "sessions",
params(
("id" = String, Path, description = "Mentor id"),
),
responses(
(status = 200, description = "Mentor stats retrieved successfully", body = MentorStatsDto),
)
)]
pub async fn get_mentor_stats(
Extension(service): Extension<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let resp = MentorStatsDto::from(service.get_mentor_stats(mentor_id).await?);
Ok(ApiSuccess(resp))
}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/v1/dimentorin/sessions/me", path = "/v1/dimentorin/sessions/me",
@@ -1,6 +1,7 @@
use super::handlers::{ use super::handlers::{
get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session, get_mentor_availability, get_mentor_sessions, get_mentor_stats,
post_submit_feedback, put_update_session_status, get_my_sessions, post_book_session, post_submit_feedback,
put_update_session_status,
}; };
use crate::sessions::application::SessionServiceImpl; use crate::sessions::application::SessionServiceImpl;
use crate::sessions::domain::SessionService; use crate::sessions::domain::SessionService;
@@ -14,14 +15,16 @@ use sea_orm::DatabaseConnection;
use std::sync::Arc; use std::sync::Arc;
fn build_service(db: DatabaseConnection) -> Arc<dyn SessionService> { fn build_service(db: DatabaseConnection) -> Arc<dyn SessionService> {
let repo = Arc::new(PostgresSessionRepository::new(db)); let db_arc = Arc::new(db);
Arc::new(SessionServiceImpl::new(repo)) let repo = Arc::new(PostgresSessionRepository::new(Arc::clone(&db_arc)));
Arc::new(SessionServiceImpl::new(repo, db_arc))
} }
pub fn sessions_public_routes(db: DatabaseConnection) -> Router { pub fn sessions_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db); let service = build_service(db);
Router::new() Router::new()
.route("/mentors/{id}/availability", get(get_mentor_availability)) .route("/mentors/{id}/availability", get(get_mentor_availability))
.route("/mentors/{id}/stats", get(get_mentor_stats))
.layer(Extension(service)) .layer(Extension(service))
} }
@@ -39,8 +39,8 @@ pub struct PostgresSessionRepository {
} }
impl PostgresSessionRepository { impl PostgresSessionRepository {
pub fn new(db: DatabaseConnection) -> Self { pub fn new(db: Arc<DatabaseConnection>) -> Self {
Self { db: Arc::new(db) } Self { db }
} }
} }
@@ -82,6 +82,22 @@ impl SessionRepository for PostgresSessionRepository {
Ok(model.map(model_to_entity)) Ok(model.map(model_to_entity))
} }
async fn find_mentor_user_id(
&self,
profile_id: Uuid,
) -> Result<Option<Uuid>, AppError> {
use imphnen_entities::seaorm::auth::mentors::{
Column as MentorColumn, Entity as MentorsEntity,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
let model = MentorsEntity::find()
.filter(MentorColumn::Id.eq(profile_id))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(model.map(|m| m.user_id))
}
async fn find_by_mentor_id( async fn find_by_mentor_id(
&self, &self,
mentor_id: Uuid, mentor_id: Uuid,
@@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "app_articles")]
pub struct Model {
#[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)]
pub id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub excerpt: String,
pub content: String,
#[sea_orm(nullable)]
pub cover_url: Option<String>,
#[sea_orm(nullable)]
pub author_name: Option<String>,
pub is_published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "app_materials")]
pub struct Model {
#[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)]
pub id: Uuid,
#[sea_orm(indexed)]
pub mentor_id: Uuid,
pub title: String,
pub slug: String,
pub category: String,
pub description: String,
pub content: String,
#[sea_orm(nullable)]
pub cover_url: Option<String>,
pub is_published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -1,7 +1,11 @@
pub mod articles;
pub mod audit_log; pub mod audit_log;
pub mod enum_impls; pub mod enum_impls;
pub mod enums; pub mod enums;
pub mod events; pub mod events;
pub mod materials;
pub mod otp_cache;
pub mod payments;
pub mod rate_limit; pub mod rate_limit;
pub mod roadmap_items; pub mod roadmap_items;
pub mod testimonials; pub mod testimonials;
@@ -0,0 +1,31 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "app_otp_cache")]
pub struct Model {
#[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique, not_null)]
pub email: String,
#[sea_orm(not_null)]
pub otp_hash: String,
#[sea_orm(not_null)]
pub expires_at: DateTime<Utc>,
#[sea_orm(not_null, default = "now()")]
pub created_at: DateTime<Utc>,
#[sea_orm(not_null, default = "now()")]
pub updated_at: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,66 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "app_payments")]
pub struct Model {
#[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)]
pub id: Uuid,
#[sea_orm(column_type = "Uuid")]
pub session_id: Uuid,
#[sea_orm(column_type = "Uuid")]
pub mentee_id: Uuid,
#[sea_orm(column_type = "Uuid")]
pub mentor_id: Uuid,
#[sea_orm(column_type = "BigInteger", default = 0)]
pub amount: i64,
#[sea_orm(column_type = "BigInteger", default = 0)]
pub service_fee: i64,
#[sea_orm(column_type = "BigInteger", default = 0)]
pub total: i64,
// payment method: "va" | "qris" | "manual"
#[sea_orm(default = "manual")]
pub method: String,
// payment provider: "manual" | "midtrans" | "xendit" (swap later)
#[sea_orm(default = "manual")]
pub provider: String,
// status: "pending" | "paid" | "expired" | "cancelled"
#[sea_orm(default = "pending")]
pub status: String,
// provider reference: VA number / QR string / external transaction id
#[sea_orm(nullable)]
pub external_ref: Option<String>,
// provider order id (Midtrans order_id, e.g. "DM-<uuid>") used to query status
#[sea_orm(nullable)]
pub provider_order_id: Option<String>,
#[sea_orm(nullable)]
pub paid_at: Option<DateTime<Utc>>,
#[sea_orm(not_null)]
pub expires_at: DateTime<Utc>,
#[sea_orm(not_null, default = "now()")]
pub created_at: DateTime<Utc>,
#[sea_orm(not_null, default = "now()")]
pub updated_at: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+15 -1
View File
@@ -6,7 +6,9 @@ use imphnen_cms::{
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes, roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
}; };
use imphnen_dimentorin::{ use imphnen_dimentorin::{
mentors_protected_routes, mentors_public_routes, sessions_protected_routes, ai_agent_routes, articles_protected_routes, articles_public_routes,
materials_protected_routes, materials_public_routes, mentors_protected_routes,
mentors_public_routes, payments_protected_routes, sessions_protected_routes,
sessions_public_routes, sessions_public_routes,
}; };
use imphnen_gacha::gacha_router; use imphnen_gacha::gacha_router;
@@ -74,10 +76,22 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router {
let dimentorin_routes = Router::new() let dimentorin_routes = Router::new()
.merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc))) .merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc)))
.merge(sessions_public_routes(db.clone())) .merge(sessions_public_routes(db.clone()))
.merge(articles_public_routes(db.clone()))
.merge(materials_public_routes(db.clone()))
.merge(ai_agent_routes(db.clone(), Arc::clone(&state_arc)))
.merge( .merge(
Router::new() Router::new()
.merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc)))
.merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc))) .merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc)))
.merge(payments_protected_routes(db.clone(), Arc::clone(&state_arc)))
.merge(articles_protected_routes(
db.clone(),
Arc::clone(&state_arc),
))
.merge(materials_protected_routes(
db.clone(),
Arc::clone(&state_arc),
))
.layer(from_fn(auth_middleware)), .layer(from_fn(auth_middleware)),
); );
+44
View File
@@ -1,3 +1,4 @@
use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
use crate::auth::domain::AuthService; use crate::auth::domain::AuthService;
use crate::auth::domain::types::{ use crate::auth::domain::types::{
AuthTokens, AuthUserDetail, LoginInput, LoginOutput, NewPasswordInput, AuthTokens, AuthUserDetail, LoginInput, LoginOutput, NewPasswordInput,
@@ -22,16 +23,19 @@ use uuid::Uuid;
pub struct AuthServiceImpl { pub struct AuthServiceImpl {
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>, role_repo: Arc<dyn RoleRepository>,
otp_repo: Arc<dyn OtpRepository>,
} }
impl AuthServiceImpl { impl AuthServiceImpl {
pub fn new( pub fn new(
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>, role_repo: Arc<dyn RoleRepository>,
otp_repo: Arc<dyn OtpRepository>,
) -> Self { ) -> Self {
Self { Self {
user_repo, user_repo,
role_repo, role_repo,
otp_repo,
} }
} }
} }
@@ -187,6 +191,16 @@ impl AuthService for AuthServiceImpl {
..Default::default() ..Default::default()
}) })
.await?; .await?;
// Persist OTP only after user creation succeeded, so a failed
// registration leaves no orphan OTP record behind.
self
.otp_repo
.save(OtpCacheRecord {
email: payload.email.clone(),
otp_hash: otp.hash.clone(),
expires_at: otp.expires_at,
})
.await?;
Ok(()) Ok(())
} }
@@ -203,6 +217,16 @@ impl AuthService for AuthServiceImpl {
&format!("Your OTP code is {}", otp.code), &format!("Your OTP code is {}", otp.code),
) )
.map_err(|e| AppError::BadRequestError(e.to_string()))?; .map_err(|e| AppError::BadRequestError(e.to_string()))?;
// Overwrite stored OTP only after the email was actually sent, so a
// failed resend never invalidates the previous (still valid) code.
self
.otp_repo
.save(OtpCacheRecord {
email: payload.email.clone(),
otp_hash: otp.hash.clone(),
expires_at: otp.expires_at,
})
.await?;
Ok(()) Ok(())
} }
@@ -265,6 +289,24 @@ impl AuthService for AuthServiceImpl {
if user.is_active { if user.is_active {
return Err(AppError::BadRequestError("User already active".into())); return Err(AppError::BadRequestError("User already active".into()));
} }
let stored = self
.otp_repo
.find_by_email(&payload.email)
.await
.map_err(|_| {
AppError::BadRequestError(
"No OTP issued for this email, request a new code".into(),
)
})?;
if !OtpManager::validate_otp_hash(
&stored.otp_hash,
&stored.expires_at,
payload.otp,
) {
return Err(AppError::BadRequestError(
"Invalid or expired OTP".into(),
));
}
self self
.user_repo .user_repo
.update(UserEntity { .update(UserEntity {
@@ -272,6 +314,8 @@ impl AuthService for AuthServiceImpl {
..user ..user
}) })
.await?; .await?;
// OTP is single-use — consume it on successful verification.
self.otp_repo.delete_by_email(&payload.email).await?;
Ok(()) Ok(())
} }
+1
View File
@@ -1,3 +1,4 @@
pub mod otp;
pub mod types; pub mod types;
use async_trait::async_trait; use async_trait::async_trait;
+18
View File
@@ -0,0 +1,18 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::AppError;
#[derive(Clone, Debug)]
pub struct OtpCacheRecord {
pub email: String,
pub otp_hash: String,
pub expires_at: DateTime<Utc>,
}
#[async_trait]
pub trait OtpRepository: Send + Sync {
/// Upsert OTP record keyed by email (one active OTP per email).
async fn save(&self, record: OtpCacheRecord) -> Result<(), AppError>;
async fn find_by_email(&self, email: &str) -> Result<OtpCacheRecord, AppError>;
async fn delete_by_email(&self, email: &str) -> Result<(), AppError>;
}
@@ -34,7 +34,7 @@ pub struct AuthLoginResponsetDto {
pub struct AuthRegisterRequestDto { pub struct AuthRegisterRequestDto {
#[zod(email, min_length(1))] #[zod(email, min_length(1))]
pub email: String, pub email: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] #[zod(min_length(8))]
pub password: String, pub password: String,
#[zod(min_length(2))] #[zod(min_length(2))]
pub fullname: String, pub fullname: String,
@@ -89,7 +89,7 @@ impl ZodValidate for AuthRefreshTokenRequestDto {
pub struct AuthNewPasswordRequestDto { pub struct AuthNewPasswordRequestDto {
#[zod(min_length(1))] #[zod(min_length(1))]
pub token: String, pub token: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] #[zod(min_length(8))]
pub password: String, pub password: String,
} }
@@ -4,6 +4,7 @@ use super::handlers::{
}; };
use crate::auth::application::AuthServiceImpl; use crate::auth::application::AuthServiceImpl;
use crate::auth::domain::AuthService; use crate::auth::domain::AuthService;
use crate::auth::infrastructure::PostgresOtpRepository;
use crate::roles::infrastructure::persistence::PostgresRoleRepository; use crate::roles::infrastructure::persistence::PostgresRoleRepository;
use crate::users::infrastructure::persistence::PostgresUserRepository; use crate::users::infrastructure::persistence::PostgresUserRepository;
use axum::{Extension, Router, routing::post}; use axum::{Extension, Router, routing::post};
@@ -18,8 +19,11 @@ pub fn auth_public_routes(_db: DatabaseConnection, state: Arc<AppState>) -> Rout
let role_repo = Arc::new(PostgresRoleRepository::new( let role_repo = Arc::new(PostgresRoleRepository::new(
state.postgres_connection.conn.clone(), state.postgres_connection.conn.clone(),
)); ));
let otp_repo = Arc::new(PostgresOtpRepository::new(
state.postgres_connection.conn.clone(),
));
let auth_service: Arc<dyn AuthService> = let auth_service: Arc<dyn AuthService> =
Arc::new(AuthServiceImpl::new(user_repo, role_repo)); Arc::new(AuthServiceImpl::new(user_repo, role_repo, otp_repo));
Router::new() Router::new()
.route("/auth/login", post(post_login)) .route("/auth/login", post(post_login))
.route("/auth/login-mentor", post(post_login_mentor)) .route("/auth/login-mentor", post(post_login_mentor))
@@ -1,2 +1,4 @@
pub mod http; pub mod http;
pub mod persistence; pub mod persistence;
pub use persistence::postgres_otp_repository::PostgresOtpRepository;
@@ -1 +1 @@
pub mod postgres_otp_repository;
@@ -0,0 +1,84 @@
use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
use async_trait::async_trait;
use chrono::Utc;
use imphnen_entities::seaorm::common::otp_cache::{
ActiveModel as OtpCacheActiveModel, Column as OtpCacheColumn, Entity as OtpCacheEntity,
};
use imphnen_utils::AppError;
use sea_orm::ActiveValue::Set;
use sea_orm::prelude::*;
use std::sync::Arc;
pub struct PostgresOtpRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresOtpRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl OtpRepository for PostgresOtpRepository {
async fn save(&self, record: OtpCacheRecord) -> Result<(), AppError> {
let now = Utc::now();
let otp_hash = record.otp_hash.clone();
let expires_at = record.expires_at;
let active_model = OtpCacheActiveModel {
id: Set(uuid::Uuid::new_v4()),
email: Set(record.email.clone()),
otp_hash: Set(otp_hash.clone()),
expires_at: Set(expires_at),
created_at: Set(now),
updated_at: Set(now),
};
// Upsert: replace any existing (possibly expired) OTP for the same email.
let existing = OtpCacheEntity::find()
.filter(OtpCacheColumn::Email.eq(record.email.clone()))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if let Some(existing) = existing {
let mut update: OtpCacheActiveModel = existing.into();
update.otp_hash = Set(otp_hash);
update.expires_at = Set(expires_at);
update.updated_at = Set(now);
update
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
} else {
OtpCacheEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
}
Ok(())
}
async fn find_by_email(&self, email: &str) -> Result<OtpCacheRecord, AppError> {
let row = OtpCacheEntity::find()
.filter(OtpCacheColumn::Email.eq(email))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("No OTP issued for this email".into()))?;
Ok(OtpCacheRecord {
email: row.email,
otp_hash: row.otp_hash,
expires_at: row.expires_at,
})
}
async fn delete_by_email(&self, email: &str) -> Result<(), AppError> {
OtpCacheEntity::delete_many()
.filter(OtpCacheColumn::Email.eq(email))
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
@@ -29,7 +29,7 @@ pub struct UsersSetNewPasswordRequestDto {
pub struct UsersCreateRequestDto { pub struct UsersCreateRequestDto {
#[zod(email, min_length(1))] #[zod(email, min_length(1))]
pub email: String, pub email: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] #[zod(min_length(8))]
pub password: String, pub password: String,
#[zod(min_length(2))] #[zod(min_length(2))]
pub fullname: String, pub fullname: String,
@@ -107,7 +107,9 @@ impl UserRepository for PostgresUserRepository {
.ok() .ok()
.or_else(|| entity.role.id.is_empty().then_some(Uuid::nil())); .or_else(|| entity.role.id.is_empty().then_some(Uuid::nil()));
let active_model = UserActiveModel { let active_model = UserActiveModel {
id: ActiveValue::Set(Uuid::new_v4()), id: ActiveValue::Set(
Uuid::parse_str(&entity.id).unwrap_or_else(|_| Uuid::new_v4()),
),
email: ActiveValue::Set(entity.email.clone()), email: ActiveValue::Set(entity.email.clone()),
password_hash: ActiveValue::Set(entity.password), password_hash: ActiveValue::Set(entity.password),
username: ActiveValue::Set(entity.email.clone()), username: ActiveValue::Set(entity.email.clone()),

Some files were not shown because too many files have changed in this diff Show More