Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5c560347 | ||
|
|
164c1860da | ||
|
|
ac8177d87e | ||
|
|
b4a9972ad7 | ||
|
|
9af1c2d163 | ||
|
|
495e043088 | ||
|
|
390f46b0e7 | ||
|
|
aa0b659b48 | ||
|
|
9214b32139 | ||
|
|
2ea6cd3d17 | ||
|
|
6d5af29de8 | ||
|
|
444c98074f | ||
|
|
c6ed5c5c19 | ||
|
|
3692b81324 |
Generated
+1
@@ -1810,6 +1810,7 @@ dependencies = [
|
||||
"paginator-utils",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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').
|
||||
@@ -39,6 +39,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity)
|
||||
.await?;
|
||||
drop_and_create_table(&db, builder, "otp_cache", common::otp_cache::Entity).await?;
|
||||
drop_and_create_table(&db, builder, "payments", common::payments::Entity)
|
||||
.await?;
|
||||
|
||||
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity)
|
||||
.await?;
|
||||
|
||||
@@ -19,6 +19,7 @@ regex.workspace = true
|
||||
zod-rs.workspace = true
|
||||
zod-rs-util.workspace = true
|
||||
axum-test.workspace = true
|
||||
reqwest.workspace = true
|
||||
rand.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod rag_service;
|
||||
|
||||
pub use rag_service::RagServiceImpl;
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::domain::{
|
||||
ChatRequest, ChatResponse, RagDocument, RagRepository, RagService, RagSource,
|
||||
};
|
||||
use crate::ai_agent::infrastructure::llm_provider::{chat_completion, embed_text};
|
||||
use crate::materials::domain::MaterialRepository;
|
||||
use imphnen_entities::seaorm::common::materials::{
|
||||
Column as MaterialColumn, Entity as MaterialsEntity,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
const CHUNK_CHARS: usize = 700;
|
||||
const CHUNK_OVERLAP: usize = 80;
|
||||
const SEARCH_LIMIT: u64 = 4;
|
||||
const MAX_ANSWER_TOKENS: u32 = 600;
|
||||
|
||||
/// Stable u64 point id for a material chunk (uuid bytes xor chunk index).
|
||||
fn qdrant_point_id(material_id: Uuid, chunk_index: usize) -> u64 {
|
||||
let bytes = material_id.as_bytes();
|
||||
let mut val: u64 = 0;
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
val ^= (*b as u64) << ((i % 8) * 8);
|
||||
}
|
||||
val ^ (chunk_index as u64)
|
||||
}
|
||||
|
||||
pub struct RagServiceImpl {
|
||||
repo: Arc<dyn RagRepository>,
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl RagServiceImpl {
|
||||
pub fn new(repo: Arc<dyn RagRepository>, db: DatabaseConnection) -> Self {
|
||||
Self { repo, db }
|
||||
}
|
||||
|
||||
fn split_chunks(title: &str, content: &str) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
let text = format!("{}\n{}", title, content);
|
||||
let bytes = text.as_bytes();
|
||||
let mut start = 0usize;
|
||||
while start < bytes.len() {
|
||||
let end = (start + CHUNK_CHARS).min(bytes.len());
|
||||
// don't split mid-utf8 char
|
||||
let mut cut = end;
|
||||
while cut > start && !bytes[cut - 1].is_ascii() && cut < end + 3 {
|
||||
cut = end;
|
||||
break;
|
||||
}
|
||||
let chunk = &text[start..cut];
|
||||
if !chunk.trim().is_empty() {
|
||||
chunks.push(chunk.to_string());
|
||||
}
|
||||
if end >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(CHUNK_OVERLAP);
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
async fn index_entity(&self, material_id: Uuid) -> Result<u64, AppError> {
|
||||
let material = MaterialsEntity::find_by_id(material_id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
|
||||
if !material.is_published {
|
||||
return Ok(0);
|
||||
}
|
||||
// re-index: clear old chunks first
|
||||
self.repo.delete_material(material_id).await?;
|
||||
|
||||
let chunks = Self::split_chunks(&material.title, &material.content);
|
||||
let mut indexed = 0u64;
|
||||
let now = Utc::now();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let embedding = embed_text(chunk).await?;
|
||||
let doc = RagDocument {
|
||||
material_id,
|
||||
title: material.title.clone(),
|
||||
chunk: chunk.clone(),
|
||||
indexed_at: now,
|
||||
};
|
||||
let point_id = qdrant_point_id(material_id, i);
|
||||
self.repo.upsert_document(point_id, &doc, embedding).await?;
|
||||
indexed += 1;
|
||||
}
|
||||
Ok(indexed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RagService for RagServiceImpl {
|
||||
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError> {
|
||||
self.index_entity(material_id).await
|
||||
}
|
||||
|
||||
async fn reindex_all(&self) -> Result<u64, AppError> {
|
||||
let materials = MaterialsEntity::find()
|
||||
.filter(MaterialColumn::IsPublished.eq(true))
|
||||
.all(&self.db)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let mut total = 0u64;
|
||||
for m in materials {
|
||||
total += self.index_entity(m.id).await?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError> {
|
||||
let question = request.question.trim().to_string();
|
||||
if question.is_empty() {
|
||||
return Err(AppError::BadRequestError("question tidak boleh kosong".into()));
|
||||
}
|
||||
|
||||
let query_emb = embed_text(&question).await?;
|
||||
let hits = self
|
||||
.repo
|
||||
.search(query_emb, SEARCH_LIMIT, request.material_id)
|
||||
.await?;
|
||||
|
||||
if hits.is_empty() {
|
||||
// No indexed context: answer without RAG context but stay honest.
|
||||
let answer = chat_completion(
|
||||
"Kamu adalah asisten AI Dimentorin. Jawab pertanyaan singkat dan jelas. Jika tidak tahu, akui tidak tahu.",
|
||||
&question,
|
||||
MAX_ANSWER_TOKENS,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ChatResponse {
|
||||
answer,
|
||||
sources: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let context: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|(doc, _)| format!("[{}]\n{}", doc.title, doc.chunk))
|
||||
.collect();
|
||||
let context_block = context.join("\n\n---\n\n");
|
||||
let system = format!(
|
||||
"Kamu adalah asisten AI Dimentorin yang menjawab berdasarkan materi mentoring berikut.\n\
|
||||
Jawab dalam bahasa Indonesia, singkat, jelas, dan berfokus pada konteks yang diberikan.\n\
|
||||
Jika pertanyaan di luar materi, katakan bahwa hal itu di luar materi yang tersedia.\n\n\
|
||||
=== MATERI ===\n{}",
|
||||
context_block
|
||||
);
|
||||
let answer = chat_completion(&system, &question, MAX_ANSWER_TOKENS).await?;
|
||||
|
||||
let sources = hits
|
||||
.into_iter()
|
||||
.map(|(doc, score)| RagSource {
|
||||
material_id: doc.material_id,
|
||||
title: doc.title,
|
||||
score,
|
||||
snippet: doc.chunk.chars().take(180).collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ChatResponse { answer, sources })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub question: String,
|
||||
#[serde(default)]
|
||||
pub material_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub answer: String,
|
||||
pub sources: Vec<RagSource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RagSource {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub score: f32,
|
||||
pub snippet: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod chat_types;
|
||||
pub mod rag_document;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
pub use chat_types::{ChatRequest, ChatResponse, RagSource};
|
||||
pub use rag_document::RagDocument;
|
||||
pub use repository::RagRepository;
|
||||
pub use service::RagService;
|
||||
@@ -0,0 +1,10 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RagDocument {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub chunk: String,
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::rag_document::RagDocument;
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait RagRepository: Send + Sync {
|
||||
/// Upsert a document chunk into the vector store.
|
||||
async fn upsert_document(
|
||||
&self,
|
||||
point_id: u64,
|
||||
doc: &RagDocument,
|
||||
embedding: Vec<f32>,
|
||||
) -> Result<(), AppError>;
|
||||
/// Search the vector store for the closest chunks to `embedding`.
|
||||
async fn search(
|
||||
&self,
|
||||
embedding: Vec<f32>,
|
||||
limit: u64,
|
||||
material_id: Option<Uuid>,
|
||||
) -> Result<Vec<(RagDocument, f32)>, AppError>;
|
||||
/// Remove all chunks for a material (re-index support).
|
||||
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::chat_types::{ChatRequest, ChatResponse};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait RagService: Send + Sync {
|
||||
/// Embed + store one material (split into chunks).
|
||||
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError>;
|
||||
/// Re-index all published materials (full refresh).
|
||||
async fn reindex_all(&self) -> Result<u64, AppError>;
|
||||
/// Retrieve context + generate answer for a question.
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
use imphnen_libs::ZodValidate;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatRequestDto {
|
||||
#[zod(min_length(1), max_length(2000))]
|
||||
pub question: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub material_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for ChatRequestDto {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatResponseDto {
|
||||
pub answer: String,
|
||||
pub sources: Vec<SourceDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SourceDto {
|
||||
pub material_id: Uuid,
|
||||
pub title: String,
|
||||
pub score: f32,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexResultDto {
|
||||
pub material_id: Uuid,
|
||||
pub chunks_indexed: u64,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::dto::{ChatRequestDto, IndexResultDto};
|
||||
use crate::ai_agent::domain::{ChatRequest, RagService};
|
||||
use axum::{
|
||||
Extension, extract::Path,
|
||||
http::HeaderMap, response::IntoResponse,
|
||||
};
|
||||
use imphnen_libs::{ValidatedJson, decode_access_token};
|
||||
use imphnen_utils::{ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn post_chat(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
ValidatedJson(body): ValidatedJson<ChatRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let material_id = match body.material_id {
|
||||
Some(m) => Some(Uuid::parse_str(&m).map_err(|_| {
|
||||
AppError::BadRequestError("material_id tidak valid".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let result = service
|
||||
.chat(ChatRequest {
|
||||
question: body.question,
|
||||
material_id,
|
||||
})
|
||||
.await?;
|
||||
Ok(ApiSuccess(result))
|
||||
}
|
||||
|
||||
pub async fn post_index_material(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let indexed = service.index_material(id).await?;
|
||||
Ok(ApiSuccess(IndexResultDto {
|
||||
material_id: id,
|
||||
chunks_indexed: indexed,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn post_reindex_all(
|
||||
_headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn RagService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let indexed = service.reindex_all().await?;
|
||||
Ok(ApiSuccess(IndexResultDto {
|
||||
material_id: Uuid::nil(),
|
||||
chunks_indexed: indexed,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,18 @@
|
||||
use super::handlers::{post_chat, post_index_material, post_reindex_all};
|
||||
use crate::ai_agent::application::RagServiceImpl;
|
||||
use crate::ai_agent::domain::RagService;
|
||||
use crate::ai_agent::infrastructure::QdrantRagRepository;
|
||||
use axum::{Extension, Router, routing::post};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn ai_agent_routes(db: DatabaseConnection, _state: Arc<AppState>) -> Router {
|
||||
let repo = Arc::new(QdrantRagRepository::new());
|
||||
let service: Arc<dyn RagService> = Arc::new(RagServiceImpl::new(repo, db));
|
||||
Router::new()
|
||||
.route("/ai/chat", post(post_chat))
|
||||
.route("/ai/materials/{id}/index", post(post_index_material))
|
||||
.route("/ai/reindex", post(post_reindex_all))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use imphnen_libs::environment::ENV;
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
|
||||
/// Call the LLM router /embeddings endpoint. Returns the embedding vector.
|
||||
pub async fn embed_text(input: &str) -> Result<Vec<f32>, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/embeddings", ENV.ai_llm_base_url);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Encoding", "identity")
|
||||
.bearer_auth(&ENV.ai_llm_api_key)
|
||||
.json(&json!({
|
||||
"model": ENV.ai_embedding_model,
|
||||
"input": input,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("embed request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("embed read failed: {e}")))?;
|
||||
let payload: serde_json::Value = serde_json::from_str(&text)
|
||||
.map_err(|e| {
|
||||
AppError::InternalServerError(format!(
|
||||
"embed parse failed: {e} (http {status}, body-len {})",
|
||||
text.len()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"embedding error (http {status}): {}",
|
||||
payload
|
||||
)));
|
||||
}
|
||||
let emb = payload["data"][0]["embedding"]
|
||||
.as_array()
|
||||
.ok_or_else(|| {
|
||||
AppError::InternalServerError("embedding response missing data[0].embedding".into())
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_f64().map(|f| f as f32))
|
||||
.collect::<Vec<f32>>();
|
||||
Ok(emb)
|
||||
}
|
||||
|
||||
/// Call the LLM router /chat/completions with a system prompt + user message.
|
||||
/// Uses streaming-safe parsing (router always streams); we read the full body.
|
||||
pub async fn chat_completion(
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
max_tokens: u32,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", ENV.ai_llm_base_url);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Encoding", "identity")
|
||||
.bearer_auth(&ENV.ai_llm_api_key)
|
||||
.json(&json!({
|
||||
"model": ENV.ai_llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system_prompt },
|
||||
{ "role": "user", "content": user_message }
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("chat request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("chat read failed: {e}")))?;
|
||||
|
||||
// 9router always returns SSE chunks; aggregate `data:` JSON lines.
|
||||
if status.is_success() {
|
||||
let mut full = String::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(payload) = line.strip_prefix("data:") {
|
||||
let payload = payload.trim();
|
||||
if payload == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
|
||||
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
|
||||
full.push_str(delta);
|
||||
}
|
||||
}
|
||||
} else if line.starts_with('{') {
|
||||
// non-streaming fallback
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(c) = v["choices"][0]["message"]["content"].as_str() {
|
||||
full.push_str(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !full.trim().is_empty() {
|
||||
return Ok(full);
|
||||
}
|
||||
// If no content extracted but status OK, return raw text as fallback.
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
Err(AppError::InternalServerError(format!(
|
||||
"chat error (http {status}): {}",
|
||||
payload
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod http;
|
||||
pub mod llm_provider;
|
||||
pub mod qdrant_rag_repository;
|
||||
|
||||
pub use llm_provider::{chat_completion, embed_text};
|
||||
pub use qdrant_rag_repository::QdrantRagRepository;
|
||||
@@ -0,0 +1,208 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_libs::environment::ENV;
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_agent::domain::{RagDocument, RagRepository};
|
||||
|
||||
pub const COLLECTION: &str = "dimentorin_materi";
|
||||
pub const VECTOR_SIZE: u64 = 3072; // gemini-embedding-001
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QdrantRagRepository {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl QdrantRagRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collection_url(&self) -> String {
|
||||
format!("{}/collections/{}", ENV.qdrant_url, COLLECTION)
|
||||
}
|
||||
|
||||
async fn ensure_collection(&self) -> Result<(), AppError> {
|
||||
let url = self.collection_url();
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant get collection: {e}")))?;
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let create = self
|
||||
.http
|
||||
.put(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"vectors": {
|
||||
"size": VECTOR_SIZE,
|
||||
"distance": "Cosine",
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::InternalServerError(format!("qdrant create collection: {e}"))
|
||||
})?;
|
||||
if !create.status().is_success() {
|
||||
let body = create.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant create collection failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
} else if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant check collection failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QdrantRagRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RagRepository for QdrantRagRepository {
|
||||
async fn upsert_document(
|
||||
&self,
|
||||
point_id: u64,
|
||||
doc: &RagDocument,
|
||||
embedding: Vec<f32>,
|
||||
) -> Result<(), AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points?wait=true", self.collection_url());
|
||||
let payload = json!({
|
||||
"points": [{
|
||||
"id": point_id,
|
||||
"vector": embedding,
|
||||
"payload": {
|
||||
"material_id": doc.material_id.to_string(),
|
||||
"title": doc.title,
|
||||
"chunk": doc.chunk,
|
||||
"indexed_at": doc.indexed_at.to_rfc3339(),
|
||||
},
|
||||
}]
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.put(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant upsert: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant upsert failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
embedding: Vec<f32>,
|
||||
limit: u64,
|
||||
material_id: Option<Uuid>,
|
||||
) -> Result<Vec<(RagDocument, f32)>, AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points/search", self.collection_url());
|
||||
let mut payload = json!({
|
||||
"vector": embedding,
|
||||
"limit": limit,
|
||||
"with_payload": true,
|
||||
});
|
||||
if let Some(mid) = material_id {
|
||||
payload["filter"] = json!({
|
||||
"must": [{ "key": "material_id", "match": { "value": mid.to_string() } }]
|
||||
});
|
||||
}
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant search: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant search failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
AppError::InternalServerError(format!("qdrant search parse: {e}"))
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
if let Some(points) = body["result"].as_array() {
|
||||
for p in points {
|
||||
let payload = &p["payload"];
|
||||
let material_id = payload["material_id"]
|
||||
.as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
let title = payload["title"].as_str().unwrap_or("").to_string();
|
||||
let chunk = payload["chunk"].as_str().unwrap_or("").to_string();
|
||||
let score = p["score"].as_f64().unwrap_or(0.0) as f32;
|
||||
if let Some(mid) = material_id {
|
||||
results.push((
|
||||
RagDocument {
|
||||
material_id: mid,
|
||||
title,
|
||||
chunk,
|
||||
indexed_at: Utc::now(),
|
||||
},
|
||||
score,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError> {
|
||||
self.ensure_collection().await?;
|
||||
let url = format!("{}/points/delete?wait=true", self.collection_url());
|
||||
let payload = json!({
|
||||
"filter": {
|
||||
"must": [{ "key": "material_id", "match": { "value": material_id.to_string() } }]
|
||||
}
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("qdrant delete: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"qdrant delete failed: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep this type alias for callers that need the concrete repo.
|
||||
pub type QdrantRepo = QdrantRagRepository;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use application::RagServiceImpl;
|
||||
pub use infrastructure::http::routes::ai_agent_routes;
|
||||
@@ -1,7 +1,13 @@
|
||||
pub mod ai_agent;
|
||||
pub mod articles;
|
||||
pub mod materials;
|
||||
pub mod mentors;
|
||||
pub mod payments;
|
||||
pub mod sessions;
|
||||
|
||||
pub use ai_agent::ai_agent_routes;
|
||||
pub use articles::{articles_protected_routes, articles_public_routes};
|
||||
pub use materials::{materials_protected_routes, materials_public_routes};
|
||||
pub use mentors::{mentors_protected_routes, mentors_public_routes};
|
||||
pub use payments::payments_protected_routes;
|
||||
pub use sessions::{sessions_protected_routes, sessions_public_routes};
|
||||
@@ -0,0 +1,135 @@
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::domain::{
|
||||
CreateMaterialCommand, MaterialEntity, MaterialListItem,
|
||||
MaterialRepository, MaterialService, UpdateMaterialCommand,
|
||||
};
|
||||
use crate::materials::domain::{MaterialRepository as _};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
pub struct MaterialServiceImpl {
|
||||
repo: Box<dyn MaterialRepository>,
|
||||
}
|
||||
|
||||
impl MaterialServiceImpl {
|
||||
pub fn new(repo: Box<dyn MaterialRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
fn slugify(title: &str) -> String {
|
||||
let slug: String = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c
|
||||
} else if c.is_whitespace() {
|
||||
'-'
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MaterialService for MaterialServiceImpl {
|
||||
async fn list_materials(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialListItem>, AppError> {
|
||||
let res = self
|
||||
.repo
|
||||
.find_all_paginated(page, per_page, category, published_only)
|
||||
.await?;
|
||||
Ok(PaginatorResponse {
|
||||
data: res
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|e| MaterialListItem::from_entity(&e))
|
||||
.collect(),
|
||||
meta: res.meta,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_material_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError> {
|
||||
self.repo.find_by_id(id).await
|
||||
}
|
||||
|
||||
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError> {
|
||||
self.repo.find_by_slug(slug).await
|
||||
}
|
||||
|
||||
async fn list_categories(&self) -> Result<Vec<String>, AppError> {
|
||||
self.repo.find_categories().await
|
||||
}
|
||||
|
||||
async fn create_material(
|
||||
&self,
|
||||
cmd: CreateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError> {
|
||||
let now = Utc::now();
|
||||
let entity = MaterialEntity {
|
||||
id: Uuid::new_v4(),
|
||||
mentor_id: cmd.mentor_id,
|
||||
title: cmd.title.clone(),
|
||||
slug: format!("{}-{}", Self::slugify(&cmd.title), Uuid::new_v4().to_string()[..8].to_string()),
|
||||
category: cmd.category,
|
||||
description: cmd.description,
|
||||
content: cmd.content,
|
||||
cover_url: cmd.cover_url,
|
||||
is_published: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
self.repo.create(entity.clone()).await?;
|
||||
Ok(MaterialListItem::from_entity(&entity))
|
||||
}
|
||||
|
||||
async fn update_material(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
cmd: UpdateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError> {
|
||||
let existing = self.repo.find_by_id(id).await?;
|
||||
if existing.mentor_id != actor_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"Only the author can update this material".into(),
|
||||
));
|
||||
}
|
||||
// do not change mentor on update
|
||||
let entity = MaterialEntity {
|
||||
id,
|
||||
mentor_id: existing.mentor_id,
|
||||
title: cmd.title.unwrap_or(existing.title),
|
||||
slug: existing.slug,
|
||||
category: cmd.category.unwrap_or(existing.category),
|
||||
description: cmd.description.unwrap_or(existing.description),
|
||||
content: cmd.content.unwrap_or(existing.content),
|
||||
cover_url: cmd.cover_url.or(existing.cover_url),
|
||||
is_published: cmd.is_published.unwrap_or(existing.is_published),
|
||||
created_at: existing.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
self.repo.update(id, entity.clone()).await?;
|
||||
Ok(MaterialListItem::from_entity(&entity))
|
||||
}
|
||||
|
||||
async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError> {
|
||||
let existing = self.repo.find_by_id(id).await?;
|
||||
if existing.mentor_id != actor_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"Only the author can delete this material".into(),
|
||||
));
|
||||
}
|
||||
self.repo.delete(id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod material_service;
|
||||
|
||||
pub use material_service::MaterialServiceImpl;
|
||||
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MaterialEntity {
|
||||
pub id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateMaterialCommand {
|
||||
pub mentor_id: Uuid,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpdateMaterialCommand {
|
||||
pub title: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MaterialListItem {
|
||||
pub id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub description: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MaterialListItem {
|
||||
pub fn from_entity(e: &MaterialEntity) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
mentor_id: e.mentor_id,
|
||||
title: e.title.clone(),
|
||||
slug: e.slug.clone(),
|
||||
category: e.category.clone(),
|
||||
description: e.description.clone(),
|
||||
cover_url: e.cover_url.clone(),
|
||||
is_published: e.is_published,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::materials::domain::material::MaterialEntity;
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod material;
|
||||
pub mod material_types;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
pub use material::MaterialEntity;
|
||||
pub use material_types::{
|
||||
CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand,
|
||||
};
|
||||
pub use repository::MaterialRepository;
|
||||
pub use service::MaterialService;
|
||||
@@ -0,0 +1,23 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::material::MaterialEntity;
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MaterialRepository: Send + Sync {
|
||||
async fn find_all_paginated(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialEntity>, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create(&self, entity: MaterialEntity) -> Result<Uuid, AppError>;
|
||||
async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::material::MaterialEntity;
|
||||
use super::material_types::{
|
||||
CreateMaterialCommand, MaterialListItem, UpdateMaterialCommand,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MaterialService: Send + Sync {
|
||||
async fn list_materials(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialListItem>, AppError>;
|
||||
async fn get_material_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError>;
|
||||
async fn get_material_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError>;
|
||||
async fn list_categories(&self) -> Result<Vec<String>, AppError>;
|
||||
async fn create_material(
|
||||
&self,
|
||||
cmd: CreateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError>;
|
||||
async fn update_material(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
cmd: UpdateMaterialCommand,
|
||||
) -> Result<MaterialListItem, AppError>;
|
||||
async fn delete_material(&self, id: Uuid, actor_id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
use crate::materials::domain::{MaterialEntity, MaterialListItem};
|
||||
use imphnen_libs::ZodValidate;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateMaterialRequest {
|
||||
#[zod(min_length(3), max_length(200))]
|
||||
pub title: String,
|
||||
#[zod(min_length(1), max_length(100))]
|
||||
pub category: String,
|
||||
#[zod(min_length(3), max_length(500))]
|
||||
pub description: String,
|
||||
#[zod(min_length(10))]
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for CreateMaterialRequest {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateMaterialRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_published: Option<bool>,
|
||||
}
|
||||
|
||||
impl ZodValidate for UpdateMaterialRequest {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MaterialResponse {
|
||||
pub id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl MaterialResponse {
|
||||
pub fn from_entity(e: &MaterialEntity) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
mentor_id: e.mentor_id,
|
||||
title: e.title.clone(),
|
||||
slug: e.slug.clone(),
|
||||
category: e.category.clone(),
|
||||
description: e.description.clone(),
|
||||
content: e.content.clone(),
|
||||
cover_url: e.cover_url.clone(),
|
||||
is_published: e.is_published,
|
||||
created_at: e.created_at.to_rfc3339(),
|
||||
updated_at: e.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MaterialListItemResponse {
|
||||
pub id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub description: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<&MaterialEntity> for MaterialListItemResponse {
|
||||
fn from(e: &MaterialEntity) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
mentor_id: e.mentor_id,
|
||||
title: e.title.clone(),
|
||||
slug: e.slug.clone(),
|
||||
category: e.category.clone(),
|
||||
description: e.description.clone(),
|
||||
cover_url: e.cover_url.clone(),
|
||||
is_published: e.is_published,
|
||||
created_at: e.created_at.to_rfc3339(),
|
||||
updated_at: e.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&MaterialListItem> for MaterialListItemResponse {
|
||||
fn from(e: &MaterialListItem) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
mentor_id: e.mentor_id,
|
||||
title: e.title.clone(),
|
||||
slug: e.slug.clone(),
|
||||
category: e.category.clone(),
|
||||
description: e.description.clone(),
|
||||
cover_url: e.cover_url.clone(),
|
||||
is_published: e.is_published,
|
||||
created_at: e.created_at.to_rfc3339(),
|
||||
updated_at: e.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type MaterialListResponse = PaginatorResponse<MaterialListItemResponse>;
|
||||
@@ -0,0 +1,130 @@
|
||||
use super::dto::{
|
||||
CreateMaterialRequest, MaterialListItemResponse, MaterialResponse,
|
||||
UpdateMaterialRequest,
|
||||
};
|
||||
use crate::materials::domain::{
|
||||
CreateMaterialCommand, MaterialService, UpdateMaterialCommand,
|
||||
};
|
||||
use axum::{
|
||||
Extension, extract::{Path, Query},
|
||||
http::{HeaderMap, header::AUTHORIZATION},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use imphnen_libs::{ValidatedJson, decode_access_token};
|
||||
use imphnen_utils::{ApiSuccess, AppError};
|
||||
use paginator_utils::PaginatorResponse;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn extract_user_id(headers: &HeaderMap) -> Result<Uuid, AppError> {
|
||||
let token = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
|
||||
let claims = decode_access_token(token)
|
||||
.map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))?;
|
||||
Uuid::parse_str(&claims.claims.user_id)
|
||||
.map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_materials_list(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let page: u64 = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
|
||||
let per_page: u64 = params
|
||||
.get("per_page")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(10);
|
||||
let category = params.get("category").cloned().filter(|c| !c.is_empty());
|
||||
// public listing always shows published only
|
||||
let res = service
|
||||
.list_materials(page, per_page, category, true)
|
||||
.await?;
|
||||
let data: Vec<MaterialListItemResponse> =
|
||||
res.data.iter().map(|e| e.into()).collect();
|
||||
Ok(ApiSuccess(PaginatorResponse {
|
||||
data,
|
||||
meta: res.meta,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_material_categories(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let categories = service.list_categories().await?;
|
||||
Ok(ApiSuccess(categories))
|
||||
}
|
||||
|
||||
pub async fn get_material_by_slug(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let material = service.get_material_by_slug(&slug).await?;
|
||||
if !material.is_published {
|
||||
return Err(AppError::NotFoundError("Material not found".into()));
|
||||
}
|
||||
Ok(ApiSuccess(MaterialResponse::from_entity(&material)))
|
||||
}
|
||||
|
||||
pub async fn get_material_by_id(
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let material = service.get_material_by_id(id).await?;
|
||||
if !material.is_published {
|
||||
return Err(AppError::NotFoundError("Material not found".into()));
|
||||
}
|
||||
Ok(ApiSuccess(MaterialResponse::from_entity(&material)))
|
||||
}
|
||||
|
||||
pub async fn post_create_material(
|
||||
headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
ValidatedJson(body): ValidatedJson<CreateMaterialRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let cmd = CreateMaterialCommand {
|
||||
mentor_id: user_id,
|
||||
title: body.title,
|
||||
category: body.category,
|
||||
description: body.description,
|
||||
content: body.content,
|
||||
cover_url: body.cover_url,
|
||||
};
|
||||
let material = service.create_material(cmd).await?;
|
||||
Ok(ApiSuccess(MaterialListItemResponse::from(&material)))
|
||||
}
|
||||
|
||||
pub async fn put_update_material(
|
||||
headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
ValidatedJson(body): ValidatedJson<UpdateMaterialRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let cmd = UpdateMaterialCommand {
|
||||
title: body.title,
|
||||
category: body.category,
|
||||
description: body.description,
|
||||
content: body.content,
|
||||
cover_url: body.cover_url,
|
||||
is_published: body.is_published,
|
||||
};
|
||||
let material = service.update_material(id, user_id, cmd).await?;
|
||||
Ok(ApiSuccess(MaterialListItemResponse::from(&material)))
|
||||
}
|
||||
|
||||
pub async fn delete_material(
|
||||
headers: HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn MaterialService>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
service.delete_material(id, user_id).await?;
|
||||
Ok(ApiSuccess(serde_json::json!({
|
||||
"message": format!("Material {} deleted", id)
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::handlers::{
|
||||
delete_material, get_material_by_id, get_material_by_slug, get_material_categories,
|
||||
get_materials_list, post_create_material, put_update_material,
|
||||
};
|
||||
use crate::materials::application::MaterialServiceImpl;
|
||||
use crate::materials::domain::MaterialService;
|
||||
use crate::materials::infrastructure::persistence::PostgresMaterialRepository;
|
||||
use axum::{
|
||||
Extension, Router, routing::{delete, get, post, put},
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn MaterialService> {
|
||||
let repo = Box::new(PostgresMaterialRepository::new(db));
|
||||
Arc::new(MaterialServiceImpl::new(repo))
|
||||
}
|
||||
|
||||
pub fn materials_public_routes(db: DatabaseConnection) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/materials", get(get_materials_list))
|
||||
.route("/materials/categories", get(get_material_categories))
|
||||
.route("/materials/slug/{slug}", get(get_material_by_slug))
|
||||
.route("/materials/{id}", get(get_material_by_id))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
|
||||
pub fn materials_protected_routes(
|
||||
db: DatabaseConnection,
|
||||
state: Arc<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/materials", post(post_create_material))
|
||||
.route("/materials/{id}", put(put_update_material))
|
||||
.route("/materials/{id}", delete(delete_material))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_material_repository;
|
||||
|
||||
pub use postgres_material_repository::PostgresMaterialRepository;
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
use async_trait::async_trait;
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait,
|
||||
ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::materials::domain::{MaterialEntity, MaterialRepository};
|
||||
use imphnen_entities::seaorm::common::materials::{
|
||||
ActiveModel, Column, Entity, Model,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresMaterialRepository {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl PostgresMaterialRepository {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
fn to_entity(model: Model) -> MaterialEntity {
|
||||
MaterialEntity {
|
||||
id: model.id,
|
||||
mentor_id: model.mentor_id,
|
||||
title: model.title,
|
||||
slug: model.slug,
|
||||
category: model.category,
|
||||
description: model.description,
|
||||
content: model.content,
|
||||
cover_url: model.cover_url,
|
||||
is_published: model.is_published,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MaterialRepository for PostgresMaterialRepository {
|
||||
async fn find_all_paginated(
|
||||
&self,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
category: Option<String>,
|
||||
published_only: bool,
|
||||
) -> Result<PaginatorResponse<MaterialEntity>, AppError> {
|
||||
let mut query = Entity::find();
|
||||
if published_only {
|
||||
query = query.filter(Column::IsPublished.eq(true));
|
||||
}
|
||||
if let Some(cat) = category.filter(|c| !c.is_empty()) {
|
||||
query = query.filter(Column::Category.eq(cat));
|
||||
}
|
||||
query = query.order_by_desc(Column::CreatedAt);
|
||||
|
||||
let paginator = query.paginate(&self.db, per_page);
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let items = paginator
|
||||
.fetch_page(page.saturating_sub(1))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let data = items.into_iter().map(Self::to_entity).collect();
|
||||
let meta =
|
||||
PaginatorResponseMeta::new(page as u32, per_page as u32, total as u32);
|
||||
Ok(PaginatorResponse { data, meta })
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<MaterialEntity, AppError> {
|
||||
let model = Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
|
||||
Ok(Self::to_entity(model))
|
||||
}
|
||||
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<MaterialEntity, AppError> {
|
||||
let model = Entity::find()
|
||||
.filter(Column::Slug.eq(slug))
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
|
||||
Ok(Self::to_entity(model))
|
||||
}
|
||||
|
||||
async fn find_categories(&self) -> Result<Vec<String>, AppError> {
|
||||
let rows: Vec<serde_json::Value> = Entity::find()
|
||||
.select_only()
|
||||
.column(Column::Category)
|
||||
.distinct()
|
||||
.into_json()
|
||||
.all(&self.db)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| r["category"].as_str().map(|s| s.to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create(&self, entity: MaterialEntity) -> Result<Uuid, AppError> {
|
||||
let model = ActiveModel {
|
||||
id: Set(entity.id),
|
||||
mentor_id: Set(entity.mentor_id),
|
||||
title: Set(entity.title),
|
||||
slug: Set(entity.slug),
|
||||
category: Set(entity.category),
|
||||
description: Set(entity.description),
|
||||
content: Set(entity.content),
|
||||
cover_url: Set(entity.cover_url),
|
||||
is_published: Set(entity.is_published),
|
||||
created_at: Set(entity.created_at),
|
||||
updated_at: Set(entity.updated_at),
|
||||
};
|
||||
model.insert(&self.db).await?;
|
||||
Ok(entity.id)
|
||||
}
|
||||
|
||||
async fn update(&self, id: Uuid, entity: MaterialEntity) -> Result<(), AppError> {
|
||||
let model = ActiveModel {
|
||||
id: Set(id),
|
||||
mentor_id: Set(entity.mentor_id),
|
||||
title: Set(entity.title),
|
||||
slug: Set(entity.slug),
|
||||
category: Set(entity.category),
|
||||
description: Set(entity.description),
|
||||
content: Set(entity.content),
|
||||
cover_url: Set(entity.cover_url),
|
||||
is_published: Set(entity.is_published),
|
||||
created_at: Set(entity.created_at),
|
||||
updated_at: Set(entity.updated_at),
|
||||
};
|
||||
model.update(&self.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
let model = Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
|
||||
model.delete(&self.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::{
|
||||
materials_protected_routes, materials_public_routes,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Midtrans payment provider — Core API (v2/charge).
|
||||
//!
|
||||
//! Gateway-agnostic design: the payments service calls into this module when
|
||||
//! Midtrans credentials are configured (`MIDTRANS_*` env). It returns the
|
||||
//! provider-specific reference that gets persisted into `app_payments.external_ref`:
|
||||
//! a VA number for `va`, or the QR string payload for `qris`.
|
||||
|
||||
use imphnen_utils::AppError;
|
||||
use serde_json::json;
|
||||
|
||||
/// Query transaction status for an order via Midtrans Core API.
|
||||
///
|
||||
/// Returns the raw `transaction_status` string (e.g. "capture", "settlement",
|
||||
/// "pending", "expire", ...).
|
||||
pub async fn get_status(
|
||||
order_id: &str,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/{}/status", status_base(), order_id);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.basic_auth(server_key, Some(""))
|
||||
// Midtrans/istio compresses with gzip even when the client cannot
|
||||
// decompress; reqwest auto-decompress can return an empty body here,
|
||||
// so ask for identity explicitly.
|
||||
.header(reqwest::header::ACCEPT_ENCODING, "identity")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans status request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let hdrs = format!("{:?}", resp.headers());
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans status read failed: {e} (http {}, hdrs {})", status, hdrs)))?;
|
||||
let payload: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
||||
AppError::InternalServerError(format!(
|
||||
"Midtrans status parse failed: {e} (http {}, hdrs {}, body-len {})",
|
||||
status,
|
||||
hdrs,
|
||||
text.len()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans status error ({}): {}",
|
||||
status,
|
||||
payload["status_message"].as_str().unwrap_or("unknown")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(
|
||||
payload["transaction_status"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_base() -> &'static str {
|
||||
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
||||
"https://api.midtrans.com/v2"
|
||||
} else {
|
||||
"https://api.sandbox.midtrans.com/v2"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox vs production endpoint for charge. Sandbox is the default and safe for demo.
|
||||
fn charge_url() -> &'static str {
|
||||
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
||||
"https://api.midtrans.com/v2/charge"
|
||||
} else {
|
||||
"https://api.sandbox.midtrans.com/v2/charge"
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bank-transfer (Virtual Account) charge via Midtrans Core API.
|
||||
///
|
||||
/// Returns the VA number to display to the mentee.
|
||||
pub async fn create_va_charge(
|
||||
order_id: &str,
|
||||
gross_amount: i64,
|
||||
bank: &str,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = json!({
|
||||
"payment_type": "bank_transfer",
|
||||
"transaction_details": {
|
||||
"order_id": order_id,
|
||||
"gross_amount": gross_amount,
|
||||
},
|
||||
"bank_transfer": {
|
||||
"bank": bank,
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(charge_url())
|
||||
.basic_auth(server_key, Some(""))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let payload: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans charge error ({}): {}",
|
||||
status,
|
||||
payload["status_message"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown error")
|
||||
)));
|
||||
}
|
||||
|
||||
payload["va_numbers"][0]["va_number"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing va_number".into()))
|
||||
}
|
||||
|
||||
/// Create a QRIS charge via Midtrans Core API.
|
||||
///
|
||||
/// Returns the QR string payload (renderable as a QR code).
|
||||
pub async fn create_qris_charge(
|
||||
order_id: &str,
|
||||
gross_amount: i64,
|
||||
server_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = json!({
|
||||
"payment_type": "qris",
|
||||
"transaction_details": {
|
||||
"order_id": order_id,
|
||||
"gross_amount": gross_amount,
|
||||
},
|
||||
"qris": {
|
||||
"acquirer": "gopay",
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(charge_url())
|
||||
.basic_auth(server_key, Some(""))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let payload: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::InternalServerError(format!(
|
||||
"Midtrans charge error ({}): {}",
|
||||
status,
|
||||
payload["status_message"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown error")
|
||||
)));
|
||||
}
|
||||
|
||||
payload["qr_string"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing qr_string".into()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod midtrans_provider;
|
||||
pub mod payment_service;
|
||||
|
||||
pub use payment_service::PaymentServiceImpl;
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::super::domain::{
|
||||
CreatePaymentCommand, PaymentEntity, PaymentRepository, PaymentService, SERVICE_FEE,
|
||||
};
|
||||
use crate::sessions::domain::SessionRepository;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use imphnen_entities::seaorm::auth::mentors::Entity as MentorsEntity;
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PaymentServiceImpl {
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PaymentServiceImpl {
|
||||
pub fn new(
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
payment_repo,
|
||||
session_repo,
|
||||
db,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_external_ref(method: &str, session_id: Uuid) -> String {
|
||||
match method {
|
||||
"va" => format!("VA-{}-{}", session_id.to_string().split('-').next().unwrap_or("X"), Utc::now().format("%Y%m%d%H%M%S")),
|
||||
"qris" => format!("QR-{}", session_id.to_string().replace('-', "").chars().take(16).collect::<String>()),
|
||||
_ => format!("MANUAL-{}", Utc::now().format("%Y%m%d%H%M%S")),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentService for PaymentServiceImpl {
|
||||
async fn create_payment(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
mentee_id: Uuid,
|
||||
cmd: CreatePaymentCommand,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Only a valid session can be paid for.
|
||||
let session = self
|
||||
.session_repo
|
||||
.find_by_id(session_id)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFoundError("Session not found".into()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Session not found".into()))?;
|
||||
|
||||
// The mentee paying must be the session's mentee.
|
||||
if session.mentee_id != mentee_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only pay for your own sessions".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load mentor rate from the mentors table (mentors.user_id = the session's
|
||||
// mentor user id).
|
||||
let mentor_uuid = session.mentor_id;
|
||||
let mentor = MentorsEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::mentors::Column::UserId.eq(mentor_uuid))
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Mentor not found".into()))?;
|
||||
|
||||
let rate = mentor.mentoring_rate.unwrap_or(50_000.0).round() as i64;
|
||||
let total = rate + SERVICE_FEE;
|
||||
let method = cmd.method.clone();
|
||||
// Use Midtrans when credentials are configured; fall back to manual refs.
|
||||
let midtrans = imphnen_libs::environment::ENV.midtrans_merchant_id.clone();
|
||||
let order_id = format!("DM-{}", Uuid::new_v4());
|
||||
let (provider, external_ref) = if !midtrans.is_empty() {
|
||||
let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone();
|
||||
match method.as_str() {
|
||||
"va" => {
|
||||
let va =
|
||||
crate::payments::application::midtrans_provider::create_va_charge(
|
||||
&order_id,
|
||||
total,
|
||||
"bca",
|
||||
&server_key,
|
||||
)
|
||||
.await?;
|
||||
("midtrans".to_string(), Some(va))
|
||||
}
|
||||
"qris" => {
|
||||
let qr = crate::payments::application::midtrans_provider::create_qris_charge(
|
||||
&order_id,
|
||||
total,
|
||||
&server_key,
|
||||
)
|
||||
.await?;
|
||||
("midtrans".to_string(), Some(qr))
|
||||
}
|
||||
_ => ("manual".to_string(), Some(generate_external_ref(&method, session_id))),
|
||||
}
|
||||
} else {
|
||||
("manual".to_string(), Some(generate_external_ref(&method, session_id)))
|
||||
};
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
let provider_order_id = if provider == "midtrans" {
|
||||
Some(order_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payment = PaymentEntity {
|
||||
id: Uuid::new_v4(),
|
||||
session_id,
|
||||
mentee_id,
|
||||
mentor_id: mentor_uuid,
|
||||
amount: rate,
|
||||
service_fee: SERVICE_FEE,
|
||||
total,
|
||||
method: method.clone(),
|
||||
provider,
|
||||
status: "pending".into(),
|
||||
external_ref,
|
||||
provider_order_id,
|
||||
expires_at,
|
||||
created_at: Utc::now(),
|
||||
paid_at: None,
|
||||
};
|
||||
self.payment_repo.create(payment).await
|
||||
}
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.mentee_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only view your own payments".into(),
|
||||
));
|
||||
}
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Payment can be confirmed by the session's mentor (they see the
|
||||
// transfer arrive) or by an Admin / "Admin Pembayaran".
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.status != "pending" {
|
||||
return Err(AppError::ConflictError(
|
||||
"Payment is not pending".into(),
|
||||
));
|
||||
}
|
||||
let user = UsersEntity::find_by_id(actor_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Actor not found".into()))?;
|
||||
// Mentor of the linked session may confirm; otherwise an
|
||||
// Admin / "Admin Pembayaran" role is required.
|
||||
let is_mentor = payment.mentor_id == actor_id;
|
||||
if !is_mentor {
|
||||
let role_id = user.role_id.ok_or_else(|| {
|
||||
AppError::ForbiddenError("User has no role assigned".into())
|
||||
})?;
|
||||
let roles =
|
||||
imphnen_entities::seaorm::auth::roles::Entity::find_by_id(role_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::ForbiddenError("Role not found".into()))?;
|
||||
if roles.name != "Admin" && roles.name != "Admin Pembayaran" {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"Only the session mentor or a payment admin can confirm payments".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let paid = self.payment_repo
|
||||
.update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default()))
|
||||
.await?;
|
||||
// Confirm the linked session so mentor/mentee can proceed with the call.
|
||||
if let Some(mut session) = self.session_repo.find_by_id(payment.session_id).await? {
|
||||
session.status = "confirmed".to_string();
|
||||
session.updated_at = Utc::now();
|
||||
self.session_repo.update(payment.session_id, session).await?;
|
||||
}
|
||||
Ok(paid)
|
||||
}
|
||||
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
self.payment_repo.find_by_mentee(mentee_id).await
|
||||
}
|
||||
|
||||
async fn refresh_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.mentee_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only refresh your own payments".into(),
|
||||
));
|
||||
}
|
||||
if payment.status == "paid" {
|
||||
return Ok(payment);
|
||||
}
|
||||
let Some(order_id) = payment.provider_order_id.clone() else {
|
||||
// Manual payments have no provider to poll — nothing to do.
|
||||
return Ok(payment);
|
||||
};
|
||||
let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone();
|
||||
let midtrans_status =
|
||||
crate::payments::application::midtrans_provider::get_status(&order_id, &server_key)
|
||||
.await?;
|
||||
|
||||
if midtrans_status == "capture" || midtrans_status == "settlement" {
|
||||
let paid = self
|
||||
.payment_repo
|
||||
.update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default()))
|
||||
.await?;
|
||||
if let Some(mut session) = self.session_repo.find_by_id(payment.session_id).await? {
|
||||
session.status = "confirmed".to_string();
|
||||
session.updated_at = Utc::now();
|
||||
self.session_repo.update(payment.session_id, session).await?;
|
||||
}
|
||||
return Ok(paid);
|
||||
}
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
async fn get_session_payments(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
// Only the session's mentee or mentor may see its payments.
|
||||
let session = self.session_repo.find_by_id(session_id).await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Session not found".into()))?;
|
||||
if session.mentee_id != user_id && session.mentor_id != user_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only view payments of your own sessions".into(),
|
||||
));
|
||||
}
|
||||
self.payment_repo.find_by_session(session_id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
pub mod service;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use imphnen_utils::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use service::PaymentService;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreatePaymentCommand {
|
||||
pub method: String, // "va" | "qris" | "manual"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PaymentEntity {
|
||||
pub id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub mentee_id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub amount: i64,
|
||||
pub service_fee: i64,
|
||||
pub total: i64,
|
||||
pub method: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub const SERVICE_FEE: i64 = 2_000;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentRepository: Send + Sync {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_session(&self, session_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn find_by_mentee(&self, mentee_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
external_ref: Option<String>,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::{CreatePaymentCommand, PaymentEntity};
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentService: Send + Sync {
|
||||
/// Create a payment record for a booked session. Computes amount from the
|
||||
/// mentor's mentoring_rate, adds service fee, and (for the default manual
|
||||
/// provider) generates a deterministic external reference.
|
||||
async fn create_payment(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
mentee_id: Uuid,
|
||||
cmd: CreatePaymentCommand,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// Confirm a pending payment (admin / "Admin Pembayaran"). Marks paid.
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// List payments for the current mentee.
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
|
||||
/// Refresh a payment's status against the provider (Midtrans).
|
||||
/// If the provider reports paid (capture/settlement), the payment is
|
||||
/// marked paid and the linked session is auto-confirmed.
|
||||
async fn refresh_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// List payments for one session. Only the session's mentee or mentor may
|
||||
/// access.
|
||||
async fn get_session_payments(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use imphnen_libs::ZodValidate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
pub struct CreatePaymentRequestDto {
|
||||
// "va" | "qris" | "manual"
|
||||
#[serde(default = "default_method")]
|
||||
#[zod(min_length(1), max_length(20))]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
fn default_method() -> String {
|
||||
"manual".into()
|
||||
}
|
||||
|
||||
impl ZodValidate for CreatePaymentRequestDto {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentResponseDto {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub mentor_id: String,
|
||||
pub amount: i64,
|
||||
pub service_fee: i64,
|
||||
pub total: i64,
|
||||
pub method: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::dto::{CreatePaymentRequestDto, PaymentResponseDto};
|
||||
use crate::payments::domain::{CreatePaymentCommand, PaymentEntity, PaymentService};
|
||||
use axum::Extension;
|
||||
use axum::extract::Path;
|
||||
use axum::http::{HeaderMap, header::AUTHORIZATION};
|
||||
use imphnen_libs::ValidatedJson;
|
||||
use imphnen_libs::decode_access_token;
|
||||
use imphnen_utils::{ApiMessage, ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn extract_user_id(headers: &HeaderMap) -> Result<uuid::Uuid, AppError> {
|
||||
let token = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
|
||||
let claims = decode_access_token(token)
|
||||
.map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))?;
|
||||
uuid::Uuid::parse_str(&claims.claims.user_id)
|
||||
.map_err(|_| AppError::AuthenticationError("Invalid token subject".into()))
|
||||
}
|
||||
|
||||
fn to_dto(p: &PaymentEntity) -> PaymentResponseDto {
|
||||
PaymentResponseDto {
|
||||
id: p.id.to_string(),
|
||||
session_id: p.session_id.to_string(),
|
||||
mentor_id: p.mentor_id.to_string(),
|
||||
amount: p.amount,
|
||||
service_fee: p.service_fee,
|
||||
total: p.total,
|
||||
method: p.method.clone(),
|
||||
provider: p.provider.clone(),
|
||||
status: p.status.clone(),
|
||||
external_ref: p.external_ref.clone(),
|
||||
provider_order_id: p.provider_order_id.clone(),
|
||||
expires_at: p.expires_at.to_rfc3339(),
|
||||
created_at: p.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/sessions/{id}/create
|
||||
pub async fn post_create_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(session_id): Path<String>,
|
||||
ValidatedJson(dto): ValidatedJson<CreatePaymentRequestDto>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let session_uuid = uuid::Uuid::parse_str(&session_id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid session ID".into()))?;
|
||||
let payment = service
|
||||
.create_payment(
|
||||
session_uuid,
|
||||
user_id,
|
||||
CreatePaymentCommand { method: dto.method },
|
||||
)
|
||||
.await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
|
||||
/// GET /v1/dimentorin/payments/me
|
||||
pub async fn get_my_payments(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payments = service.get_mentee_payments(user_id).await?;
|
||||
let items: Vec<PaymentResponseDto> = payments.iter().map(to_dto).collect();
|
||||
Ok(ApiSuccess(items))
|
||||
}
|
||||
|
||||
pub async fn get_session_payments(
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::extract::Path(session_id): axum::extract::Path<Uuid>,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payments = service.get_session_payments(session_id, user_id).await?;
|
||||
let items: Vec<PaymentResponseDto> = payments.iter().map(to_dto).collect();
|
||||
Ok(ApiSuccess(items))
|
||||
}
|
||||
|
||||
/// GET /v1/dimentorin/payments/{id}
|
||||
pub async fn get_payment_by_id(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.get_payment_by_id(payment_uuid, user_id).await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/{id}/confirm (Admin / Admin Pembayaran)
|
||||
pub async fn post_confirm_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let actor_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.confirm_payment(payment_uuid, actor_id).await?;
|
||||
Ok(ApiMessage::ok(format!(
|
||||
"Payment {} confirmed",
|
||||
payment.external_ref.clone().unwrap_or_else(|| payment.id.to_string())
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /v1/dimentorin/payments/{id}/refresh (mentee)
|
||||
/// Polls the provider (Midtrans) and auto-marks the payment paid + session
|
||||
/// confirmed when the transaction settles.
|
||||
pub async fn post_refresh_payment(
|
||||
headers: axum::http::HeaderMap,
|
||||
Extension(service): Extension<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payment_uuid = uuid::Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?;
|
||||
let payment = service.refresh_status(payment_uuid, user_id).await?;
|
||||
Ok(ApiSuccess(to_dto(&payment)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::payments_protected_routes;
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::handlers::{
|
||||
get_my_payments, get_payment_by_id, get_session_payments, post_confirm_payment,
|
||||
post_create_payment, post_refresh_payment,
|
||||
};
|
||||
use crate::payments::application::PaymentServiceImpl;
|
||||
use crate::payments::domain::PaymentService;
|
||||
use crate::payments::infrastructure::persistence::PostgresPaymentRepository;
|
||||
use crate::sessions::infrastructure::persistence::PostgresSessionRepository;
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn PaymentService> {
|
||||
let db_arc = Arc::new(db);
|
||||
let payment_repo =
|
||||
Arc::new(PostgresPaymentRepository::new(Arc::clone(&db_arc)));
|
||||
let session_repo =
|
||||
Arc::new(PostgresSessionRepository::new(Arc::clone(&db_arc)));
|
||||
Arc::new(PaymentServiceImpl::new(payment_repo, session_repo, db_arc))
|
||||
}
|
||||
|
||||
pub fn payments_protected_routes(
|
||||
db: DatabaseConnection,
|
||||
state: Arc<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/payments/sessions/{id}/create", post(post_create_payment))
|
||||
.route("/payments/me", get(get_my_payments))
|
||||
.route("/payments/session/{id}", get(get_session_payments))
|
||||
.route("/payments/{id}", get(get_payment_by_id))
|
||||
.route("/payments/{id}/confirm", post(post_confirm_payment))
|
||||
.route("/payments/{id}/refresh", post(post_refresh_payment))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
pub use persistence::PostgresPaymentRepository;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_payment_repository;
|
||||
|
||||
pub use postgres_payment_repository::PostgresPaymentRepository;
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
use crate::payments::domain::{PaymentEntity, PaymentRepository};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::seaorm::common::payments::{
|
||||
ActiveModel as PaymentActiveModel, Column as PaymentColumn, Entity as PaymentEntityOrm,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn map(row: imphnen_entities::seaorm::common::payments::Model) -> PaymentEntity {
|
||||
PaymentEntity {
|
||||
id: row.id,
|
||||
session_id: row.session_id,
|
||||
mentee_id: row.mentee_id,
|
||||
mentor_id: row.mentor_id,
|
||||
amount: row.amount,
|
||||
service_fee: row.service_fee,
|
||||
total: row.total,
|
||||
method: row.method,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
external_ref: row.external_ref,
|
||||
provider_order_id: row.provider_order_id,
|
||||
expires_at: row.expires_at,
|
||||
created_at: row.created_at,
|
||||
paid_at: row.paid_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresPaymentRepository {
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PostgresPaymentRepository {
|
||||
pub fn new(db: Arc<DatabaseConnection>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentRepository for PostgresPaymentRepository {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError> {
|
||||
let now = Utc::now();
|
||||
let model = PaymentActiveModel {
|
||||
id: Set(payment.id),
|
||||
session_id: Set(payment.session_id),
|
||||
mentee_id: Set(payment.mentee_id),
|
||||
mentor_id: Set(payment.mentor_id),
|
||||
amount: Set(payment.amount),
|
||||
service_fee: Set(payment.service_fee),
|
||||
total: Set(payment.total),
|
||||
method: Set(payment.method),
|
||||
provider: Set(payment.provider),
|
||||
status: Set(payment.status),
|
||||
external_ref: Set(payment.external_ref),
|
||||
provider_order_id: Set(payment.provider_order_id),
|
||||
paid_at: Set(payment.paid_at),
|
||||
expires_at: Set(payment.expires_at),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
let row = PaymentEntityOrm::insert(model)
|
||||
.exec_with_returning(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<PaymentEntity, AppError> {
|
||||
let row = PaymentEntityOrm::find_by_id(id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
|
||||
async fn find_by_session(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
let rows = PaymentEntityOrm::find()
|
||||
.filter(PaymentColumn::SessionId.eq(session_id))
|
||||
.all(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(map).collect())
|
||||
}
|
||||
|
||||
async fn find_by_mentee(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
let rows = PaymentEntityOrm::find()
|
||||
.filter(PaymentColumn::MenteeId.eq(mentee_id))
|
||||
.order_by_desc(PaymentColumn::CreatedAt)
|
||||
.all(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(map).collect())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
external_ref: Option<String>,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
let existing = PaymentEntityOrm::find_by_id(id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?;
|
||||
let mut update: PaymentActiveModel = existing.clone().into();
|
||||
update.status = Set(status.to_string());
|
||||
if external_ref.is_some() {
|
||||
update.external_ref = Set(external_ref);
|
||||
}
|
||||
if status == "paid" {
|
||||
update.paid_at = Set(Some(Utc::now()));
|
||||
}
|
||||
update.updated_at = Set(Utc::now());
|
||||
let row = update
|
||||
.update(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(map(row))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use application::PaymentServiceImpl;
|
||||
pub use infrastructure::http::routes::payments_protected_routes;
|
||||
@@ -3,12 +3,17 @@ use crate::sessions::domain::{
|
||||
SessionRepository,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use imphnen_entities::seaorm::auth::mentors::{
|
||||
Column as MentorColumn, Entity as MentorsEntity,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SessionQueryService {
|
||||
pub repo: Arc<dyn SessionRepository>,
|
||||
pub db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl SessionQueryService {
|
||||
@@ -20,14 +25,20 @@ impl SessionQueryService {
|
||||
let mentor_uuid = Uuid::parse_str(&mentor_id)
|
||||
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?;
|
||||
|
||||
// Resolve mentor profile id -> user id (sessions.mentor_id FK ke app_users)
|
||||
let mentor = MentorsEntity::find_by_id(mentor_uuid)
|
||||
.one(self.db.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
|
||||
|
||||
let count = self
|
||||
.repo
|
||||
.count_by_mentor(mentor_uuid, status_filter.clone())
|
||||
.count_by_mentor(mentor.user_id, status_filter.clone())
|
||||
.await?;
|
||||
|
||||
let sessions = self
|
||||
.repo
|
||||
.find_by_mentor_id(mentor_uuid, status_filter)
|
||||
.find_by_mentor_id(mentor.user_id, status_filter)
|
||||
.await?;
|
||||
|
||||
let items: Vec<SessionListItem> = sessions
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::session_booking_service::SessionBookingService;
|
||||
use super::session_query_service::SessionQueryService;
|
||||
use crate::sessions::domain::{
|
||||
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail,
|
||||
SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionRepository,
|
||||
SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
SessionRepository, SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::AppError;
|
||||
@@ -15,12 +15,15 @@ pub struct SessionServiceImpl {
|
||||
}
|
||||
|
||||
impl SessionServiceImpl {
|
||||
pub fn new(repo: Arc<dyn SessionRepository>) -> Self {
|
||||
pub fn new(
|
||||
repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<sea_orm::DatabaseConnection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
booking: SessionBookingService {
|
||||
repo: Arc::clone(&repo),
|
||||
},
|
||||
query: SessionQueryService { repo },
|
||||
query: SessionQueryService { repo, db },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +50,34 @@ impl SessionService for SessionServiceImpl {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_mentor_stats(
|
||||
&self,
|
||||
mentor_id: String,
|
||||
) -> Result<MentorStats, AppError> {
|
||||
let list = self.query.get_mentor_sessions(mentor_id.clone(), None).await?;
|
||||
let mut mentees = std::collections::HashSet::new();
|
||||
let mut rating_sum = 0i64;
|
||||
let mut rating_count = 0i64;
|
||||
for s in &list.sessions {
|
||||
mentees.insert(s.mentee_id.clone());
|
||||
if let Some(r) = s.rating {
|
||||
rating_sum += r as i64;
|
||||
rating_count += 1;
|
||||
}
|
||||
}
|
||||
let avg = if rating_count > 0 {
|
||||
rating_sum as f64 / rating_count as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok(MentorStats {
|
||||
mentor_id,
|
||||
total_sessions: list.total as u64,
|
||||
unique_mentees: mentees.len() as u64,
|
||||
avg_rating: (avg * 10.0).round() / 10.0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_user_sessions(
|
||||
&self,
|
||||
user_id: String,
|
||||
|
||||
@@ -8,6 +8,6 @@ pub use service::SessionService;
|
||||
pub use session::SessionEntity;
|
||||
pub use session_types::{
|
||||
AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
MentorStats, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult,
|
||||
SessionList, SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,13 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<SessionEntity>, AppError>;
|
||||
|
||||
/// Resolve mentor *profile* id (app_mentors.id) to the owning user id
|
||||
/// (app_users.id) — session rows store the user id.
|
||||
async fn find_mentor_user_id(
|
||||
&self,
|
||||
profile_id: Uuid,
|
||||
) -> Result<Option<Uuid>, AppError>;
|
||||
|
||||
async fn find_by_mentor_id(
|
||||
&self,
|
||||
mentor_id: Uuid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::session_types::{
|
||||
BookSessionCommand, BookedSession, MentorAvailability, SessionDetail,
|
||||
SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
BookSessionCommand, BookedSession, MentorAvailability, MentorStats,
|
||||
SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList,
|
||||
UpdateSessionStatusCommand, UpdatedSessionStatus,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
@@ -32,6 +32,11 @@ pub trait SessionService: Send + Sync {
|
||||
mentor_id: String,
|
||||
) -> Result<MentorAvailability, AppError>;
|
||||
|
||||
async fn get_mentor_stats(
|
||||
&self,
|
||||
mentor_id: String,
|
||||
) -> Result<MentorStats, AppError>;
|
||||
|
||||
async fn update_session_status(
|
||||
&self,
|
||||
session_id: String,
|
||||
|
||||
@@ -73,6 +73,14 @@ pub struct MentorAvailability {
|
||||
pub booked_dates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MentorStats {
|
||||
pub mentor_id: String,
|
||||
pub total_sessions: u64,
|
||||
pub unique_mentees: u64,
|
||||
pub avg_rating: f64,
|
||||
}
|
||||
|
||||
pub struct UpdateSessionStatusCommand {
|
||||
pub status: String,
|
||||
pub meeting_link: Option<String>,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub use request::{
|
||||
};
|
||||
pub use response::{
|
||||
AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto,
|
||||
SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto,
|
||||
SessionListResponseDto, UpdateSessionStatusResponseDto,
|
||||
MentorStatsDto, SessionDetailDto, SessionFeedbackResponseDto,
|
||||
SessionListItemDto, SessionListResponseDto,
|
||||
UpdateSessionStatusResponseDto,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::sessions::domain::{
|
||||
AvailabilitySlot, BookedSession, MentorAvailability, SessionDetail,
|
||||
AvailabilitySlot, BookedSession, MentorAvailability, MentorStats, SessionDetail,
|
||||
SessionFeedbackResult, SessionList, SessionListItem, UpdatedSessionStatus,
|
||||
};
|
||||
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)]
|
||||
pub struct UpdateSessionStatusResponseDto {
|
||||
pub id: String,
|
||||
|
||||
@@ -5,5 +5,5 @@ pub use mutation_handlers::{
|
||||
post_book_session, post_submit_feedback, put_update_session_status,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::super::dto::{MentorAvailabilityDto, SessionListResponseDto};
|
||||
use super::super::dto::{MentorAvailabilityDto, MentorStatsDto, SessionListResponseDto};
|
||||
use crate::sessions::domain::SessionService;
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -68,6 +68,25 @@ pub async fn get_mentor_availability(
|
||||
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(
|
||||
get,
|
||||
path = "/v1/dimentorin/sessions/me",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::handlers::{
|
||||
get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session,
|
||||
post_submit_feedback, put_update_session_status,
|
||||
get_mentor_availability, get_mentor_sessions, get_mentor_stats,
|
||||
get_my_sessions, post_book_session, post_submit_feedback,
|
||||
put_update_session_status,
|
||||
};
|
||||
use crate::sessions::application::SessionServiceImpl;
|
||||
use crate::sessions::domain::SessionService;
|
||||
@@ -14,14 +15,16 @@ use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn SessionService> {
|
||||
let repo = Arc::new(PostgresSessionRepository::new(db));
|
||||
Arc::new(SessionServiceImpl::new(repo))
|
||||
let db_arc = Arc::new(db);
|
||||
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 {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/mentors/{id}/availability", get(get_mentor_availability))
|
||||
.route("/mentors/{id}/stats", get(get_mentor_stats))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -39,8 +39,8 @@ pub struct PostgresSessionRepository {
|
||||
}
|
||||
|
||||
impl PostgresSessionRepository {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db: Arc::new(db) }
|
||||
pub fn new(db: Arc<DatabaseConnection>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,22 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
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(
|
||||
&self,
|
||||
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_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 {}
|
||||
@@ -3,6 +3,9 @@ pub mod audit_log;
|
||||
pub mod enum_impls;
|
||||
pub mod enums;
|
||||
pub mod events;
|
||||
pub mod materials;
|
||||
pub mod otp_cache;
|
||||
pub mod payments;
|
||||
pub mod rate_limit;
|
||||
pub mod roadmap_items;
|
||||
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 {}
|
||||
@@ -6,8 +6,10 @@ use imphnen_cms::{
|
||||
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
|
||||
};
|
||||
use imphnen_dimentorin::{
|
||||
articles_protected_routes, articles_public_routes, mentors_protected_routes,
|
||||
mentors_public_routes, sessions_protected_routes, sessions_public_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,
|
||||
};
|
||||
use imphnen_gacha::gacha_router;
|
||||
use imphnen_hackathon::hackathon_router;
|
||||
@@ -75,14 +77,21 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router {
|
||||
.merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc)))
|
||||
.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(
|
||||
Router::new()
|
||||
.merge(mentors_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)),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
|
||||
use crate::auth::domain::AuthService;
|
||||
use crate::auth::domain::types::{
|
||||
AuthTokens, AuthUserDetail, LoginInput, LoginOutput, NewPasswordInput,
|
||||
@@ -22,16 +23,19 @@ use uuid::Uuid;
|
||||
pub struct AuthServiceImpl {
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
role_repo: Arc<dyn RoleRepository>,
|
||||
otp_repo: Arc<dyn OtpRepository>,
|
||||
}
|
||||
|
||||
impl AuthServiceImpl {
|
||||
pub fn new(
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
role_repo: Arc<dyn RoleRepository>,
|
||||
otp_repo: Arc<dyn OtpRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_repo,
|
||||
role_repo,
|
||||
otp_repo,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +191,16 @@ impl AuthService for AuthServiceImpl {
|
||||
..Default::default()
|
||||
})
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -203,6 +217,16 @@ impl AuthService for AuthServiceImpl {
|
||||
&format!("Your OTP code is {}", otp.code),
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -265,6 +289,24 @@ impl AuthService for AuthServiceImpl {
|
||||
if user.is_active {
|
||||
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
|
||||
.user_repo
|
||||
.update(UserEntity {
|
||||
@@ -272,6 +314,8 @@ impl AuthService for AuthServiceImpl {
|
||||
..user
|
||||
})
|
||||
.await?;
|
||||
// OTP is single-use — consume it on successful verification.
|
||||
self.otp_repo.delete_by_email(&payload.email).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod otp;
|
||||
pub mod types;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use super::handlers::{
|
||||
};
|
||||
use crate::auth::application::AuthServiceImpl;
|
||||
use crate::auth::domain::AuthService;
|
||||
use crate::auth::infrastructure::PostgresOtpRepository;
|
||||
use crate::roles::infrastructure::persistence::PostgresRoleRepository;
|
||||
use crate::users::infrastructure::persistence::PostgresUserRepository;
|
||||
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(
|
||||
state.postgres_connection.conn.clone(),
|
||||
));
|
||||
let otp_repo = Arc::new(PostgresOtpRepository::new(
|
||||
state.postgres_connection.conn.clone(),
|
||||
));
|
||||
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()
|
||||
.route("/auth/login", post(post_login))
|
||||
.route("/auth/login-mentor", post(post_login_mentor))
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod http;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ pub struct Env {
|
||||
pub google_client_secret: String,
|
||||
pub google_redirect_url: String,
|
||||
pub cdn_url: String,
|
||||
pub midtrans_merchant_id: String,
|
||||
pub midtrans_client_key: String,
|
||||
pub midtrans_server_key: String,
|
||||
pub ai_llm_base_url: String,
|
||||
pub ai_llm_api_key: String,
|
||||
pub ai_llm_model: String,
|
||||
pub ai_embedding_model: String,
|
||||
pub qdrant_url: String,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -73,6 +81,14 @@ impl std::fmt::Debug for Env {
|
||||
.field("google_client_secret", &"***")
|
||||
.field("google_redirect_url", &self.google_redirect_url)
|
||||
.field("cdn_url", &self.cdn_url)
|
||||
.field("midtrans_merchant_id", &self.midtrans_merchant_id)
|
||||
.field("midtrans_client_key", &"***")
|
||||
.field("midtrans_server_key", &"***")
|
||||
.field("ai_llm_base_url", &self.ai_llm_base_url)
|
||||
.field("ai_llm_api_key", &"***")
|
||||
.field("ai_llm_model", &self.ai_llm_model)
|
||||
.field("ai_embedding_model", &self.ai_embedding_model)
|
||||
.field("qdrant_url", &self.qdrant_url)
|
||||
.field("cors_allowed_origins", &self.cors_allowed_origins)
|
||||
.finish()
|
||||
}
|
||||
@@ -198,6 +214,23 @@ pub static ENV: Lazy<Env> = Lazy::new(|| {
|
||||
"http://localhost:8000/api/v1/auth/google/callback",
|
||||
),
|
||||
cdn_url: get_env_with_warning("CDN_URL", "https://cdn.asepharyana.tech"),
|
||||
midtrans_merchant_id: get_env_with_warning("MIDTRANS_MERCHANT_ID", ""),
|
||||
midtrans_client_key: get_env_with_warning("MIDTRANS_CLIENT_KEY", ""),
|
||||
midtrans_server_key: get_env_with_warning("MIDTRANS_SERVER_KEY", ""),
|
||||
ai_llm_base_url: get_env_with_warning(
|
||||
"AI_LLM_BASE_URL",
|
||||
"https://9router.asepharyana.my.id/v1",
|
||||
),
|
||||
ai_llm_api_key: get_env_with_warning("AI_LLM_API_KEY", ""),
|
||||
ai_llm_model: get_env_with_warning("AI_LLM_MODEL", "text"),
|
||||
ai_embedding_model: get_env_with_warning(
|
||||
"AI_EMBEDDING_MODEL",
|
||||
"gemini/gemini-embedding-001",
|
||||
),
|
||||
qdrant_url: get_env_with_warning(
|
||||
"QDRANT_URL",
|
||||
"http://100.121.180.82:6333",
|
||||
),
|
||||
cors_allowed_origins: get_env_with_warning(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev,https://backoffice.imphnen.dev,https://hackathon.imphnen.dev,https://qr.imphnen.dev,https://infra.imphnen.dev",
|
||||
|
||||
@@ -27,14 +27,24 @@ impl OtpManager {
|
||||
}
|
||||
|
||||
pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool {
|
||||
if Utc::now() > stored.expires_at {
|
||||
Self::validate_otp_hash(&stored.hash, &stored.expires_at, user_otp)
|
||||
}
|
||||
|
||||
/// Validate a user-supplied OTP against a stored hash + expiry (e.g. from a
|
||||
/// cache table where the plaintext code is not persisted).
|
||||
pub fn validate_otp_hash(
|
||||
stored_hash: &str,
|
||||
expires_at: &DateTime<Utc>,
|
||||
user_otp: u32,
|
||||
) -> bool {
|
||||
if Utc::now() > *expires_at {
|
||||
return false;
|
||||
}
|
||||
let user_otp_str = user_otp.to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(user_otp_str.as_bytes());
|
||||
let user_hash = format!("{:x}", hasher.finalize());
|
||||
user_hash == stored.hash
|
||||
user_hash == stored_hash
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +73,37 @@ mod tests {
|
||||
assert!(!OtpManager::validate_otp(&otp, 123456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_hash_valid() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(OtpManager::validate_otp_hash(
|
||||
&otp.hash,
|
||||
&otp.expires_at,
|
||||
otp.code
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_hash_invalid() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(!OtpManager::validate_otp_hash(
|
||||
&otp.hash,
|
||||
&otp.expires_at,
|
||||
123456
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_hash_expired() {
|
||||
let mut otp = OtpManager::generate_otp();
|
||||
otp.expires_at = Utc::now() - chrono::Duration::seconds(1);
|
||||
assert!(!OtpManager::validate_otp_hash(
|
||||
&otp.hash,
|
||||
&otp.expires_at,
|
||||
otp.code
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_expired() {
|
||||
let mut otp = OtpManager::generate_otp();
|
||||
|
||||
Reference in New Issue
Block a user