diff --git a/docs/google_oauth_integration.md b/docs/google_oauth_integration.md deleted file mode 100644 index e7d2708..0000000 --- a/docs/google_oauth_integration.md +++ /dev/null @@ -1,393 +0,0 @@ -# Google OAuth 2.1 Integration - -This document describes the implementation of Google OAuth 2.1 authentication in the backend service using PKCE (Proof Key for Code Exchange) flow with enhanced security measures and robust role management. - -## Overview - -The Google OAuth integration allows users to authenticate using their Google accounts. The implementation follows OAuth 2.1 best practices with PKCE for public clients and includes several security enhancements and intelligent role assignment. - -## Security Features - -### 1. PKCE (Proof Key for Code Exchange) -- Uses SHA256 challenge method -- Prevents authorization code interception attacks -- Required for OAuth 2.1 compliance - -### 2. CSRF Protection -- Custom signed state tokens for stateless CSRF protection -- Tokens are time-limited (10 minutes) -- Signature verification prevents tampering -- No server-side session storage required - -### 3. Input Validation -- Authorization code length and character validation -- State parameter validation -- Protection against injection attacks - -### 4. Secure Token Generation -- Separate secrets for access and refresh tokens -- Configurable token expiration times -- Strong random token generation - -### 5. Intelligent Role Assignment -- Configurable default role via environment variable -- Automatic role lookup by name ("User") -- Multiple fallback mechanisms to ensure robustness -- Integration with existing role management system - -## Configuration - -### Environment Variables - -```bash -GOOGLE_CLIENT_ID="your_google_client_id" -GOOGLE_CLIENT_SECRET="your_google_client_secret" -GOOGLE_REDIRECT_URL="https://your-backend-url.com/api/v1/auth/google/callback" -``` - -> Ganti `your-backend-url.com` dengan URL backend Anda yang sebenarnya. - -### Role Assignment Strategy - -The system automatically assigns the default "User" role to new Google OAuth users using the role ID from the seed data (`5713cb37-dc02-4e87-8048-d7a41d352059`). This ensures consistency with the database schema and eliminates the need for additional configuration. - -### Required Scopes - -The integration requests minimal required scopes: -- `https://www.googleapis.com/auth/userinfo.email` -- `https://www.googleapis.com/auth/userinfo.profile` - -### Obtaining Credentials from Google Cloud Console - -1. **Navigate to Google Cloud Console:** Go to the [Google Cloud Console](https://console.cloud.google.com/). -2. **Select/Create a Project:** Choose an existing project or create a new one. -3. **Enable Google People API:** In the navigation menu, go to `APIs & Services` > `Library` and search for "Google People API" and enable it. -4. **Create OAuth Consent Screen:** Go to `APIs & Services` > `OAuth consent screen`. - - Configure your consent screen, including application name, user support email, and developer contact information. -5. **Create Credentials:** Go to `APIs & Services` > `Credentials`. - - Click `Create Credentials` > `OAuth client ID`. - - Select "Web application" as the application type. - - Provide a name for your OAuth 2.0 client. - - Under `Authorized redirect URIs`, add the `GOOGLE_REDIRECT_URL` specified in your environment variables (e.g., `http://127.0.0.1:8080/api/v1/auth/google/callback`). - - Click "Create". Your Client ID and Client Secret will be displayed. Copy these values and set them as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in your environment. - -## API Endpoints - -### 1. Initiate OAuth Flow -**GET** `/api/v1/auth/google/login` - -Redirects user to Google OAuth authorization URL dengan: -- PKCE code challenge -- Signed CSRF state token -- Required scopes - -**Response:** HTTP 302 redirect ke Google OAuth - -**Example:** -```bash -curl -v https://your-backend-url.com/api/v1/auth/google/login -``` - -### 2. OAuth Callback -**GET** `/api/v1/auth/google/callback?code={code}&state={state}` - -Handles the OAuth callback from Google. - -**Parameters:** -- `code`: Authorization code from Google -- `state`: CSRF state token (must match the one issued) - -**Response:** -```json -{ - "user": { - "id": "user_id", - "role": { - "id": "5713cb37-dc02-4e87-8048-d7a41d352059", - "name": "User", - "is_deleted": false, - "permissions": [ - { - "id": "permission_id", - "name": "basic_access", - "description": "Basic user access" - } - ], - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - }, - "email": "user@example.com", - "fullname": "User Name", - "phone_number": "", - "is_active": true, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - }, - "token": { - "access_token": "jwt_access_token", - "refresh_token": "jwt_refresh_token" - } -} -``` - -**Example:** -```bash -curl -v "https://your-backend-url.com/api/v1/auth/google/callback?code=AUTH_CODE&state=CSRF_STATE" -``` - -## Flow Description - -1. **User clicks "Login with Google"** - - Frontend calls `/api/v1/auth/google/login` - - Server generates PKCE challenge and signed CSRF state - - User is redirected to Google OAuth - -2. **User authorizes application** - - Google redirects to callback URL with code and state - - Server validates CSRF state token signature and expiration - - Server validates authorization code format - -3. **Token exchange** - - Server exchanges authorization code for Google access token - - Fetches user profile from Google API - - Creates or updates user in database - -4. **Role assignment for new users** - - Uses configured `DEFAULT_USER_ROLE_ID` if available - - Falls back to querying database for "User" role - - Final fallback to hardcoded User role ID - -5. **JWT token generation** - - Generates access token (15 min expiry) - - Generates refresh token (1 day expiry) - - Returns user data with complete role information and tokens - -## Error Handling - -### Authentication Errors (401) -- Invalid or expired CSRF state token -- Failed token exchange with Google -- Token generation failures - -### Validation Errors (400) -- Invalid authorization code format -- Missing or malformed parameters -- CSRF token validation failures - -### Server Errors (500) -- Database connection issues -- Google API communication failures -- Role lookup failures (with fallback mechanisms) -- Internal processing errors - -## Role Management Integration - -### Available Roles -The system integrates with the existing role management system. Current default roles include: -- **User** (`5713cb37-dc02-4e87-8048-d7a41d352059`) - Default role for new users -- **Admin** (`f6b03f25-e416-4893-ac88-caaa690afb07`) - Administrative access -- **Staff** (`50133429-f4b1-4249-9f97-7b86e6ee9d86`) - Staff-level access -- **Mentor** - For mentoring platform features - -### Role Assignment Process -1. **Configuration check**: Uses `DEFAULT_USER_ROLE_ID` environment variable -2. **Dynamic lookup**: Queries roles table for "User" role by name -3. **Fallback protection**: Uses seeded User role ID if all else fails -4. **Audit logging**: All role assignment decisions are logged - -### Role Permissions -Each role includes associated permissions that control user access within the application. The complete role and permission information is returned in the OAuth callback response. - -## Security Considerations - -### Implemented ✅ -- **PKCE flow**: Prevents code interception attacks -- **CSRF protection**: Signed state tokens with time limits -- **Input validation**: All parameters validated for format and content -- **Minimal scopes**: Only email and profile requested -- **Separate JWT secrets**: Different secrets for access/refresh tokens -- **Token expiration**: Reasonable expiry times (15min/1day) -- **Audit logging**: OAuth events logged for monitoring -- **Secure defaults**: Safe fallbacks for configuration -- **Role-based access**: Integration with permission system -- **Robust role assignment**: Multiple fallback mechanisms - -### Additional Recommendations -- Use HTTPS in production environments -- Implement rate limiting for auth endpoints -- Monitor for suspicious OAuth activity patterns -- Regular security audits and dependency updates -- Consider implementing additional 2FA for admin users -- Set up alerts for authentication failures -- Monitor role assignment patterns for anomalies - -## Testing - -### Prerequisites -1. Valid Google account credentials -2. Properly configured redirect URLs in Google Console -3. Valid environment variables set -4. Database with seeded roles - -### Test Cases -1. **Successful OAuth Flow** - ```bash - # Step 1: Initiate flow - curl -v http://127.0.0.1:8080/api/v1/auth/google/login - - # Step 2: Follow redirect and complete OAuth - # Step 3: Verify callback returns valid JWT tokens and user with role - ``` - -2. **Role Assignment** - - Test with configured `DEFAULT_USER_ROLE_ID` - - Test with missing role configuration (fallback mechanisms) - - Test with invalid role ID (should use fallback) - -3. **CSRF Protection** - - Test with invalid state token - - Test with expired state token - - Test with tampered state token - -4. **Input Validation** - - Test with malformed authorization codes - - Test with missing parameters - - Test with oversized parameters - -### Monitoring -- Monitor authentication success/failure rates -- Track CSRF validation failures -- Monitor token generation latency -- Alert on unexpected error patterns -- Track role assignment patterns - -## Implementation Details - -### Key Components - -1. **GoogleOauthServiceImpl** - - Handles OAuth flow logic - - Manages PKCE and CSRF tokens - - Integrates with Google APIs - - Implements intelligent role assignment - -2. **get_default_role_id() Helper** - - Hierarchical role lookup strategy - - Database integration for dynamic role resolution - - Robust fallback mechanisms - -3. **CSRF Token Module** - - Generates signed state tokens - - Validates token signatures and expiration - - Stateless design (no server storage) - -4. **Error Handling** - - Comprehensive error types - - Proper HTTP status codes - - Security-aware error messages - -### Dependencies - -- `oauth2` - OAuth 2.1 client implementation -- `reqwest` - HTTP client for Google API calls -- `jsonwebtoken` - JWT token generation -- `uuid` - Random ID generation -- `base64` - Token encoding -- `sha2` - Cryptographic hashing for CSRF tokens -- `serde` - JSON serialization/deserialization -- `tracing` - Structured logging -- `anyhow` - Error handling - -## Changelog - -### Version 1.2 (Current) -- ✅ Improved role assignment with database integration -- ✅ Added intelligent role lookup with fallback mechanisms -- ✅ Enhanced error handling for role resolution -- ✅ Complete role and permission information in responses -- ✅ Robust configuration management - -### Version 1.1 -- ✅ Added CSRF protection with signed state tokens -- ✅ Implemented comprehensive input validation -- ✅ Added configurable default roles for new users -- ✅ Enhanced error handling with proper HTTP status codes -- ✅ Added audit logging for security events - -### Version 1.0 -- Initial Google OAuth 2.1 implementation -- Basic PKCE flow support -- JWT token generation -- User creation/update logic - -**Note**: Ensure the redirect URL in Google Console matches exactly the `GOOGLE_REDIRECT_URL` environment variable. - ---- - -## Contoh Implementasi Frontend (React) - -Berikut contoh sederhana implementasi login Google di React menggunakan window.location untuk redirect ke backend: - -```jsx -// src/components/GoogleLoginButton.jsx -import React from 'react'; - -const GOOGLE_AUTH_URL = 'https://your-backend-url.com/api/v1/auth/google/login'; - -function GoogleLoginButton() { - const handleLogin = () => { - window.location.href = GOOGLE_AUTH_URL; - }; - - return ( - - ); -} - -export default GoogleLoginButton; -``` - -> Pastikan URL pada `GOOGLE_AUTH_URL` sesuai dengan endpoint backend Anda. - -Setelah login berhasil, backend akan mengarahkan kembali ke frontend sesuai pengaturan `GOOGLE_REDIRECT_URL`. - -Untuk implementasi lebih lanjut, Anda bisa menggunakan library seperti `react-google-login` atau `@react-oauth/google` jika ingin autentikasi langsung di frontend, namun untuk skenario ini, login dialihkan ke backend. - ---- - -### Contoh Login Google dengan Popup di React - -Berikut contoh login Google menggunakan popup agar user tetap di halaman utama: - -```jsx -// src/components/GoogleLoginPopup.jsx -import React from 'react'; - -const GOOGLE_AUTH_URL = 'https://your-backend-url.com/api/v1/auth/google/login'; - -function GoogleLoginPopup({ onSuccess }) { - const handleLogin = () => { - const popup = window.open(GOOGLE_AUTH_URL, 'google-oauth', 'width=500,height=600'); - const timer = setInterval(() => { - if (popup.closed) { - clearInterval(timer); - if (onSuccess) onSuccess(); - } - }, 500); - }; - - return ( - - ); -} - -export default GoogleLoginPopup; -``` - -> Setelah user login di popup dan backend redirect ke frontend, Anda bisa trigger refresh data user atau reload halaman. - -Untuk komunikasi lebih advance antara popup dan parent, gunakan `window.postMessage` untuk mengirim data dari backend ke frontend setelah login berhasil. diff --git a/imphnen-backend/src/bin/seed_mentor_user.rs b/imphnen-backend/src/bin/seed_mentor_user.rs index 97ab72d..c5940a8 100644 --- a/imphnen-backend/src/bin/seed_mentor_user.rs +++ b/imphnen-backend/src/bin/seed_mentor_user.rs @@ -26,7 +26,7 @@ async fn main() -> Result<(), Box> { .await?; use surrealdb::sql::Thing; - db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, created_at = $created_at, updated_at = $updated_at") + db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, legal_name = $legal_name, domicile = $domicile, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, last_education = $last_education, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, portfolio_url = $portfolio_url, created_at = $created_at, updated_at = $updated_at") .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("fullname", "Mentor User")) .bind(("email", "mentor@example.com")) @@ -39,20 +39,23 @@ async fn main() -> Result<(), Box> { .bind(("gender", "male")) .bind(("birthdate", "1990-05-15")) .bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a")))) + .bind(("legal_name", "Mentor User")) + .bind(("domicile", "Jakarta, Indonesia")) + // .bind(("identity_document_url", "https://example.com/ktp.jpg")) + .bind(("phone_for_verification", "081234567890")) + .bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.")) + .bind(("last_education", "S1 Teknik Informatika")) + .bind(("linkedin_url", "https://linkedin.com/in/mentor")) + .bind(("github_url", "https://github.com/mentor")) + .bind(("cv_url", Option::::None)) + .bind(("portfolio_url", Option::::None)) .bind(("created_at", get_iso_date())) .bind(("updated_at", get_iso_date())) .await?; - db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, legal_name = $legal_name, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at, email = $email") + db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at") .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901")))) - .bind(("legal_name", "Mentor User")) - .bind(("identity_document_url", "https://example.com/ktp.jpg")) - .bind(("phone_for_verification", "081234567890")) - .bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.")) - .bind(("linkedin_url", "https://linkedin.com/in/mentor")) - .bind(("github_url", "https://github.com/mentor")) - .bind(("cv_url", Option::::None)) .bind(("industries", vec!["Software", "Education"])) .bind(("expertise", vec!["Rust", "Microservices"])) .bind(("languages", vec!["Indonesian", "English"])) @@ -72,7 +75,6 @@ async fn main() -> Result<(), Box> { .bind(("is_deleted", false)) .bind(("created_at", get_iso_date())) .bind(("updated_at", get_iso_date())) - .bind(("email", "mentor@example.com")) .await?; println!("Mentor created successfully!"); println!("Updating user with mentor_id..."); diff --git a/imphnen-backend/src/bin/seed_users.rs b/imphnen-backend/src/bin/seed_users.rs index a6ef540..3ae5f9f 100644 --- a/imphnen-backend/src/bin/seed_users.rs +++ b/imphnen-backend/src/bin/seed_users.rs @@ -46,25 +46,25 @@ async fn main() -> Result<(), Box> { let user = UsersSchema { id: Thing::from(("app_users", id)), fullname: fullname.into(), - legal_name: None, + legal_name: Some(format!("{} Legal Name", fullname)), email: email.into(), password: hash_password("password").unwrap(), - avatar: None, + avatar: Some("https://example.com/avatar.jpg".into()), phone_number: "081234567890".into(), - phone_for_verification: None, + phone_for_verification: Some("081234567890".into()), is_active: true, is_deleted: false, mentor_id: None, - gender: None, - birthdate: None, - domicile: None, - identity_document_url: None, - bio: None, - last_education: None, - linkedin_url: None, - github_url: None, - cv_url: None, - portfolio_url: None, + gender: Some("male".into()), + birthdate: Some("1990-05-15".into()), + domicile: Some("Jakarta, Indonesia".into()), + // identity_document_url: None, // Sudah tidak dipakai, bisa dihapus dari schema jika tidak diperlukan + bio: Some(format!("{} adalah user dengan data pribadi lengkap untuk testing.", fullname)), + last_education: Some("S1 Teknik Informatika".into()), + linkedin_url: Some("https://linkedin.com/in/user".into()), + github_url: Some("https://github.com/user".into()), + cv_url: Some("https://example.com/cv.pdf".into()), + portfolio_url: Some("https://example.com/portfolio".into()), role: Thing::from(("app_roles", role_id)), created_at: get_iso_date(), updated_at: get_iso_date(), diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs b/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs index a7ebbe3..2e5b98c 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs @@ -18,15 +18,9 @@ pub struct MentorListResponseDto { pub struct MentorDetailWithUserDto { pub id: Thing, pub user_id: Thing, - pub fullname: Option, - pub email: Option, - pub legal_name: String, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, + // Personal data is now in UsersSchema, access via user_id + // Removed: fullname, email, legal_name, identity_document_url, + // phone_for_verification, bio, linkedin_url, github_url, cv_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -47,15 +41,20 @@ pub struct MentorDetailWithUserDto { pub struct MentorDetailResponseDto { pub id: String, pub user_id: String, + // Personal data fields from UsersSchema pub fullname: Option, pub email: Option, - pub legal_name: String, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, + pub legal_name: Option, + pub gender: Option, + pub domicile: Option, + pub phone_for_verification: Option, + pub bio: Option, + pub last_education: Option, pub linkedin_url: Option, pub github_url: Option, pub cv_url: Option, + pub portfolio_url: Option, + // Professional data from MentorSchema pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -94,9 +93,6 @@ pub struct MentorUpdateRequestDto { pub gender: Option, #[serde(skip_serializing_if = "Option::is_none")] pub domicile: Option, - #[validate(url(message = "Invalid identity document URL"))] - #[serde(skip_serializing_if = "Option::is_none")] - pub identity_document_url: Option, #[validate(length( min = 10, max = 15, @@ -279,18 +275,7 @@ pub struct MentoringRate { pub struct MentorInsertDto { pub id: Thing, pub user_id: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data removed - now stored in UsersSchema pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -313,18 +298,7 @@ impl From for MentorInsertDto { MentorInsertDto { id: schema.id, user_id: schema.user_id, - email: schema.email, - legal_name: schema.legal_name, - gender: schema.gender, - domicile: schema.domicile, - identity_document_url: schema.identity_document_url, - phone_for_verification: schema.phone_for_verification, - bio: schema.bio, - last_education: schema.last_education, - linkedin_url: schema.linkedin_url, - github_url: schema.github_url, - cv_url: schema.cv_url, - portfolio_url: schema.portfolio_url, + // Personal data removed from MentorSchema industries: schema.industries, expertise: schema.expertise, languages: schema.languages, @@ -354,19 +328,10 @@ pub struct MentorVerifyRequestDto { pub struct MentorDetailQueryDto { pub id: Thing, pub user_id: Thing, - pub fullname: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data has been moved to UsersSchema + // Use user_id to get: fullname, email, legal_name, gender, domicile, + // identity_document_url, phone_for_verification, bio, last_education, + // linkedin_url, github_url, cv_url, portfolio_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -388,8 +353,8 @@ impl From for MentorListResponseDto { fn from(dto: MentorDetailQueryDto) -> Self { Self { id: extract_id(&dto.id), - fullname: dto.fullname, - email: dto.email, + fullname: None, // now in user table, must be populated from service layer + email: None, // now in user table, must be populated from service layer status: dto.status, created_at: dto.created_at, updated_at: dto.updated_at, @@ -402,15 +367,20 @@ impl From for MentorDetailResponseDto { Self { id: extract_id(&dto.id), user_id: extract_id(&dto.user_id), - fullname: dto.fullname, - email: dto.email, - legal_name: dto.legal_name, - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, + // Personal data fields are populated in service layer from UsersSchema + fullname: None, // populated from user table in service layer + email: None, // populated from user table in service layer + legal_name: None, // populated from user table in service layer + gender: None, // populated from user table in service layer + domicile: None, // populated from user table in service layer + phone_for_verification: None, // populated from user table in service layer + bio: None, // populated from user table in service layer + last_education: None, // populated from user table in service layer + linkedin_url: None, // populated from user table in service layer + github_url: None, // populated from user table in service layer + cv_url: None, // populated from user table in service layer + portfolio_url: None, // populated from user table in service layer + // Professional data from mentor industries: dto.industries, expertise: dto.expertise, languages: dto.languages, @@ -433,7 +403,7 @@ impl From for MentorRegisterResponseDto { Self { id: schema.id.to_string(), user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(), - email: schema.email, + email: None, // schema.email - now in user table status: schema.status, created_at: schema.created_at, updated_at: schema.updated_at, @@ -446,19 +416,7 @@ impl From for MentorDetailQueryDto { MentorDetailQueryDto { id: dto.id, user_id: dto.user_id, - fullname: dto.fullname, - email: dto.email, - legal_name: dto.legal_name, - gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, - portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto + // Personal data removed from MentorDetailWithUserDto - now in UsersSchema industries: dto.industries, expertise: dto.expertise, languages: dto.languages, diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs b/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs index 50cbac3..54d103f 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs @@ -28,19 +28,11 @@ impl<'a> MentorsRepository<'a> { let db = &self.state.surrealdb_ws; let mentors_table = ResourceEnum::Mentors.to_string(); let builder = QueryListBuilder::new(db, &mentors_table, &meta) - .search_field("legal_name") + .search_field("user_id.legal_name") // Search in user data instead .select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", @@ -80,19 +72,11 @@ impl<'a> MentorsRepository<'a> { let now = Instant::now(); let db = &self.state.surrealdb_ws; let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string()) - .with_where("email", Some(email.clone())) + .with_where("user_id.email", Some(email.clone())) // Search in user table .with_select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", @@ -143,15 +127,7 @@ impl<'a> MentorsRepository<'a> { .with_select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs b/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs index b9c7b2d..f61d696 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs @@ -1,5 +1,5 @@ use super::{ - IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto, + MentorDetailQueryDto, MentorUpdateRequestDto, MentoringLogistics, MentoringRate, ProfessionalProfile, }; use imphnen_libs::ResourceEnum; @@ -12,18 +12,9 @@ pub struct MentorSchema { pub id: Thing, #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data has been moved to UsersSchema - use user_id to reference + // phone_for_verification, bio, last_education, linkedin_url, github_url, + // cv_url, portfolio_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -52,18 +43,6 @@ impl Default for MentorSchema { ResourceEnum::Users.to_string().as_str(), &Uuid::new_v4().to_string(), )), - email: None, - legal_name: String::new(), - gender: None, - domicile: None, - identity_document_url: String::new(), - phone_for_verification: String::new(), - bio: String::new(), - last_education: None, - linkedin_url: None, - github_url: None, - cv_url: None, - portfolio_url: None, industries: Vec::new(), expertise: Vec::new(), languages: Vec::new(), @@ -89,11 +68,9 @@ impl Default for MentorSchema { impl MentorSchema { pub fn create( - identity_and_verification: IdentityAndVerification, professional_profile: ProfessionalProfile, mentoring_logistics: MentoringLogistics, user_id_raw: String, - email_str: String, ) -> Self { Self { id: make_thing( @@ -101,18 +78,7 @@ impl MentorSchema { &Uuid::new_v4().to_string(), ), user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)), - email: Some(email_str), - legal_name: identity_and_verification.legal_name, - gender: identity_and_verification.gender, - domicile: identity_and_verification.domicile, - identity_document_url: identity_and_verification.identity_document_url, - phone_for_verification: identity_and_verification.phone_for_verification, - bio: professional_profile.bio, - last_education: professional_profile.last_education, - linkedin_url: professional_profile.linkedin_url, - github_url: professional_profile.github_url, - cv_url: professional_profile.cv_url, - portfolio_url: professional_profile.portfolio_url, + // Personal data now stored in UsersSchema, not here industries: professional_profile.industries, expertise: professional_profile.expertise, languages: professional_profile.languages, @@ -139,18 +105,7 @@ impl MentorSchema { Self { id: dto.id, user_id: Some(dto.user_id), - email: dto.email, - legal_name: dto.legal_name, - gender: dto.gender, - domicile: dto.domicile, - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - last_education: dto.last_education, - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, - portfolio_url: dto.portfolio_url, + // Personal data now comes from UsersSchema via user_id industries: dto.industries, expertise: dto.expertise, languages: dto.languages, @@ -170,40 +125,10 @@ impl MentorSchema { } pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self { - // Update fields only if they are Some(value), otherwise preserve current value - if let Some(val) = dto.legal_name { - self.legal_name = val; - } - if let Some(val) = dto.gender { - self.gender = Some(val); - } - if let Some(val) = dto.domicile { - self.domicile = Some(val); - } - if let Some(val) = dto.identity_document_url { - self.identity_document_url = val; - } - if let Some(val) = dto.phone_for_verification { - self.phone_for_verification = val; - } - if let Some(val) = dto.bio { - self.bio = val; - } - if let Some(val) = dto.last_education { - self.last_education = Some(val); - } - if let Some(val) = dto.linkedin_url { - self.linkedin_url = Some(val); - } - if let Some(val) = dto.github_url { - self.github_url = Some(val); - } - if let Some(val) = dto.cv_url { - self.cv_url = Some(val); - } - if let Some(val) = dto.portfolio_url { - self.portfolio_url = Some(val); - } + // phone_for_verification, bio, last_education, linkedin_url, github_url, + // cv_url, portfolio_url) are now updated in UsersSchema, not here + + // Only update professional fields that are still in MentorSchema if let Some(val) = dto.industries { self.industries = val; } diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_service.rs b/imphnen-dimentorin/src/v1/mentors/mentors_service.rs index 6c8d633..44ad29c 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_service.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_service.rs @@ -60,6 +60,18 @@ impl MentorsService { user_schema.fullname = dto.fullname.clone(); user_schema.phone_number = dto.phone_number.clone(); + // Update personal data from identity_and_verification + user_schema.legal_name = Some(dto.identity_and_verification.legal_name.clone()); + user_schema.gender = dto.identity_and_verification.gender.clone(); + user_schema.domicile = dto.identity_and_verification.domicile.clone(); + user_schema.phone_for_verification = Some(dto.identity_and_verification.phone_for_verification.clone()); + // Update personal data from professional_profile + user_schema.bio = Some(dto.professional_profile.bio.clone()); + user_schema.last_education = dto.professional_profile.last_education.clone(); + user_schema.linkedin_url = dto.professional_profile.linkedin_url.clone(); + user_schema.github_url = dto.professional_profile.github_url.clone(); + user_schema.cv_url = dto.professional_profile.cv_url.clone(); + user_schema.portfolio_url = dto.professional_profile.portfolio_url.clone(); user_schema.updated_at = imphnen_utils::get_iso_date(); let hashed_password = match imphnen_utils::hash_password(&dto.password) { @@ -135,6 +147,18 @@ impl MentorsService { fullname: dto.fullname.clone(), password: hashed_password, phone_number: dto.phone_number.clone(), + // Store personal data from identity_and_verification in user + legal_name: Some(dto.identity_and_verification.legal_name.clone()), + gender: dto.identity_and_verification.gender.clone(), + domicile: dto.identity_and_verification.domicile.clone(), + phone_for_verification: Some(dto.identity_and_verification.phone_for_verification.clone()), + // Store personal data from professional_profile in user + bio: Some(dto.professional_profile.bio.clone()), + last_education: dto.professional_profile.last_education.clone(), + linkedin_url: dto.professional_profile.linkedin_url.clone(), + github_url: dto.professional_profile.github_url.clone(), + cv_url: dto.professional_profile.cv_url.clone(), + portfolio_url: dto.professional_profile.portfolio_url.clone(), created_at: imphnen_utils::get_iso_date(), updated_at: imphnen_utils::get_iso_date(), role: imphnen_utils::make_thing( @@ -187,11 +211,9 @@ impl MentorsService { } let mentor_schema = MentorSchema::create( - dto.identity_and_verification, dto.professional_profile, dto.mentoring_logistics, user_id.to_raw(), - final_user_email.clone(), ); match mentor_repo.query_create_mentor(mentor_schema.clone()).await { @@ -233,16 +255,27 @@ impl MentorsService { pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response { let repo = MentorsRepository::new(state); + let user_repo = UsersRepository::new(state); + match repo.query_mentor_list(meta).await { Ok(result) => { - let data: Vec = result - .data - .into_iter() - .map(MentorDetailQueryDto::from) - .map(MentorListResponseDto::from) - .collect(); + let mut mentor_list_data: Vec = Vec::new(); + + for mentor_with_user in result.data { + let mentor_dto = MentorDetailQueryDto::from(mentor_with_user); + let mut list_item = MentorListResponseDto::from(mentor_dto.clone()); + + // Get user data to populate personal fields + if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await { + list_item.fullname = Some(user.fullname); + list_item.email = Some(user.email); + } + + mentor_list_data.push(list_item); + } + success_list_response(ResponseListSuccessDto { - data, + data: mentor_list_data, meta: result.meta, }) } @@ -251,12 +284,56 @@ impl MentorsService { } pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response { - let repo = MentorsRepository::new(state); + let mentor_repo = MentorsRepository::new(state); + let user_repo = UsersRepository::new(state); let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id)); - match repo.query_mentor_by_id(&thing_id, false).await { + + match mentor_repo.query_mentor_by_id(&thing_id, false).await { Ok(mentor) => { - let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor)); - success_response(ResponseSuccessDto { data: dto }) + // Get user data separately + let user_result = user_repo.query_user_by_id(&mentor.user_id).await; + match user_result { + Ok(user) => { + // Combine mentor and user data + let dto = MentorDetailResponseDto { + id: mentor.id.to_raw(), + user_id: mentor.user_id.to_raw(), + // Personal data from user + fullname: Some(user.fullname), + email: Some(user.email), + legal_name: user.legal_name, + gender: user.gender, + domicile: user.domicile, + phone_for_verification: user.phone_for_verification, + bio: user.bio, + last_education: user.last_education, + linkedin_url: user.linkedin_url, + github_url: user.github_url, + cv_url: user.cv_url, + portfolio_url: user.portfolio_url, + // Professional data from mentor + industries: mentor.industries, + expertise: mentor.expertise, + languages: mentor.languages, + current_company: mentor.current_company, + current_role: mentor.current_role, + years_of_experience: mentor.years_of_experience, + topics_of_interest: mentor.topics_of_interest, + preferred_mentee_level: mentor.preferred_mentee_level, + preferred_mentoring_formats: mentor.preferred_mentoring_formats, + availability_commitment: mentor.availability_commitment, + mentoring_rate: mentor.mentoring_rate, + status: mentor.status, + created_at: mentor.created_at, + updated_at: mentor.updated_at, + }; + success_response(ResponseSuccessDto { data: dto }) + } + Err(_e) => { + error!("Failed to get user data for mentor {}: {}", id, _e); + common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get mentor user data") + } + } } Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()), } diff --git a/imphnen-iam/src/v1/auth/auth_repository.rs b/imphnen-iam/src/v1/auth/auth_repository.rs index a4e92a3..5b877ee 100644 --- a/imphnen-iam/src/v1/auth/auth_repository.rs +++ b/imphnen-iam/src/v1/auth/auth_repository.rs @@ -104,7 +104,6 @@ impl<'a> AuthRepository<'a> { gender: None, birthdate: None, domicile: None, - identity_document_url: None, bio: None, last_education: None, linkedin_url: None, diff --git a/imphnen-iam/src/v1/users/users_dto.rs b/imphnen-iam/src/v1/users/users_dto.rs index e296b4e..cfb4dcc 100644 --- a/imphnen-iam/src/v1/users/users_dto.rs +++ b/imphnen-iam/src/v1/users/users_dto.rs @@ -82,7 +82,6 @@ pub struct UsersUpdateRequestDto { pub birthdate: Option, pub domicile: Option, #[validate(url(message = "Invalid identity document URL"))] - pub identity_document_url: Option, #[validate(length(min = 50, message = "Bio must be at least 50 characters"))] pub bio: Option, pub last_education: Option, @@ -113,7 +112,6 @@ pub struct UsersDetailItemDto { pub gender: Option, pub birthdate: Option, pub domicile: Option, - pub identity_document_url: Option, pub bio: Option, pub last_education: Option, pub linkedin_url: Option, @@ -139,7 +137,6 @@ impl UsersDetailItemDto { gender: dto.gender.clone(), birthdate: dto.birthdate.clone(), domicile: dto.domicile.clone(), - identity_document_url: dto.identity_document_url.clone(), bio: dto.bio.clone(), last_education: dto.last_education.clone(), linkedin_url: dto.linkedin_url.clone(), @@ -165,7 +162,6 @@ impl UsersDetailItemDto { gender: schema.gender.clone(), birthdate: schema.birthdate.clone(), domicile: schema.domicile.clone(), - identity_document_url: schema.identity_document_url.clone(), bio: schema.bio.clone(), last_education: schema.last_education.clone(), linkedin_url: schema.linkedin_url.clone(), @@ -234,7 +230,6 @@ pub struct UsersDetailQueryDto { pub gender: Option, pub birthdate: Option, pub domicile: Option, - pub identity_document_url: Option, pub bio: Option, pub last_education: Option, pub linkedin_url: Option, @@ -263,7 +258,6 @@ impl UsersDetailQueryDto { mentor_id: self.mentor_id.clone(), gender: self.gender.clone(), domicile: self.domicile.clone(), - identity_document_url: self.identity_document_url.clone(), bio: self.bio.clone(), last_education: self.last_education.clone(), linkedin_url: self.linkedin_url.clone(), @@ -294,7 +288,6 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto { gender: dto.gender.clone(), birthdate: dto.birthdate.clone(), domicile: dto.domicile.clone(), - identity_document_url: dto.identity_document_url.clone(), bio: dto.bio.clone(), last_education: dto.last_education.clone(), linkedin_url: dto.linkedin_url.clone(), diff --git a/imphnen-iam/src/v1/users/users_schema.rs b/imphnen-iam/src/v1/users/users_schema.rs index 27fa146..4aba6aa 100644 --- a/imphnen-iam/src/v1/users/users_schema.rs +++ b/imphnen-iam/src/v1/users/users_schema.rs @@ -29,8 +29,6 @@ pub struct UsersSchema { #[serde(skip_serializing_if = "Option::is_none")] pub domicile: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub identity_document_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub bio: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_education: Option, @@ -70,7 +68,6 @@ impl Default for UsersSchema { gender: None, birthdate: None, domicile: None, - identity_document_url: None, bio: None, last_education: None, linkedin_url: None, @@ -108,7 +105,6 @@ impl UsersSchema { gender: dto.gender, birthdate: dto.birthdate, domicile: dto.domicile, - identity_document_url: dto.identity_document_url, bio: dto.bio, last_education: dto.last_education, linkedin_url: dto.linkedin_url, @@ -134,7 +130,6 @@ impl UsersSchema { gender: user.gender, birthdate: user.birthdate, domicile: user.domicile, - identity_document_url: user.identity_document_url, bio: user.bio, last_education: user.last_education, linkedin_url: user.linkedin_url, @@ -170,7 +165,6 @@ impl UsersSchema { gender: None, birthdate: None, domicile: None, - identity_document_url: None, bio: None, last_education: None, linkedin_url: None, diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 9501351..5507a43 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -31,7 +31,6 @@ pub fn create_test_user( gender: None, birthdate: None, domicile: None, - identity_document_url: None, bio: None, last_education: None, linkedin_url: None, diff --git a/tests/src/mock_test.rs b/tests/src/mock_test.rs index a30680d..719f6d2 100644 --- a/tests/src/mock_test.rs +++ b/tests/src/mock_test.rs @@ -161,7 +161,6 @@ pub async fn seed_users_for_test( gender: None, birthdate: None, domicile: None, - identity_document_url: None, bio: None, last_education: None, linkedin_url: None,