Compare commits

..
Author SHA1 Message Date
maulanasdqnandClaude Sonnet 4.6 e432a1a743 refactor: migrate to clean architecture with trait-based DI (v0.2.0)
Complete architectural overhaul across all 12 crates:

- Replace validator crate with zod-rs for all DTO validation
- Replace manual pagination with paginator-rs/paginator-sea-orm
- Migrate all modules (iam, cms, gacha, dimentorin) to clean architecture:
  domain → application → infrastructure layers
- Introduce trait-based DI (Arc<dyn Trait>) at every layer for repositories and services
- Delete all v1/ legacy SurrealDB-era code across every crate
- Replace opaque response helpers with typed IntoResponse structs (ApiSuccess, ApiCreated, ApiPaginated, ApiMessage)
- Remove dual_mode_repository, migration_validation_errors, validator.rs dead code
- Zero cargo clippy warnings; release build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:39:52 +07:00
Maulana SodiqinandGitHub 1b3366d735 Merge pull request #47 from IMPHNEN/feat/postgress
postgress
2026-01-15 14:20:18 +07:00
MythEclipse b429b3a9c7 postgress 2025-12-01 00:20:42 +07:00
Maulana SodiqinandGitHub 6fe495eed1 Merge pull request #46 from IMPHNEN/submissions
Submissions
2025-10-29 17:13:16 +07:00
MythEclipse 97c2fce7be Refactor API endpoints for consistency and clarity
- Updated route paths for hackathon submissions, notifications, registrations, and teams to include more descriptive actions (e.g., "update", "create", "delete").
- Removed deprecated routes and adjusted corresponding test cases to reflect new endpoint structures.
- Enhanced test scripts to ensure compatibility with updated API routes and improved error handling for OTP resend functionality.
- Adjusted server startup script for better Windows compatibility and streamlined process management.
2025-10-29 14:27:07 +07:00
MythEclipse 98c46611fb refactor: Update deployment script for Rust binaries with improved comments and error handling 2025-10-28 21:58:49 +07:00
MythEclipse fbfe3dcd51 refactor: Remove unused imports and clean up status update logic in hackathon service and controller 2025-10-28 19:57:43 +07:00
MythEclipse 5cc5a3dfe1 feat: Implement hackathon status change functionality with audit logging
- Added HackathonStatusChangeRequestDto for status change requests.
- Implemented update_hackathon_status method in HackathonRepository to handle status updates.
- Enhanced HackathonService to validate and process status changes, including audit logging.
- Introduced HackathonAuditLogSchema to track changes and actions related to hackathons.
- Created HackathonAuditRepository for managing audit logs.
- Added validation functions for hackathon operations, including dates, organizers, and prizes.
- Implemented atomic service for creating hackathons with timelines and events, ensuring all-or-nothing behavior.
- Updated mod.rs to include new modules for audit logging and validation.
2025-10-28 19:55:38 +07:00
MythEclipse 95ae55c9df refactor: Simplify string conversion for hackathon and timeline attributes in seed test submission 2025-10-28 18:47:52 +07:00
MythEclipse f7ca67d720 feat: Add contact fields to hackathon and submission data; update registration service to handle string ID 2025-10-28 17:34:03 +07:00
MythEclipse 02421e9bc2 fix: Remove unused HeaderMap import from payment middleware 2025-10-28 14:59:26 +07:00
MythEclipse 5e2b0d3caf feat: Add session counting methods for mentors and users; enhance registration queries with related data 2025-10-28 14:57:20 +07:00
MythEclipse b9a51ce6cc feat: Enhance validation and permissions handling across controllers
- Added `ValidatedJson` extractor for automatic JSON validation in `events_controller.rs`, `testimonials_controller.rs`, `mentors_controller.rs`, `gacha_items_controller.rs`, and `hackathon_controller.rs`.
- Replaced manual permission checks with `require_permissions!` and `require_auth!` macros in relevant controllers to streamline permission handling.
- Introduced `sanitization` utilities in `sanitization.rs` for improved input sanitization.
- Added `permission_macros.rs` to encapsulate permission checking logic and reduce boilerplate.
- Updated dependencies in `Cargo.toml` to include `serde_json` and `validator`.
- Implemented error handling improvements in `notification_service.rs` for better response management.
2025-10-28 14:04:41 +07:00
MythEclipse d4a6c4c9ea Add new test suites for registrations and notifications; update existing tests for improved error handling and security checks
- Updated `run-tests.sh` to include new test suites for registrations and notifications.
- Modified `test-cms.sh` to skip SQL injection tests due to query timeout issues and adjusted expected status codes for XSS tests.
- Adjusted expected status codes in `test-auth.sh` for SQL injection and XSS tests; updated missing password test to return 422.
- Updated `test-roles-permissions.sh` to expect 409 for duplicate role creation.
- Changed expected status for duplicate user creation in `test-users.sh` to 409.
- Added comprehensive tests for notification endpoints in `test-notifications.sh`, including edge cases and pagination.
- Created `test-registrations.sh` to cover hackathon registration endpoints, including registration, approval, and check-in processes.
2025-10-28 10:27:19 +07:00
MythEclipse ece6499e2b feat: Implement hackathon registration module with controller, DTOs, repository, schema, and service
- Added registration_controller.rs to handle registration-related routes and logic.
- Created registration_dto.rs for data transfer objects related to registrations.
- Implemented registration_repository.rs for database interactions concerning registrations.
- Defined registration_schema.rs to represent the registration data structure.
- Developed registration_service.rs to encapsulate business logic for registrations.
- Established routes for registering, listing, updating, and checking in participants for hackathons.
- Added validation for registration requests and status updates.
- Included statistics retrieval for hackathon registrations.
2025-10-27 19:53:05 +07:00
MythEclipse 1caaa8404b a 2025-10-27 19:23:29 +07:00
MythEclipse cb6eef2054 feat: Add endpoint to retrieve the current user's team and corresponding service logic 2025-10-27 17:44:13 +07:00
MythEclipse e77797d7bb feat: Update hackathon event and submission routes to require admin permissions and adjust response descriptions 2025-10-27 17:26:53 +07:00
MythEclipse d5ccf4cf75 Add comprehensive security tests for authentication, roles, and user management
- Enhance `test-auth.sh` with SQL injection, XSS, and credential validation tests.
- Extend `test-roles-permissions.sh` to include unauthorized access and duplicate role creation tests.
- Improve `test-users.sh` with checks for invalid emails, duplicate users, and unauthorized actions.
- Introduce `test-security.sh` for thorough security assessments including CSRF, SQL injection, XSS, rate limiting, and session management.
- Add `.serena.gitignore` and `.serena/project.yml` for project configuration and file management.
2025-10-27 16:33:08 +07:00
MythEclipse 1a36698962 feat: Improve validation logic in HackathonService by adding a helper function and using parameterized queries 2025-10-24 18:14:09 +07:00
MythEclipse 15a2ee1950 feat: Enhance hackathon submission process with additional fields and validation checks 2025-10-24 18:06:47 +07:00
MythEclipse bef8482402 feat: Add response validation functions and examples for API tests 2025-10-24 15:42:06 +07:00
MythEclipse 768e583f81 a 2025-10-24 15:34:04 +07:00
MythEclipse 1ca6d8f47c Add comprehensive tests for CMS, Gacha, Hackathon, IAM, and User Management endpoints
- Implemented tests for Events and Testimonials endpoints in `test-cms.sh`
- Added common functions and variables for API testing in `test-common.sh`
- Created tests for Mentor endpoints in `test-mentors.sh`
- Developed tests for Gacha endpoints in `test-gacha.sh`
- Established tests for Hackathon endpoints in `test-hackathon.sh`
- Implemented tests for Authentication endpoints in `test-auth.sh`
- Added tests for Roles and Permissions endpoints in `test-roles-permissions.sh`
- Created tests for Teams endpoints in `test-teams.sh`
- Developed tests for User Management endpoints in `test-users.sh`
2025-10-24 10:38:21 +07:00
MythEclipse 3fcfb3709e refactor: Clean up unused imports and improve error handling in middleware and DTOs 2025-10-23 22:44:32 +07:00
MythEclipse 6915a97d79 feat: Enhance Hackathon Timeline Management and Admin Features
- Updated HackathonTimelineCreateRequestDto to accept optional title and name fields.
- Added custom validators for HackathonPhase and date checks in hackathon_dto.rs.
- Implemented admin-sensitive data management DTOs for handling user scores and personal info.
- Introduced new admin routes for managing users, roles, and permissions in IAM module.
- Added timeline enforcement middleware to restrict access based on hackathon phases.
- Created tests for timeline enforcement and admin permissions to ensure proper access control.
- Implemented payment middleware as a placeholder for future payment processing logic.
- Enhanced audit logging middleware for improved error handling and logging.
2025-10-13 10:57:53 +07:00
MythEclipse 6f596efadd feat: Implement audit logging and rate limiting middleware with SurrealDB integration
- Added audit logging middleware to track admin actions and save logs to SurrealDB.
- Introduced rate limiting middleware for public endpoints and authentication endpoints.
- Enhanced security headers middleware with nonce generation for CSP in development.
- Created utility functions for extracting real client IP addresses from headers.
- Updated Cargo.toml and Cargo.lock to include new dependencies.
- Added new schemas for audit logs and rate limiting in the entities module.
- Refactored permissions middleware to support new permission checks.
2025-10-12 01:54:34 +07:00
MythEclipse b6b5f48055 Implement rate limiting middleware for authentication endpoints, adding security headers middleware, and comprehensive error handling. Enhance validation tests for various DTOs and ensure proper functionality of gacha credits and rolls. Add unit tests for rate limiting and security headers middleware to validate behavior under different conditions. 2025-10-11 23:23:51 +07:00
Your Name 789b20c278 tests(hackathon): align tests with schema changes and fix clippy warnings 2025-10-11 15:33:52 +07:00
MythEclipse 884b679ba8 fix: Update string formatting in hackathon service and repository tests for consistency 2025-10-11 15:28:27 +07:00
MythEclipse 6ef624c169 feat: Enhance hackathon submission and participant management
- Updated HackathonSubmissionsSchema to use Option types for team_id, project_name, description, technologies, submission_status, and submitted_at.
- Modified seed_hackathons and seed_test_submission scripts to accommodate new optional fields.
- Added routes for participant registration and listing in hackathon_controller.
- Implemented register_participant and list_participants functions in hackathon_controller.
- Introduced HackathonParticipantSchema and corresponding DTOs for participant management.
- Enhanced HackathonRepository with CRUD operations for hackathon participants.
- Updated HackathonService to include methods for participant registration and listing.
- Refactored TeamsService to allow admin-level updates and invitations, bypassing leader-only restrictions.
- Added validation for member emails in TeamsCreateRequestDto and TeamInviteRequestDto.
2025-10-11 15:06:12 +07:00
MythEclipse c10443f881 Add minimal test for basic hackathon operations and enhance test utilities
- Introduced a new test module for hackathon-related functionality.
- Implemented a basic test for creating a hackathon using a mock repository.
- Enhanced the test utilities in `lib.rs` for better request handling and response extraction.
- Added a `ServiceClient` struct to facilitate HTTP requests in tests.
- Created a `RequestBuilder` to streamline building and sending requests with headers and JSON bodies.
2025-10-11 10:58:51 +07:00
MythEclipse 466ba3391a feat: Add seed_test_data script to populate initial test data for events, testimonials, hackathons, and mentors 2025-10-08 23:03:00 +07:00
MythEclipse 8c2527d61e Add access level descriptions to API responses across multiple controllers
- Updated event controller to specify public and admin access levels in response descriptions.
- Modified testimonials controller to indicate public access for list and detail responses.
- Enhanced mentors controller with admin access labels for various responses.
- Adjusted gacha claims and items controllers to reflect admin access in response descriptions.
- Updated hackathon controller to clarify public and admin access levels in response messages.
- Revised authentication controller to specify public access for login and registration responses.
- Enhanced admin teams controller with admin access labels for team-related responses.
- Updated users controller to clarify admin and user access levels in response descriptions.
2025-10-07 17:45:47 +07:00
MythEclipse 018124f7b3 feat: Implement permissions checks for hackathon and testimonial routes, enhancing security with header validation 2025-10-07 13:08:23 +07:00
MythEclipse 7749f6fdec feat: Add public routes for hackathons and enhance permissions checks to support both names and IDs 2025-10-05 20:23:34 +07:00
MythEclipse b27e4a4404 refactor: Enhance permission checks to accept both name and ID for Administrator role 2025-10-05 19:39:15 +07:00
MythEclipse 3dde67a4ac Enhance test coverage for IAM module by validating all required fields in response DTOs
- Added assertions to check for non-empty fields in TokenDto, UsersSchema, PermissionsSchema, and RolesDetailItemDto.
- Ensured that all necessary fields are validated in team-related DTOs, including TeamsCreateResponseDto and TeamsDetailResponseDto.
- Improved checks for user details in UsersDetailItemDto and UsersListItemDto to ensure data integrity.
- Verified that sensitive fields are present in admin team responses and member details.
- Updated tests to ensure that created and updated timestamps are present and valid across various entities.
2025-10-05 18:16:34 +07:00
MythEclipse 49abcf28c3 Refactor tests to enhance response validation and error handling
- Updated gacha claims service tests to parse and verify error JSON content for various scenarios.
- Improved gacha credits controller tests by adding checks for success messages in JSON responses.
- Enhanced gacha items service tests to validate response data structure and content.
- Modified IAM auth controller and service tests to ensure token data integrity and presence of required fields.
- Refined permissions controller and service tests to assert response data correctness.
- Improved roles controller and service tests to validate response data and ensure non-empty fields.
- Enhanced teams controller tests to verify sensitive data exposure and required fields in responses.
- Updated users controller and service tests to ensure response data integrity and presence of required fields.
2025-10-05 17:05:06 +07:00
MythEclipse 9cecc8fa05 Refactor tests to improve response parsing and validation
- Introduced a new common module with response helper functions to streamline response parsing across tests.
- Updated role controller tests to assert success messages in responses for create, update, and delete operations.
- Enhanced role service tests to validate response structures and ensure proper error handling.
- Modified team controller and service tests to check for success messages and validate response data formats.
- Improved user controller and service tests to include success message assertions and error handling for invalid inputs.
- Ensured all tests utilize the new response parsing functions for consistency and maintainability.
2025-10-05 16:00:46 +07:00
MythEclipse a32f54873b feat: Add previous winners field to hackathon schema and update related tests 2025-09-27 22:30:42 +07:00
MythEclipse af41a98111 feat: Add previous winners field to hackathon DTOs and schema 2025-09-27 21:05:34 +07:00
MythEclipse 4c435708c0 feat: Add endpoint to retrieve hackathon submissions and implement seeding for test submissions 2025-09-27 19:24:45 +07:00
MythEclipse ac462ea771 Refactor IAM tests: Remove unused repository tests, streamline service tests, and enhance validation checks for team and user services 2025-09-27 17:58:15 +07:00
MythEclipse 3e52223730 refactor: Update route parameters in hackathon controller for consistency 2025-09-27 13:40:02 +07:00
MythEclipse 01e479350b test: Add not found scenarios for hackathon, event, timeline, and submission repository operations 2025-09-27 13:16:13 +07:00
MythEclipse b0cf9817d7 feat: Implement submission retrieval and timeline validation in hackathon service 2025-09-27 13:06:55 +07:00
MythEclipse e530eba60d Add comprehensive tests for hackathon service functionality
- Implemented tests for creating, retrieving, updating, and deleting hackathons.
- Added validation tests for hackathon creation and updates.
- Included tests for hackathon events and timelines, ensuring proper handling of edge cases.
- Created tests for hackathon submissions, including validation and submission status updates.
- Organized tests into a dedicated module for better structure and maintainability.
2025-09-27 13:01:08 +07:00
MythEclipse ef1d63e893 refactor: Update OTP handling in auth repository tests and clean up unused variables in team and user repository tests 2025-09-26 23:30:13 +07:00
MythEclipse 5859af5294 Refactor environment module: Rename enviroment to environment and consolidate environment configuration management
- Updated all references from `enviroment` to `environment` across the codebase.
- Removed the old `enviroment` module and replaced it with a new `environment` module that includes centralized configuration management.
- Enhanced OTP generation to include secure hashing and expiration handling.
- Improved CSRF token generation and validation with better error handling.
- Cleaned up logging statements in various modules for clarity and consistency.
- Updated response formatting to include versioning from Cargo.toml.
- Removed unused mock test module from utils.
2025-09-26 23:15:33 +07:00
MythEclipse c12da948aa refactor: Clean up Argon2 password hashing implementation and remove unused configuration constants 2025-09-26 22:24:23 +07:00
MythEclipse 3cde18360c refactor: Simplify gateway service initialization and enhance seeding process for gacha rolls 2025-09-26 17:50:31 +07:00
MythEclipse 14b22328de Refactor and enhance SurrealDB integration and resource management
- Updated `lib.rs` to selectively expose specific entities and services for better clarity.
- Improved SurrealDB client initialization with detailed logging in `surrealdb/mod.rs`.
- Enhanced resource definitions in `resource.rs` with additional utility methods for better resource management.
- Refactored user data retrieval logic in `auth_middleware/mod.rs` for improved readability and efficiency.
- Cleaned up middleware exports in `lib.rs` for clearer API surface.
- Added detailed comments and documentation throughout the SurrealDB module for better maintainability.
- Updated tests to ensure compatibility with new changes and improved structure.
- Introduced new permissions module structure in `imphnen-utils` for future enhancements.
2025-09-26 17:35:30 +07:00
MythEclipse 96debc210a Refactor permissions and user DTOs to use imphnen_entities module
- Updated permissions_repository.rs to import PermissionsItemDto from imphnen_entities.
- Modified permissions_schema.rs to import PermissionsItemDto and PermissionsQueryDto from imphnen_entities.
- Changed roles_dto.rs to import PermissionsItemDto and PermissionsQueryDto from imphnen_entities.
- Updated users_dto.rs to import ExperienceDto, EducationDto, UsersDetailQueryDto, RolesDetailQueryDto, and RolesDetailItemDto from imphnen_entities, and removed redundant struct definitions.
- Refactored users_repository.rs to import UsersDetailQueryDto from imphnen_entities.
- Updated users_schema.rs to import UsersDetailQueryDto, ExperienceDto, and EducationDto from imphnen_entities.
- Modified users_service.rs to use UsersDetailQueryDto from imphnen_entities and added UserLookupService implementation.
- Updated Cargo.toml in imphnen-libs to include async-trait as a workspace dependency.
- Refactored axum module to remove redundant imports and streamline code.
- Updated lib.rs to include AppState struct with user_lookup_service and auth_repository fields.
- Refactored surrealdb module to define SurrealWsClient and SurrealMemClient types.
- Removed imphnen-iam dependency from middleware's Cargo.toml.
- Updated auth_middleware to use UsersDetailQueryDto from imphnen_entities and refactored user retrieval logic.
- Refactored permissions_middleware to use PermissionsEnum from imphnen_entities and updated user retrieval logic.
- Updated mock_test.rs to create AppState with user_lookup_service and auth_repository.
- Added permissions.rs and users.rs to imphnen_entities with necessary DTOs and enums.
- Created services.rs in imphnen-libs to define UserLookupService and AuthRepositoryTrait traits.
2025-09-26 15:05:45 +07:00
MythEclipse b80662188f feat: Update admin teams endpoint to return 403 Forbidden for unauthorized access 2025-09-26 13:04:48 +07:00
MythEclipse 7b9ce6cda1 feat: Add seeding process for teams and ensure permissions are seeded even when other seeds are skipped 2025-09-26 02:23:55 +07:00
MythEclipse 1bb2d6a419 feat: Add Administrator permission to seeding process and ensure permissions are seeded even when other seeds are skipped 2025-09-26 01:35:06 +07:00
MythEclipse 14690a378f feat: Allow access for users with Administrator permission in permissions guard 2025-09-26 01:30:00 +07:00
MythEclipse 17cc4ccdba refactor: Update permissions handling to filter out None values and ensure safe access 2025-09-26 01:08:18 +07:00
MythEclipse 1b7368a4b7 feat: Add Administrator permission and update permissions handling for roles and users 2025-09-26 00:45:57 +07:00
MythEclipse ff2e14d5f9 feat: Introduce Administrator permission and update permissions checks for access control 2025-09-25 23:47:29 +07:00
MythEclipse 012aff60ef refactor: Update admin team routes to remove versioning and avoid conflicts 2025-09-25 23:28:37 +07:00
MythEclipse f3d88e2ac9 Add comprehensive tests for Gacha Credits, Auth, and User services
- Implement tests for GachaCreditsService including creation, retrieval, updating, and deletion of gacha credits.
- Add tests for AuthController covering user login, registration, email verification, and error handling for invalid credentials.
- Enhance AuthService tests to include login, registration, email verification, OTP resend, password reset, and token refresh functionalities.
- Create tests for UsersService to validate user creation, retrieval, updating, deletion, and fetching by email.
- Ensure all tests utilize a consistent setup and teardown process for database interactions.
2025-09-25 22:53:26 +07:00
MythEclipse 0e48b6755f refactor: Remove unused execute_safe_count_query import from teams_repository 2025-09-25 22:07:38 +07:00
MythEclipse 7056f8c8a6 Add comprehensive tests for IAM permissions, roles, teams, and users
- Implemented unit tests for PermissionsController and PermissionsService, covering create, read, update, and delete operations.
- Added tests for RolesController and RolesService, including handling of duplicates and retrieval by ID.
- Developed tests for TeamsController, including creation, retrieval, updating, deletion, and search functionality.
- Created tests for UsersController, ensuring user creation and validation of attributes.
- Each test includes setup, execution, and cleanup to maintain database integrity.
2025-09-25 22:06:21 +07:00
MythEclipse 4ecdcd12d2 feat: Add administrator permissions to PermissionsEnum and update related team management functionality 2025-09-25 20:57:31 +07:00
MythEclipse dd4a73e360 feat: Add comprehensive admin team management functionality with new permissions and endpoints 2025-09-25 20:28:08 +07:00
MythEclipse 49e0a89ad1 feat: Enhance team management and permissions structure with detailed DTOs and service methods 2025-09-25 20:18:16 +07:00
MythEclipse 82b85abe8f a 2025-09-25 17:29:17 +07:00
MythEclipse df11aa5eb8 a 2025-09-25 17:26:39 +07:00
MythEclipse ac44867ec1 feat: Add member team management functionality with detailed DTOs and service methods 2025-09-22 17:38:54 +07:00
MythEclipse 7205afe43b feat: Implement admin team management endpoints with permissions and DTOs 2025-09-22 17:25:02 +07:00
MythEclipse b1c678c72b feat: Add public team listing and detail endpoints with DTOs for public access 2025-09-22 17:07:14 +07:00
MythEclipse fa3687c6be feat: Add team-related DTOs and update routes for team management 2025-09-22 16:55:03 +07:00
MythEclipse ba46a19a5a refactor: Replace surrealdb_helpers with integrated query builder methods for improved clarity and functionality 2025-09-22 16:38:07 +07:00
MythEclipse 8cdbca24ba feat: Enhance query builder with new condition methods and safe query execution 2025-09-22 16:09:22 +07:00
MythEclipse 8f981572cd feat: Introduce surrealdb_helpers for query building and execution utilities 2025-09-22 15:06:37 +07:00
MythEclipse 39b75f410f Add unit tests for TeamsRepository and TeamsService
- Implement tests for team creation, retrieval, updating, and deletion in TeamsRepository.
- Add tests for team member management including adding and removing members.
- Create tests for team invitations and searching teams.
- Ensure proper cleanup of test data after each test case.
- Validate unauthorized operations for team management.
2025-09-22 14:59:38 +07:00
MythEclipse 6da1b2a7e7 refactor: Replace make_thing with make_thing_from_enum for consistency and clarity across multiple modules 2025-09-22 09:10:20 +07:00
MythEclipse acccae9f7c fix: Correct typo in FAILED_TESTS_SUMMARY variable and enhance HTTP status code handling in API tests 2025-09-22 08:21:21 +07:00
MythEclipse e7c17784cc feat: Simplify token generation by removing unnecessary permissions parameter from access and refresh token functions 2025-09-22 07:52:00 +07:00
Maulana SodiqinandGitHub 042e0b4eff Merge pull request #45 from IMPHNEN/feat/auth-google
Feat/auth google
2025-08-17 18:26:24 +07:00
MythEclipse 0b51a56dee feat: Enhance CORS middleware to dynamically include development port and allow OPTIONS method 2025-08-17 16:06:21 +07:00
MythEclipse cb98440b65 feat: Refactor update_user_avatar method to be static and enhance avatar deletion logic 2025-08-17 15:22:15 +07:00
MythEclipse 3c19d262f3 feat: Increase CSRF token maximum age to 30 minutes and enhance logging for token generation 2025-08-17 15:08:58 +07:00
MythEclipse bdffe85544 Update dependencies and refactor OAuth service
- Updated various dependencies in Cargo.toml to their latest versions for improved performance and security.
- Refactored the Google OAuth service to streamline the creation of the OAuth client and improve code readability.
- Changed the way WebSocket messages are sent in clear_db_test.rs to use `into()` for better type handling.
- Enhanced error handling and logging in the Google OAuth callback method.
2025-08-16 19:10:27 +07:00
MythEclipse 9ed619e3fd feat: Optimize password verification by using blocking tasks for improved performance 2025-08-16 13:47:29 +07:00
MythEclipse 00fd7e1907 feat: Refactor password hashing and JWT encoding to use static keys for improved performance and security 2025-08-16 13:43:18 +07:00
MythEclipse b70126734e feat: Refactor password reset flow to use async processing and improve token validation 2025-08-16 13:38:35 +07:00
MythEclipse 6af9461079 a 2025-08-16 13:22:39 +07:00
MythEclipse 57b3662951 feat: Refactor auth service and middleware for improved user data handling and caching 2025-08-16 13:18:55 +07:00
MythEclipse c0c08f2821 Enhance Users Service and DTOs
- Added Default trait to UsersDetailItemDto for easier instantiation.
- Refactored UsersServiceTrait to use Pin<Box<dyn Future<...>> for async functions, improving compatibility with async/await.
- Updated all service methods to return futures instead of using async_trait.
- Improved error handling and logging in upload_file method, ensuring proper handling of multipart data.
- Masked sensitive information in Env struct's Debug implementation for better security in logs.
- Cleaned up Cargo.toml by removing unnecessary async-trait workspace dependency.
2025-08-16 12:48:24 +07:00
MythEclipse 8402415649 feat: Enhance user permissions and update event handling with improved response structures 2025-08-15 00:32:15 +07:00
MythEclipse 01e20baa45 feat: Add axum-extra dependency and refactor auth middleware for improved token handling 2025-08-14 23:16:41 +07:00
MythEclipse 10dc869eaf feat: Enhance permissions and roles management with improved indexing and state handling 2025-08-14 23:11:44 +07:00
MythEclipse 199d0c885e feat: Add axum-extra dependency and implement typed headers
feat: Define unique index on users table for email and fix minor syntax error

refactor: Update permissions_guard to use claims from JWT and improve user retrieval

refactor: Modify user-related service methods to accept user details directly

fix: Update token generation functions to include user ID and permissions

test: Update Google OAuth flow tests to reflect changes in token generation
2025-08-14 22:46:42 +07:00
MythEclipse 89ef48a57e feat: Update permissions for user and gacha claim access 2025-08-14 21:38:47 +07:00
MythEclipse da1826ebba feat: Add file upload with deduplication to MinIO service 2025-08-14 17:03:23 +07:00
MythEclipse 874986f1d6 feat: Implement file upload functionality with MinIO integration
- Added new dependencies for HMAC, hex, and urlencoding in Cargo.toml.
- Introduced FileUploadSchema for handling multipart file uploads.
- Enhanced UsersService with upload_file method to handle file uploads to MinIO.
- Implemented MinioService for managing MinIO interactions, including file uploads and presigned URL generation.
- Updated environment configuration to include MinIO region and secure settings.
- Added validation for file types and sizes during upload.
- Improved error handling and logging for file upload processes.
- Created utility functions for base64 decoding and content type extraction.
2025-08-14 16:53:14 +07:00
MythEclipse bbff555a06 feat(docs): add upload_file endpoint to OpenAPI documentation 2025-08-14 14:16:07 +07:00
Asep Haryana Saputra d3eb687a58 Merge branch 'feat/auth-google' of https://github.com/IMPHNEN/imphnen-backend-service into feat/auth-google 2025-08-14 06:55:26 +00:00
Asep Haryana Saputra c5516d4006 a 2025-08-14 06:54:56 +00:00
MythEclipse 5f51dd281d feat: update dependencies and add MinIO integration
- Updated `async-channel` to version 2.5.0 and added new dependencies in `Cargo.lock`.
- Introduced `minio` crate for file upload functionality.
- Added `career_status` field to user-related DTOs and schemas.
- Implemented file upload endpoint in `users_controller.rs` with multipart support.
- Created `MinioService` for handling file uploads to MinIO.
- Updated user seeding and test cases to accommodate new `career_status` field.
- Refactored permissions guard to return user details.
2025-08-14 13:16:39 +07:00
MythEclipse f6232832af feat(users): Enhance user schema and DTOs with additional fields for website, social links, location, skills, experience, and education 2025-08-13 20:40:02 +07:00
MythEclipse 277cf0770d feat(auth): Add GOOGLE_REDIRECT_URL to .env.example for Google OAuth callback 2025-08-13 01:29:41 +07:00
MythEclipse 650728a385 feat(auth): Update Google OAuth service to support custom redirect URIs 2025-08-12 23:21:34 +07:00
MythEclipse 3cc3e2a7d8 feat(auth): Update Google OAuth flow to support custom redirect URIs 2025-08-12 22:06:26 +07:00
MythEclipse 39fc2f86ad feat(users): Include avatar field in user creation from DTO 2025-08-12 19:50:52 +07:00
Asep Haryana Saputra 7bd0ef2274 a 2025-08-12 12:41:16 +00:00
MythEclipse b40a430c49 feat(auth): Enhance Google OAuth flow with async email extraction and caching 2025-08-12 19:25:10 +07:00
MythEclipse 44b1e09551 fix(users): Change meta parameter extraction from Json to Query in get_user_list function 2025-08-12 18:53:11 +07:00
MythEclipse 1b9251b2dd Refactor mentor and user schemas to centralize personal data management
- Updated `seed_mentor_user.rs` to include additional fields in the `app_users` creation and removed redundant fields from `app_mentors`.
- Modified `seed_users.rs` to initialize new user fields including legal name, domicile, and professional links.
- Adjusted `mentors_dto.rs` to remove personal data fields from `MentorDetailWithUserDto` and `MentorDetailResponseDto`, now sourced from `UsersSchema`.
- Updated `mentors_repository.rs` to search for user data through `user_id` instead of mentor fields.
- Refined `mentors_schema.rs` to remove personal data fields, now managed in `UsersSchema`.
- Enhanced `mentors_service.rs` to populate mentor responses with user data from `UsersSchema`.
- Removed identity document URL and related fields from various DTOs and schemas across the codebase.
- Updated tests to reflect the removal of identity document URL and ensure compatibility with the new schema structure.
2025-08-12 18:43:30 +07:00
MythEclipse 34b6cdabfb feat(users): Add optional fields to user schemas and update user avatar functionality 2025-08-12 17:31:12 +07:00
MythEclipse a25e896fa2 feat(auth): Enhance Google OAuth integration with PKCE support and CSRF validation improvements 2025-08-12 16:11:15 +07:00
Asep Haryana Saputra adf3ef8e28 a 2025-08-12 08:14:05 +00:00
MythEclipse ad46687f07 refactor(tests): Remove unused imports in CSRF token tests 2025-08-12 01:51:10 +07:00
MythEclipse 0ba04abee9 feat(auth): Enhance Google OAuth service with CSRF protection and validation logic
feat(auth): Add error handling for authentication and validation errors
feat(auth): Implement default role assignment for new users in Google OAuth flow
feat(utils): Introduce CSRF token generation and validation utilities
fix(dependencies): Update Cargo.toml to include base64 and sha2 dependencies
2025-08-12 00:32:59 +07:00
MythEclipse 1e25a5b496 feat(auth): Update Google OAuth integration to enhance response structure and improve callback handling 2025-08-11 23:47:15 +07:00
MythEclipse d7178c792d a 2025-08-11 23:06:45 +07:00
MythEclipse d2754de9ef feat(auth): Refactor Google OAuth service and controller to utilize environment variables and enhance response structure 2025-08-11 22:54:10 +07:00
MythEclipse cda950ed7e feat(auth): Implement Google OAuth 2.0 integration with user creation and JWT generation
- Added Google OAuth controller and service to handle authentication via Google.
- Introduced DTOs for Google user and token responses.
- Updated AuthService and UsersService traits to support new Google OAuth functionality.
- Implemented logic to create a new user if they do not exist in the system after Google authentication.
- Enhanced existing user retrieval and JWT generation upon successful login.
- Added tests for Google OAuth flow, including login redirection and callback handling for both new and existing users.
- Updated environment configuration to include Google OAuth credentials.
2025-08-11 22:31:51 +07:00
MythEclipse c6a95c1231 fix: update default surrealdb password to 'root' for consistency 2025-07-31 20:34:36 +07:00
MythEclipse 86116b8157 feat: add logging for SurrealDB queries across multiple repositories 2025-07-31 16:51:08 +07:00
MythEclipse ac06224ed1 feat: enhance logging and dependency management with dotenvy and tracing-subscriber 2025-07-31 15:42:15 +07:00
MythEclipse 83e765f358 feat: add logger module with initialization function using tracing and dotenvy 2025-07-31 15:15:55 +07:00
MythEclipse 558e0ce568 refactor: simplify environment variable loading with helper function and remove unused mail configuration 2025-07-31 15:12:01 +07:00
MythEclipse 148cf3a5cd refactor: streamline environment variable loading and improve logging 2025-07-31 15:07:18 +07:00
Maulana SodiqinandGitHub ee9d23e01b Merge pull request #44 from IMPHNEN/feat/mentor
Add comprehensive tests for mentor repository and authentication
2025-07-21 22:18:14 +07:00
MythEclipse 1a2e0c58b6 Add comprehensive tests for mentor repository and authentication
- Implemented tests for creating, retrieving, updating, and deleting mentors in `mentor_repository_test.rs`.
- Added tests for user authentication, including successful login, invalid email formats, and inactive users in `auth_login_tests.rs`.
- Created a mock test environment setup in `mock_test.rs` to facilitate database operations during tests.
- Updated module structure to include new test files for mentors and authentication.
- Ensured cleanup of the database after tests to maintain isolation and prevent side effects.
2025-07-21 21:29:04 +07:00
Maulana SodiqinandGitHub e66f1f1634 chore: remove deprecated deployment 2025-06-30 09:28:11 +07:00
Maulana SodiqinandGitHub 6f45cf80c8 Merge pull request #38 from IMPHNEN/feat/client-dynamic-wss-or-ws
refactor: update surrealdb client usage to support 'any' engine type
2025-06-30 09:27:16 +07:00
382 changed files with 20502 additions and 11237 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
+5 -5
View File
@@ -1,5 +1,5 @@
target
.dockerignore
Dockerfile
.git
.gitignore
target
.dockerignore
Dockerfile
.git
.gitignore
+34 -8
View File
@@ -1,8 +1,34 @@
PORT=
SURREALDB_URL=
SURREALDB_USERNAME=
SURREALDB_PASSWORD=
SURREALDB_NAMESPACE=
SURREALDB_DBNAME=
ACCESS_TOKEN_SECRET=
REFRESH_TOKEN_SECRET=
RUST_ENV=development
RUST_LOG=debug
PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root
SURREALDB_PASSWORD=root
SURREALDB_NAMESPACE=test
SURREALDB_DBNAME=test
ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_NAME="Your App Name"
SMTP_HOST=smtp.gmail.com
REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECURE=false
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
POOL_SIZE=10
CONNECT_TIMEOUT=30
IDLE_TIMEOUT=60
MAX_LIFETIME=1800
STATEMENT_TIMEOUT=30000
IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
SSLMODE=require
RETRY_ATTEMPTS=3
RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback
+1 -1
View File
@@ -1 +1 @@
use flake --impure
use flake --impure
-65
View File
@@ -1,65 +0,0 @@
name: Deploy
on:
push:
branches:
- develop
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build the project
run: cargo build --release
- name: Stop service on VPS before upload
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
set -e
echo "Stopping the service before uploading the binary"
sudo systemctl stop imphnen-backend-service
- name: Upload artifact to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: ./target/release/*
target: /opt/imphnen-backend-service/imphnen-backend-service
rm: true
overwrite: true
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
set -e
echo "Restarting the service"
sudo systemctl daemon-reload
sudo systemctl restart imphnen-backend-service
echo "Deployment completed successfully"
+16 -15
View File
@@ -1,15 +1,16 @@
# Build
/target
/resutl
# Nix
/.direnv
/Cargo.nix
# Environment
.envrc
.env
.env.local
.env.development
.env.staging
.env.production
# Build
/target
/resutl
# Nix
/.direnv
/Cargo.nix
# Environment
.envrc
.env
.env.local
.env.development
.env.staging
.env.production
**/**.log
+1
View File
@@ -0,0 +1 @@
/cache
+71
View File
@@ -0,0 +1,71 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: rust
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "imphnen-backend-service"
Generated
+1765 -2545
View File
File diff suppressed because it is too large Load Diff
+75 -29
View File
@@ -1,49 +1,95 @@
[workspace]
resolver = "2"
members = [
"tests",
"imphnen-iam",
"imphnen-cms",
"imphnen-libs",
"imphnen-utils",
"imphnen-gacha",
"imphnen-gateway",
"imphnen-backend",
"imphnen-entities",
"imphnen-dimentorin",
"imphnen-middleware",
members = [
"imphnen-entities", # Most basic - core data structures
"imphnen-macros", # Macros
"imphnen-libs", # Depends on entities
"imphnen-utils", # Depends on libs and entities
"imphnen-middleware",# Utility for permissions
"imphnen-iam", # Core auth service, depends on libs, utils, entities
"imphnen-cms", # Content management, depends on core services
"imphnen-gacha", # Game mechanics, depends on core services
"imphnen-dimentorin",# Learning platform, depends on core services
"imphnen-gateway", # API gateway, depends on all services
"imphnen-backend", # Main application, depends on all services
]
[workspace.dependencies]
axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138"
tokio = { version = "1.45.0" }
async-trait = "0.1.83"
oauth2 = "5.0.0"
reqwest = { version = "0.12.23", features = ["json"] }
serde_json = "1.0.142"
axum = { version = "0.8.4", features = ["multipart", "macros"] }
log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1"
chrono = "0.4.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.2", features = ["kv-mem"] }
thiserror = "2.0.12"
anyhow = "1.0.98"
rand = { version = "0.9.1", features = ["std", "alloc"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
thiserror = "2.0.14"
anyhow = "1.0.99"
rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors"] }
validator = { version = "0.12", features = ["derive"] }
lazy_static = "1.4.0"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.3"
zod-rs = { version = "0.4", features = ["macros"] }
zod-rs-util = "0.4"
paginator-rs = "0.2"
paginator-utils = "0.2"
paginator-sea-orm = { version = "0.2", features = ["sqlx-postgres", "runtime-tokio"] }
paginator-axum = "0.2"
lazy_static = "1.5.0"
regex = "1.11.1"
axum-test = "17.2.0"
fancy-regex = "0.14.0"
axum-test = "17.3.0"
axum-extra = { version = "0.10.1", features = ["typed-header"] }
fancy-regex = "0.16.1"
futures = "0.3.31"
tower = "0.5.2"
env_logger = "0.11.8"
tracing = "0.1.41"
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
strum = { version = "0.27.2", features = ["derive"] }
strum_macros = "0.27.2"
base64 = "0.22.1"
sha2 = "0.10.9"
hmac = "0.12"
hex = "0.4"
urlencoding = "2.1"
hyper = "1.6.0"
hyper-util = "0.1.16"
minio = "0.3.0"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] }
num_cpus = "1.16.0"
tokio-test = "0.4.4"
mockall = "0.13.1"
once_cell = "1.21.3"
dotenvy = "0.15.7"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tokio-tungstenite = "0.27"
url = "2.5"
futures-util = "0.3"
http = "1.3"
imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" }
imphnen-libs = { path = "./imphnen-libs" }
imphnen-utils = { path = "./imphnen-utils" }
imphnen-gacha = { path = "./imphnen-gacha" }
imphnen-gateway = { path = "./imphnen-gateway" }
imphnen-backend = { path = "./imphnen-backend" }
imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
imphnen-macros = { path = "./imphnen-macros" }
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
opt-level = "z"
+46 -46
View File
@@ -1,47 +1,47 @@
FROM rust:1.85-alpine AS builder
RUN apk add --no-cache \
curl \
musl-dev \
openssl-dev \
openssl-libs-static \
pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p imphnen-backend/src imphnen-cms/src imphnen-dimentorin/src \
imphnen-entities/src imphnen-gacha/src imphnen-gateway/src \
imphnen-iam/src imphnen-libs/src imphnen-middleware/src \
imphnen-utils/src tests/src && \
echo "fn main() {}" > imphnen-backend/src/main.rs && \
find . -name "src" -type d -exec sh -c 'echo "// dummy" > "$1/lib.rs"' _ {} \;
RUN echo '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
RUN echo -e '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
COPY imphnen-backend ./imphnen-backend
COPY imphnen-cms ./imphnen-cms
COPY imphnen-dimentorin ./imphnen-dimentorin
COPY imphnen-entities ./imphnen-entities
COPY imphnen-gacha ./imphnen-gacha
COPY imphnen-gateway ./imphnen-gateway
COPY imphnen-iam ./imphnen-iam
COPY imphnen-libs ./imphnen-libs
COPY imphnen-middleware ./imphnen-middleware
COPY imphnen-utils ./imphnen-utils
COPY tests ./tests
RUN RUSTFLAGS="-C target-cpu=generic -C opt-level=s -C panic=abort -C codegen-units=1 -C strip=symbols" \
cargo build -p imphnen-backend --release && \
strip target/release/api && \
upx --best --lzma target/release/api 2>/dev/null || true
FROM scratch AS runner
COPY --from=builder /app/target/release/api /api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
FROM rust:1.86-alpine AS builder
RUN apk add --no-cache \
curl \
musl-dev \
openssl-dev \
openssl-libs-static \
pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p imphnen-backend/src imphnen-cms/src imphnen-dimentorin/src \
imphnen-entities/src imphnen-gacha/src imphnen-gateway/src \
imphnen-iam/src imphnen-libs/src imphnen-middleware/src \
imphnen-utils/src tests/src && \
echo "fn main() {}" > imphnen-backend/src/main.rs && \
find . -name "src" -type d -exec sh -c 'echo "// dummy" > "$1/lib.rs"' _ {} \;
RUN echo '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
RUN echo -e '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
COPY imphnen-backend ./imphnen-backend
COPY imphnen-cms ./imphnen-cms
COPY imphnen-dimentorin ./imphnen-dimentorin
COPY imphnen-entities ./imphnen-entities
COPY imphnen-gacha ./imphnen-gacha
COPY imphnen-gateway ./imphnen-gateway
COPY imphnen-iam ./imphnen-iam
COPY imphnen-libs ./imphnen-libs
COPY imphnen-middleware ./imphnen-middleware
COPY imphnen-utils ./imphnen-utils
COPY tests ./tests
RUN RUSTFLAGS="-C target-cpu=generic -C opt-level=s -C panic=abort -C codegen-units=1 -C strip=symbols" \
cargo build -p imphnen-backend --release && \
strip target/release/api && \
upx --best --lzma target/release/api 2>/dev/null || true
FROM scratch AS runner
COPY --from=builder /app/target/release/api /api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/api"]
+130 -130
View File
@@ -1,130 +1,130 @@
# IMPHNEN Backend Service
<p align="center">
<img src="docs/logo.svg" alt="IMPHNEN">
</p>
This repository serves as the **monorepo** for all backend services of IMPHNEN. It encompasses several main services:
1. **IMPHNEN-Backend** - Provides fundamental functionalities and shared resources for other services.
2. **IMPHNEN-IAM** - Handles identity and access management across IMPHNEN applications.
3. **IMPHNEN-CMS** - Supports the cms services by IMPHNEN [Landing Page website](https://imphnen.dev/).
4. **IMPHNEN-Gacha** - Supports the gacha services by IMPHNEN [Gacha website](https://gacha.imphnen.dev/).
5. **IMPHNEN-Dimentorin** - Supports the mentoring services by IMPHNEN [Dimentorin website](https://dimentorin.imphnen.dev/).
6. **IMPHNEN-Gateway** - Acts as the API gateway, routing requests to appropriate services.
7. **IMPHNEN-Middleware** - Acts as the middleware for the API Gateway, providing authentication and authorization.
## How to Install
1. **Clone the repository**:
```sh
git clone https://github.com/IMPHNEN/imphnen-backend-service.git
cd imphnen-backend-service
```
2. **Set up the environment**:
- Copy the example environment files:
```sh
cp .env.example .env
```
if you use windows based system
```sh
./apply-env.ps1
```
if you use unix based system
```sh
source ./apply-env.sh
```
- Modify the `.env` files with your specific configuration settings.
3. **Install dependencies**:
Ensure you have [Rust](https://www.rust-lang.org/) installed. Then, run:
```sh
cargo fetch
```
4. **Run the seeders**:
to run the seeders, run:
```sh
cargo run --bin seeder
```
## How to Run
### Development
To run the services in development mode:
1. **Start the database and other dependencies** using Docker Compose:
```sh
docker-compose up -d
```
2. **Run using cargo run**. For example, to run the Core Service:
```sh
cargo run --bin api
```
3. **Run using cargo watch**. For example, to run the Core Service:
```sh
cargo watch -x "run --bin api"
```
### Production
For production deployment:
1. **Build the Docker image**:
```sh
docker build -t imphnen-backend .
```
2. **Run the Docker container**:
```sh
docker run --name imphnen-backend -d --env-file .env -p 3000:3000 imphnen-backend
```
Adjust the port and environment variables as needed.
## How to Run the Tests
1. **Run the tests**:
```sh
cargo test -p tests
```
## How to Contribute
1. **Fork the repository** and clone it locally.
2. **Create a new branch** for your feature or fix:
```sh
git checkout -b feat/your-feature-name
```
3. **Make your changes**, commit them, and push to your forked repository.
4. **Create a pull request** to the `develop` branch of this repository.
If you encounter any issues or have questions, feel free to create a new issue in the repository.
---
_Note: For detailed API documentation, please refer to our [API Docs](https://api.imphnen.dev/docs)._
# IMPHNEN Backend Service
<p align="center">
<img src="docs/logo.svg" alt="IMPHNEN">
</p>
This repository serves as the **monorepo** for all backend services of IMPHNEN. It encompasses several main services:
1. **IMPHNEN-Backend** - Provides fundamental functionalities and shared resources for other services.
2. **IMPHNEN-IAM** - Handles identity and access management across IMPHNEN applications.
3. **IMPHNEN-CMS** - Supports the cms services by IMPHNEN [Landing Page website](https://imphnen.dev/).
4. **IMPHNEN-Gacha** - Supports the gacha services by IMPHNEN [Gacha website](https://gacha.imphnen.dev/).
5. **IMPHNEN-Dimentorin** - Supports the mentoring services by IMPHNEN [Dimentorin website](https://dimentorin.imphnen.dev/).
6. **IMPHNEN-Gateway** - Acts as the API gateway, routing requests to appropriate services.
7. **IMPHNEN-Middleware** - Acts as the middleware for the API Gateway, providing authentication and authorization.
## How to Install
1. **Clone the repository**:
```sh
git clone https://github.com/IMPHNEN/imphnen-backend-service.git
cd imphnen-backend-service
```
2. **Set up the environment**:
- Copy the example environment files:
```sh
cp .env.example .env
```
if you use windows based system
```sh
./apply-env.ps1
```
if you use unix based system
```sh
source ./apply-env.sh
```
- Modify the `.env` files with your specific configuration settings.
3. **Install dependencies**:
Ensure you have [Rust](https://www.rust-lang.org/) installed. Then, run:
```sh
cargo fetch
```
4. **Run the seeders**:
to run the seeders, run:
```sh
cargo run --bin seeder
```
## How to Run
### Development
To run the services in development mode:
1. **Start the database and other dependencies** using Docker Compose:
```sh
docker-compose up -d
```
2. **Run using cargo run**. For example, to run the Core Service:
```sh
cargo run --bin api
```
3. **Run using cargo watch**. For example, to run the Core Service:
```sh
cargo watch -x "run --bin api"
```
### Production
For production deployment:
1. **Build the Docker image**:
```sh
docker build -t imphnen-backend .
```
2. **Run the Docker container**:
```sh
docker run --name imphnen-backend -d --env-file .env -p 3000:3000 imphnen-backend
```
Adjust the port and environment variables as needed.
## How to Run the Tests
1. **Run the tests**:
```sh
cargo test -p tests
```
## How to Contribute
1. **Fork the repository** and clone it locally.
2. **Create a new branch** for your feature or fix:
```sh
git checkout -b feat/your-feature-name
```
3. **Make your changes**, commit them, and push to your forked repository.
4. **Create a pull request** to the `develop` branch of this repository.
If you encounter any issues or have questions, feel free to create a new issue in the repository.
---
_Note: For detailed API documentation, please refer to our [API Docs](https://api.imphnen.dev/docs)._
-35
View File
@@ -1,35 +0,0 @@
@echo off
setlocal
:: Cek apakah file .env ada
if not exist ".env" (
echo File .env tidak ditemukan di direktori saat ini.
exit /b 1
)
echo Memuat variabel dari .env...
:: Baca file .env baris per baris
for /f "tokens=*" %%a in ('type ".env" ^| findstr /v "^$" ^| findstr /v "^#"') do (
echo.%%a | findstr "=" >nul && (
for /f "tokens=1,2 delims==" %%b in ("%%a") do (
set "key=%%b"
set "value=%%c"
:: Trim whitespace
call :trimValue key value
echo Set variabel: %%b=%%c
setx %%b %%c >nul
)
)
)
echo.
echo Semua variabel telah dimuat.
endlocal
goto :eof
:: Fungsi trim (sederhana)
:trimValue
set "%1=%[%1]%"
set "%2=%[%2]%"
goto :eof
-30
View File
@@ -1,30 +0,0 @@
function Set-TempEnvFromDotEnv {
param (
[string]$envFilePath
)
if (-Not (Test-Path $envFilePath)) {
Write-Error "The .env file at path '$envFilePath' does not exist."
return
}
$envContent = Get-Content $envFilePath
foreach ($line in $envContent) {
$trimmedLine = $line.Trim()
if (-Not [string]::IsNullOrWhiteSpace($trimmedLine) -and -Not $trimmedLine.StartsWith("#")) {
$keyValue = $trimmedLine -split "=", 2
if ($keyValue.Length -eq 2) {
$key = $keyValue[0].Trim()
$value = $keyValue[1].Trim()
[System.Environment]::SetEnvironmentVariable($key, $value, [System.EnvironmentVariableTarget]::Process)
Write-Host "Set temporary environment variable: $key=$value"
}
}
}
Write-Host "All environment variables from '$envFilePath' have been set temporarily."
}
Set-TempEnvFromDotEnv -envFilePath ".env"
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set_temp_env_from_dotenv() {
local env_file_path="$1"
if [[ ! -f "$env_file_path" ]]; then
echo "Error: The .env file at path '$env_file_path' does not exist."
return 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
trimmed_line=$(echo "$line" | xargs)
if [[ -n "$trimmed_line" && ! "$trimmed_line" =~ ^# ]]; then
key=$(echo "$trimmed_line" | cut -d '=' -f 1 | xargs)
value=$(echo "$trimmed_line" | cut -d '=' -f 2- | xargs)
export "$key=$value"
echo "Set temporary environment variable: $key=$value"
fi
done < "$env_file_path"
echo "All environment variables from '$env_file_path' have been set temporarily."
}
set_temp_env_from_dotenv ".env"
+15 -15
View File
@@ -1,15 +1,15 @@
{pkgs ? import <nixpkgs> {}}: let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
rustDeps = pkgs.callPackage ./Cargo.nix {};
packageEntry = rustDeps.workspaceMembers.${manifest.name};
deps = packageEntry.build.cargoDeps or null;
in
pkgs.rustPlatform.buildRustPackage {
pname = manifest.name;
version = manifest.version;
cargoDeps = deps;
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [pkgs.openssl pkgs.pkg-config];
buildInputs = [pkgs.openssl];
}
{pkgs ? import <nixpkgs> {}}: let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
rustDeps = pkgs.callPackage ./Cargo.nix {};
packageEntry = rustDeps.workspaceMembers.${manifest.name};
deps = packageEntry.build.cargoDeps or null;
in
pkgs.rustPlatform.buildRustPackage {
pname = manifest.name;
version = manifest.version;
cargoDeps = deps;
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [pkgs.openssl pkgs.pkg-config];
buildInputs = [pkgs.openssl];
}
+46 -16
View File
@@ -1,16 +1,46 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "${PORT}:${PORT}"
env_file: ".env"
depends_on:
- surrealdb
surrealdb:
image: surrealdb/surrealdb:latest
command: start --log trace --user root --pass root
ports:
- "8000:8000"
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "${PORT}:${PORT}"
env_file: ".env"
depends_on:
- postgres
postgres:
image: postgres:15-alpine
container_name: imphnen_postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-imphnen}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-imphnen}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
volumes:
postgres_data:
minio_data:
+43 -42
View File
@@ -1,42 +1,43 @@
{pkgs, ...}: let
baseImage = pkgs.ociTools.pullImage {
imageName = "ubuntu";
tag = "latest";
};
in
pkgs.dockerTools.buildImage {
name = "imphnen-cms-api";
fromImage = baseImage;
copyToRoot = pkgs.buildEnv {
name = "imphnen-cms-api";
paths = [
(pkgs.stdenv.mkDerivation {
name = "imphnen-cms-api";
src = ./src;
buildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.openssl
pkgs.pkg-config
];
buildPhase = ''
cargo build --release
'';
installPhase = ''
mkdir -p $out/bin
cp target/release/najm-course-api $out/bin/
'';
})
];
};
config = {
Cmd = ["/bin/imphnen-cms-api"];
WorkingDir = "/bin";
};
}
{ pkgs, ... }:
let
baseImage = pkgs.ociTools.pullImage {
imageName = "ubuntu";
tag = "latest";
};
in
pkgs.dockerTools.buildImage {
name = "imphnen-backend-service";
fromImage = baseImage;
copyToRoot = pkgs.buildEnv {
name = "imphnen-backend-service";
paths = [
(pkgs.stdenv.mkDerivation {
name = "imphnen-backend-service";
src = ./src;
buildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.openssl
pkgs.pkg-config
];
buildPhase = ''
cargo build --release
'';
installPhase = ''
mkdir -p $out/bin
cp target/release/imphnen-backend-service $out/bin/
'';
})
];
};
config = {
Cmd = [ "/bin/imphnen-backend-service" ];
WorkingDir = "/bin";
};
}
+9 -9
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Generated
+27 -27
View File
@@ -1,27 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1739020877,
"narHash": "sha256-mIvECo/NNdJJ/bXjNqIh8yeoSjVLAuDuTUzAo7dzs8Y=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a79cfe0ebd24952b580b1cf08cd906354996d547",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1739020877,
"narHash": "sha256-mIvECo/NNdJJ/bXjNqIh8yeoSjVLAuDuTUzAo7dzs8Y=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a79cfe0ebd24952b580b1cf08cd906354996d547",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+41 -32
View File
@@ -1,32 +1,41 @@
{
description = "IMPHNEN Backend Service Nix Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-darwin" "aarch64-linux"];
pkgsFor = system:
import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
};
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in {
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix {};
});
devShells = forAllSystems (system: {
default = (pkgsFor system).callPackage ./shell.nix {};
});
dockerImages = forAllSystems (system: {
tryOutApi = (pkgsFor system).callPackage ./docker.nix {};
});
};
}
{
description = "IMPHNEN Backend Service Nix Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-darwin"
"aarch64-linux"
];
pkgsFor =
system:
import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
};
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix { };
});
devShells = forAllSystems (system: {
default = (pkgsFor system).callPackage ./shell.nix { };
});
dockerImages = forAllSystems (system: {
tryOutApi = (pkgsFor system).callPackage ./docker.nix { };
});
};
}
+57 -9
View File
@@ -1,24 +1,69 @@
[package]
name = "imphnen-backend"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[[bin]]
name = "api"
path = "src/main.rs"
[[bin]]
name = "create_schema"
path = "src/bin/create_schema.rs"
[[bin]]
name = "seeder"
path = "src/bin/seeder.rs"
[[bin]]
name = "seed_events"
path = "src/bin/seed_events.rs"
[[bin]]
name = "seed_gacha_rolls"
path = "src/bin/seed_gacha_rolls.rs"
[[bin]]
name = "seed_mentor_user"
path = "src/bin/seed_mentor_user.rs"
[[bin]]
name = "seed_permissions"
path = "src/bin/seed_permissions.rs"
[[bin]]
name = "seed_roles"
path = "src/bin/seed_roles.rs"
[[bin]]
name = "seed_roles_permissions"
path = "src/bin/seed_roles_permissions.rs"
[[bin]]
name = "seed_test_data"
path = "src/bin/seed_test_data.rs"
[[bin]]
name = "test_postgres"
path = "src/bin/test_postgres.rs"
[dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-gateway = { version = "0.1.0", path = "../imphnen-gateway" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-cms = { version = "0.1.0", path = "../imphnen-cms" }
sea-orm.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-gateway.workspace = true
imphnen-entities.workspace = true
imphnen-iam.workspace = true
imphnen-cms.workspace = true
imphnen-gacha.workspace = true
imphnen-dimentorin.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
axum-test.workspace = true
surrealdb.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
@@ -26,3 +71,6 @@ anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
env_logger.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace=true
+14 -12
View File
@@ -1,12 +1,14 @@
use env_logger;
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
env_logger::init();
axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await
})
.await;
}
// API entry point using PostgreSQL (SurrealDB migration complete)
// This file has been updated to use SeaORM with PostgreSQL instead of SurrealDB
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
axum_init(|postgres_db| async {
// Gateway service now uses PostgreSQL exclusively (SeaORM)
// SurrealDB dependencies have been completely removed
gateway_service(postgres_db).await
})
.await;
}
+93
View File
@@ -0,0 +1,93 @@
#![allow(clippy::all)]
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{Statement, ConnectionTrait};
use std::error::Error;
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
// New default behavior: execute by default; use --dry-run to preview only.
let dry_run = args.iter().any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry");
let force = args.iter().any(|s| s == "--force" || s == "-f");
println!("🔎 Clear DB script - WARNING: This will remove data from tables\n");
println!("Note: script now runs by default (no --yes required). To preview without executing, use --dry-run.\n");
// List of tables to truncate (order doesn't matter with CASCADE)
let tables = vec![
"gacha_claims",
"gacha_rolls",
"gacha_items",
"gacha_credits",
"audit_logs",
"rate_limits",
"testimonials",
"events",
"app_mentors",
"app_sessions",
"app_roles_permissions",
"app_permissions",
"app_roles",
"app_users",
];
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
// Filter tables that actually exist in the database
let mut existing_tables: Vec<&str> = vec![];
for t in tables.iter() {
let check_sql = format!(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;",
t
);
let stmt = Statement::from_string(db.get_database_backend(), check_sql);
if let Ok(Some(row)) = pg_conn.query_one(stmt).await {
let exists_val: Option<bool> = row.try_get("", "exists").ok();
if exists_val.unwrap_or(false) {
existing_tables.push(t);
}
}
}
if existing_tables.is_empty() {
println!("No configured tables found to clear - nothing to do.");
return Ok(());
}
let truncate_sql = format!(
"TRUNCATE TABLE {} RESTART IDENTITY CASCADE;",
existing_tables.join(", ")
);
println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql);
// Prevent accidental execution in production without explicit force flag
let env_name = std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string());
if env_name == "production" && !force {
println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override.");
return Ok(());
}
if dry_run {
println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production).");
return Ok(());
}
println!("Executing truncate...\n");
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let stmt = Statement::from_string(db.get_database_backend(), truncate_sql);
match pg_conn.execute(stmt).await {
Ok(_) => println!("✅ Successfully cleared DB tables"),
Err(e) => println!("❌ Failed to clear DB tables: {}", e),
}
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
#![allow(clippy::all)]
use sea_orm::{ConnectionTrait, Database, Schema, DbBackend, EntityTrait};
use imphnen_libs::postgres::PostgresConfig;
use imphnen_entities::seaorm::{auth, common, gacha};
use sea_orm::sea_query::Table;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🛠️ Creating database schema...");
let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?;
let builder = db.get_database_backend();
println!(" Database connected. Creating/updating tables...");
// Dropping and recreating tables to ensure schema is up-to-date
// This is safer for development/testing environments to prevent schema drift.
drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?;
drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity).await?;
drop_and_create_table(&db, builder, "app_users", auth::users::Entity).await?;
drop_and_create_table(&db, builder, "app_roles_permissions", auth::roles_permissions::Entity).await?;
drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?;
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity).await?;
drop_and_create_table(&db, builder, "events", common::events::Entity).await?;
drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity).await?;
drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity).await?;
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity).await?;
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity).await?;
drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity).await?;
drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity).await?;
drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity).await?;
println!("✅ Schema creation completed.");
Ok(())
}
async fn drop_and_create_table<E>(
db: &sea_orm::DatabaseConnection,
builder: DbBackend,
name: &str,
entity: E,
) -> Result<(), Box<dyn std::error::Error>> // Return Result
where
E: EntityTrait,
{
let schema = Schema::new(builder);
// Drop table if it exists
let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned(); // Added .cascade()
db.execute(builder.build(&drop_stmt)).await?; // Propagate error
println!(" Dropped table if exists: {}", name);
// Create table
let mut create_stmt = schema.create_table_from_entity(entity);
create_stmt.if_not_exists();
db.execute(builder.build(&create_stmt)).await?; // Propagate error
println!(" ✅ Created table: {}", name);
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
#![allow(clippy::all)]
use imphnen_libs::jsonwebtoken::encode_access_token;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: mk_token <email_or_sub>");
std::process::exit(1);
}
let sub = args[1].clone();
// Use sub as both sub and user_id
match encode_access_token(sub.clone(), sub.clone()) {
Ok(token) => println!("{}", token),
Err(e) => {
eprintln!("Failed to generate token: {:?}", e);
std::process::exit(2);
}
}
}
+142 -40
View File
@@ -1,26 +1,20 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_utils::{get_iso_date, Env};
#![allow(clippy::all)]
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing};
use imphnen_libs::enviroment::load_env;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::common::events::{ActiveModel as EventsActiveModel, Entity as EventEntity};
use sea_orm::{ActiveValue::Set, ActiveModelTrait, EntityTrait, ColumnTrait, QueryFilter};
use uuid::Uuid;
use chrono::Utc; // Removed NaiveDateTime as it was unused
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let events = vec![
(
"e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"Tech Conference 2025",
"Annual technology conference featuring the latest innovations in software development, AI, and cloud computing.",
"https://techconf2025.example.com",
@@ -31,7 +25,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-06-17T18:00:00Z",
),
(
"f2b3c4d5-6e7f-8g9h-0i1j-2k3l4m5n6o7p",
"Online Web Development Workshop",
"Comprehensive workshop covering modern web development frameworks including React, Vue, and Angular.",
"https://webdev-workshop.example.com",
@@ -42,7 +35,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-07-10T17:00:00Z",
),
(
"g3c4d5e6-7f8g-9h0i-1j2k-3l4m5n6o7p8q",
"Startup Pitch Competition",
"Exciting competition where emerging startups present their innovative ideas to a panel of expert judges and investors.",
"https://startup-pitch.example.com",
@@ -53,7 +45,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-08-05T16:00:00Z",
),
(
"h4d5e6f7-8g9h-0i1j-2k3l-4m5n6o7p8q9r",
"Digital Marketing Masterclass",
"Learn advanced digital marketing strategies, social media optimization, and data-driven marketing techniques.",
"https://digital-marketing.example.com",
@@ -63,31 +54,142 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-09-20T13:00:00Z",
"2025-09-22T15:00:00Z",
),
// Additional Events
(
"Rust Programming Bootcamp",
"Intensive 3-day bootcamp to master Rust fundamentals and advanced concepts.",
"https://rust-bootcamp.example.com",
200.0,
Some("Bandung Digital Valley".to_string()),
false,
"2025-10-01T09:00:00Z",
"2025-10-03T17:00:00Z",
),
(
"AI & Machine Learning Summit",
"Global summit discussing the future of AI and its impact on industries.",
"https://ai-summit.example.com",
300.0,
Some("Bali Nusa Dua Convention Center".to_string()),
false,
"2025-11-15T08:00:00Z",
"2025-11-17T18:00:00Z",
),
(
"Cybersecurity Awareness Webinar",
"Free webinar on best practices for personal and corporate cybersecurity.",
"https://cybersecurity-webinar.example.com",
0.0,
None,
true,
"2025-12-05T14:00:00Z",
"2025-12-05T16:00:00Z",
),
(
"Cloud Computing Workshop",
"Hands-on workshop on deploying scalable applications using AWS and Azure.",
"https://cloud-workshop.example.com",
120.0,
None,
true,
"2026-01-20T10:00:00Z",
"2026-01-22T15:00:00Z",
),
(
"Blockchain for Finance",
"Exploring the applications of blockchain technology in the financial sector.",
"https://blockchain-finance.example.com",
180.0,
Some("Jakarta Ritz-Carlton".to_string()),
false,
"2026-02-10T09:00:00Z",
"2026-02-11T17:00:00Z",
),
(
"Game Development Jam",
"48-hour game development marathon for indie developers.",
"https://game-jam.example.com",
50.0,
Some("Yogyakarta Creative Hub".to_string()),
false,
"2026-03-15T18:00:00Z",
"2026-03-17T18:00:00Z",
),
(
"UX/UI Design Principles",
"Masterclass on creating intuitive and user-friendly interfaces.",
"https://uxui-design.example.com",
90.0,
None,
true,
"2026-04-05T13:00:00Z",
"2026-04-07T16:00:00Z",
),
(
"Data Science Fundamentals",
"Introduction to data analysis, visualization, and statistical modeling.",
"https://data-science.example.com",
110.0,
None,
true,
"2026-05-12T10:00:00Z",
"2026-05-14T15:00:00Z",
),
(
"IoT Innovation Expo",
"Showcase of the latest Internet of Things devices and solutions.",
"https://iot-expo.example.com",
50.0,
Some("Surabaya Expo Center".to_string()),
false,
"2026-06-20T09:00:00Z",
"2026-06-22T18:00:00Z",
),
];
for (id, name, description, detail_link, price, location, is_online, start_date, end_date) in events {
let event = EventsSchema {
id: Thing::from(("app_events", id)),
name: name.into(),
description: description.into(),
detail_link: detail_link.into(),
price,
location,
is_online,
is_deleted: false,
start_date: start_date.into(),
end_date: end_date.into(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
};
for (
name,
description,
detail_link,
price,
location,
is_online,
start_date_str, // Renamed to avoid conflict
end_date_str, // Renamed to avoid conflict
) in events
{
// Check if event already exists by name
let existing = EventEntity::find().filter(<EventEntity as EntityTrait>::Column::Name.eq(name)).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {name}");
continue;
}
db.create::<Option<EventsSchema>>(("app_events", id))
.content(event)
.await?;
let uuid = Uuid::new_v4(); // Generate a Uuid
let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(uuid);
event_model.name = Set(name.to_string());
event_model.description = Set(description.to_string());
event_model.detail_link = Set(detail_link.to_string());
event_model.price = Set(price);
event_model.is_online = Set(is_online);
event_model.location = Set(location.clone());
event_model.start_date = Set(chrono::DateTime::parse_from_rfc3339(start_date_str)?.with_timezone(&chrono::Utc));
event_model.end_date = Set(chrono::DateTime::parse_from_rfc3339(end_date_str)?.with_timezone(&chrono::Utc));
event_model.is_deleted = Set(false); // Explicitly set is_deleted
event_model.created_at = Set(Utc::now()); // Explicitly set created_at
event_model.updated_at = Set(Utc::now()); // Explicitly set updated_at
println!("✅ Inserted event: {} ({})", name, if is_online { "Online" } else { "In-person" });
event_model.insert(db).await?;
println!(
"✅ Inserted event: {} ({})",
name,
if is_online { "Online" } else { "In-person" }
);
}
println!("✅ All Events seeded");
Ok(())
}
}
@@ -0,0 +1,78 @@
#![allow(clippy::all)]
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::gacha::gacha_items::ActiveModel as GachaItemActiveModel;
use imphnen_entities::seaorm::gacha::gacha_rolls::ActiveModel as GachaRollActiveModel;
use sea_orm::ActiveModelTrait;
use sea_orm::ActiveValue::Set;
use uuid::Uuid;
use sea_orm::ConnectionTrait;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Check if gacha item already exists
let check_item_sql = "SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1";
let item_result = pg_conn.query_one(sea_orm::Statement::from_string(db.get_database_backend(), check_item_sql)).await?;
let gacha_item_uuid = if let Some(ref row) = item_result {
// Item exists, get its ID
row.try_get("", "id")?
} else {
// Item doesn't exist, create it
// Note: We can't easily delete by a fixed ID since it's a UUID, but the insert will fail if there's a conflict
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string())).await.ok();
// Create gacha item via SeaORM
let new_uuid = Uuid::new_v4();
let mut item_model: GachaItemActiveModel = Default::default();
item_model.id = Set(new_uuid);
item_model.item_code = Set("ITEM_TEST_1".to_string());
item_model.name = Set("Test Gacha Item".to_string());
item_model.description = Set("Test item for gacha".to_string());
item_model.rarity = Set("common".to_string());
item_model.type_ = Set("item".to_string());
item_model.category = Set("test".to_string());
item_model.value = Set(1);
item_model.weight = Set(1.0);
item_model.stock = Set(10);
item_model.is_limited = Set(false);
item_model.created_at = Set(chrono::Utc::now());
item_model.updated_at = Set(chrono::Utc::now());
item_model.insert(db).await?;
println!("Gacha Item seeded successfully!");
new_uuid
};
// Always try to insert the roll, relying on the database constraints to prevent duplicates if needed
let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id);
roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
roll_model.gacha_id = Set(Uuid::new_v4().to_string());
roll_model.item_id = Set(gacha_item_uuid);
roll_model.weight = Set(1.0);
roll_model.quantity = Set(10);
roll_model.is_deleted = Set(false);
roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?;
println!("Gacha Roll seeded successfully!");
let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id);
roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
roll_model.gacha_id = Set(Uuid::new_v4().to_string());
roll_model.item_id = Set(gacha_item_uuid);
roll_model.weight = Set(1.0);
roll_model.quantity = Set(10);
roll_model.is_deleted = Set(false);
roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?;
println!("✅ Gacha items and rolls seeded.");
Ok(())
}
@@ -0,0 +1,72 @@
#![allow(clippy::all)]
use imphnen_libs::hash_password;
use serde_json::json;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use imphnen_entities::seaorm::auth::roles::{Entity as RoleEntity, Column as RoleColumn};
use sea_orm::{ActiveModelTrait, ConnectionTrait, ActiveValue::Set, EntityTrait, QueryFilter, ColumnTrait};
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_mentors WHERE id = 'e6f78d23-83bf-5c2b-bcd4-001345678901'".to_string())).await.ok();
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_users WHERE email = 'mentor@example.com'".to_string())).await.ok();
// Find Mentor role
let role = RoleEntity::find()
.filter(RoleColumn::Name.eq("Mentor"))
.one(db)
.await?
.ok_or("Role 'Mentor' not found")?;
// Insert user with Mentor role
let user_id = Uuid::new_v4();
let mut user_model: UsersActiveModel = Default::default();
user_model.id = Set(user_id);
user_model.email = Set("mentor@example.com".to_string());
user_model.password_hash = Set(hash_password("password").unwrap());
user_model.username = Set("mentor@example.com".to_string());
user_model.first_name = Set(Some("Mentor".to_string()));
user_model.last_name = Set(Some("User".to_string()));
user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string()));
user_model.is_active = Set(true);
user_model.is_verified = Set(true);
user_model.role_id = Set(Some(role.id));
user_model.created_at = Set(chrono::Utc::now());
user_model.updated_at = Set(chrono::Utc::now());
user_model.insert(db).await?;
// Insert mentor
let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id);
mentor_model.user_id = Set(user_id);
mentor_model.industries = Set(Some(json!( ["Software", "Education"] )));
mentor_model.expertise = Set(Some(json!( ["Rust", "Microservices"] )));
mentor_model.languages = Set(Some(json!( ["Indonesian", "English"] )));
mentor_model.current_company = Set(Some("PT Contoh".to_string()));
mentor_model.current_role = Set(Some("Senior Backend Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5));
mentor_model.topics_of_interest = Set(Some(json!( ["Rust Programming", "Backend Development"] )));
mentor_model.preferred_mentee_level = Set(Some("beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["online", "offline"] )));
mentor_model.availability_commitment = Set(Some("2 jam per minggu untuk mentoring online dan offline".to_string()));
mentor_model.mentoring_rate = Set(Some(100000.0));
mentor_model.status = Set(Some("verified".to_string()));
mentor_model.is_deleted = Set(false);
mentor_model.created_at = Set(chrono::Utc::now());
mentor_model.updated_at = Set(chrono::Utc::now());
mentor_model.insert(db).await?;
println!("Mentor created successfully!");
println!("✅ Inserted mentor user: mentor@example.com");
println!("✅ Mentor user seeded");
Ok(())
}
+81 -69
View File
@@ -1,69 +1,81 @@
use imphnen_iam::PermissionsEnum;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
for permission in [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
] {
db.query("CREATE type::thing('app_permissions', $id) CONTENT $data")
.bind(("id", permission.id()))
.bind((
"data",
json!({
"name": permission.to_string(),
"is_deleted": false,
"created_at": get_iso_date(),
"updated_at": get_iso_date()
}),
))
.await?;
println!("✅ Inserted: {}", permission.to_string());
}
println!("✅ All Permissions seeded");
Ok(())
}
#![allow(clippy::all)]
use imphnen_iam::PermissionsEnum;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::permissions::ActiveModel as PermissionActiveModel;
use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait};
use uuid::Uuid;
use chrono::Utc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
for permission in [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
PermissionsEnum::Administrator,
] {
// permission.id() returns a string, try parse to uuid
let parsed_id = Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4());
// Check if permission already exists
let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {permission}");
continue;
}
// Insert permission using active model
let mut perm_model: PermissionActiveModel = Default::default();
perm_model.id = Set(parsed_id);
perm_model.name = Set(permission.to_string());
perm_model.is_deleted = Set(false);
perm_model.created_at = Set(Utc::now());
perm_model.updated_at = Set(Utc::now());
perm_model.insert(db).await?;
println!("✅ Inserted: {permission}");
}
println!("✅ All Permissions seeded");
Ok(())
}
+45 -34
View File
@@ -1,22 +1,15 @@
use imphnen_utils::{get_iso_date, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::opt::auth::Root;
use imphnen_libs::enviroment::load_env;
use surrealdb::engine::any;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::roles::{RoleBuilder, Entity as RoleEntity};
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait};
use uuid::Uuid;
use chrono::Utc; // Added chrono
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
let roles = vec![
(
@@ -28,7 +21,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
"User",
None,
Some("2025-02-28T14:53:58.576688+00"),
Some("2025-02-28T14:53:58.576688+00"),
),
(
@@ -46,27 +39,45 @@ async fn main() -> Result<(), Box<dyn Error>> {
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
"Admin",
None,
Some("2025-02-22T15:38:39.868306+00"),
Some("2025-02-22T15:38:39.868306+00"),
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
"Mentor",
Some("2025-07-06T10:00:00.000000+00"),
Some("2025-07-06T10:00:00.000000+00"),
),
];
for (id, name, _created_at, _updated_at) in roles {
db.query("CREATE type::thing('app_roles', $id) CONTENT $data")
.bind(("id", id))
.bind((
"data",
json!({
"name": name,
"permissions": [],
"is_deleted": false,
"created_at": get_iso_date(),
"updated_at": get_iso_date(),
}),
))
.await?;
println!("✅ Inserted role: {}", name);
for (id, name, _created_at_str, _updated_at_str) in roles { // Renamed to avoid conflict
let uuid = Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4());
// Check if role already exists
let existing = RoleEntity::find_by_id(uuid).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {name}");
continue;
}
// Delete existing by id to avoid duplicates (original logic, replaced by existence check)
// let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), format!("DELETE FROM app_roles WHERE id = '{}'", uuid))).await.ok();
let role_model = RoleBuilder::new()
.name(name.to_string())
.description("System generated role".to_string())
.permissions(vec![])
.is_default(false)
.build()?;
let mut role_model = role_model;
role_model.id = Set(uuid);
role_model.is_system_role = Set(true); // Set the missing field
role_model.created_at = Set(Utc::now()); // Set created_at
role_model.updated_at = Set(Utc::now()); // Set updated_at
role_model.insert(db).await?;
println!("✅ Inserted role: {name}");
}
println!("✅ All Roles seeded");
Ok(())
}
}
+112 -58
View File
@@ -1,58 +1,112 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::engine::any;
use imphnen_libs::enviroment::load_env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let permission_refs_admin: Vec<_> = [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
]
.iter()
.map(|perm| make_thing("app_permissions", perm.id()))
.collect();
let admin_role_id = "f6b03f25-e416-4893-ac88-caaa690afb07";
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false")
.bind(("role_id", admin_role_id))
.bind(("permissions", permission_refs_admin))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ All permissions successfully added to Admin role");
Ok(())
}
use imphnen_iam::PermissionsEnum;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
use imphnen_entities::seaorm::auth::roles::ActiveModel as RoleActiveModel;
use sea_orm::ActiveValue::Set;
use sea_orm::EntityTrait;
use sea_orm::ActiveModelTrait;
use uuid::Uuid;
use serde_json::Value as JsonValue;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Ensure indexes are present if needed (placeholders) - we don't modify schema here
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'.");
let roles_permissions = vec![
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
vec![
// Only Administrator permission - grants access to everything
PermissionsEnum::Administrator,
],
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
],
),
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![
// Staff should be able to list roles and permissions in tests
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
vec![PermissionsEnum::ActivateUsers],
),
("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]),
];
for (role_id, permissions) in roles_permissions {
let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4());
// Map permissions enum to JSON array of permission ids
let json_permissions = JsonValue::Array(
permissions.iter().map(|p| JsonValue::String(p.id())).collect()
);
// Find role and update permissions
if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? {
let mut am: RoleActiveModel = role_model.into();
am.permissions = Set(Some(json_permissions));
am.update(db).await?;
println!("✅ Permissions updated for role: {role_id}");
} else {
println!("⚠️ Role with id {role_id} not found, skipping permissions update");
}
}
println!("✅ All roles permissions updated!");
Ok(())
}
+77
View File
@@ -0,0 +1,77 @@
#![allow(clippy::all)]
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::common::events::ActiveModel as EventsActiveModel;
use imphnen_entities::seaorm::common::testimonials::ActiveModel as TestimonialsActiveModel;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use sea_orm::ActiveValue::Set;
use sea_orm::ActiveModelTrait;
use uuid::Uuid;
use serde_json::json;
use chrono::Utc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Seed Events - handle existing data
let uuid = Uuid::new_v4().to_string();
let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(Uuid::parse_str(&uuid)?);
event_model.name = Set("Test Event".to_string());
event_model.description = Set("Test event description".to_string());
event_model.detail_link = Set("https://example.com/event".to_string());
event_model.price = Set(50.0);
event_model.is_online = Set(true);
event_model.start_date = Set(Utc::now());
event_model.end_date = Set(Utc::now() + chrono::Duration::days(1));
event_model.location = Set(None);
event_model.is_deleted = Set(false);
match event_model.insert(db).await {
Ok(_) => println!("✅ Inserted test event"),
Err(_) => println!("⚠️ Test event already exists or could not be inserted, skipping"),
};
// Seed Testimonials - handle existing data
let mut testimonial_model: TestimonialsActiveModel = Default::default();
testimonial_model.id = Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?);
testimonial_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
testimonial_model.role = Set("Student".to_string());
testimonial_model.content = Set("This is a great platform!".to_string());
testimonial_model.is_deleted = Set(false);
match testimonial_model.insert(db).await {
Ok(_) => println!("✅ Inserted test testimonial"),
Err(_) => println!("⚠️ Test testimonial already exists or could not be inserted, skipping"),
};
// Seed Mentor - handle existing data
let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id);
// Use the admin user ID instead of a random one
mentor_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
mentor_model.industries = Set(Some(json!( ["Technology", "Education"] )));
mentor_model.expertise = Set(Some(json!( ["Software Development"] )));
mentor_model.languages = Set(Some(json!( ["English", "Indonesian"] )));
mentor_model.current_company = Set(Some("Tech Corp".to_string()));
mentor_model.current_role = Set(Some("Senior Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5));
mentor_model.topics_of_interest = Set(Some(json!( ["Rust", "Web Development"] )));
mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["1:1", "Group"] )));
mentor_model.availability_commitment = Set(Some("Weekly".to_string()));
mentor_model.mentoring_rate = Set(Some(100.0));
mentor_model.status = Set(Some("active".to_string()));
mentor_model.is_deleted = Set(false);
mentor_model.created_at = Set(chrono::Utc::now());
mentor_model.updated_at = Set(chrono::Utc::now());
// Create mentor record via SeaORM active model
mentor_model.insert(db).await?;
println!("✅ Inserted test mentor via SeaORM");
println!("✅ All test data seeded successfully");
Ok(())
}
+151 -58
View File
@@ -1,68 +1,161 @@
use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env};
#![allow(clippy::all)]
use imphnen_libs::hash_password;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::users::Entity as UserEntity; // Added for dynamic role lookup
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
use uuid::Uuid;
use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing};
use chrono::Utc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let users = vec![
(
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com",
"Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07",
),
(
"a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com",
"Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com",
"User",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com",
"Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07",
),
(
"a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com",
"Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com",
"User",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4",
"testuser1@example.com",
"Test User 1",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID
),
(
"3972c139-a450-416c-93b0-c42539dc780f",
"testuser2@example.com",
"Test User 2",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"b426c0a9-0efb-4e26-b078-4f18767255f3",
"testuser3@example.com",
"Test User 3",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID
),
// Additional Users for Volume and Variety
(
"11111111-1111-1111-1111-111111111111",
"user4@example.com",
"User Four",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"22222222-2222-2222-2222-222222222222",
"user5@example.com",
"User Five",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"33333333-3333-3333-3333-333333333333",
"mentor2@example.com",
"Mentor Two",
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", // Mentor Role
),
(
"44444444-4444-4444-4444-444444444444",
"staff2@example.com",
"Staff Two",
"50133429-f4b1-4249-9f97-7b86e6ee9d86", // Staff Role
),
(
"55555555-5555-5555-5555-555555555555",
"user6@example.com",
"User Six",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"66666666-6666-6666-6666-666666666666",
"user7@example.com",
"User Seven",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"77777777-7777-7777-7777-777777777777",
"user8@example.com",
"User Eight",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"88888888-8888-8888-8888-888888888888",
"user9@example.com",
"User Nine",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"99999999-9999-9999-9999-999999999999",
"user10@example.com",
"User Ten",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
];
for (id, email, fullname, role_id) in users {
let user = UsersSchema {
id: Thing::from(("app_users", id)),
fullname: fullname.into(),
email: email.into(),
password: hash_password("password").unwrap(),
avatar: None,
phone_number: "081234567890".into(),
is_active: true,
is_deleted: false,
gender: None,
birthdate: None,
role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(),
updated_at: get_iso_date(),
};
db.create::<Option<UsersSchema>>(("app_users", id))
.content(user)
.await?;
println!("✅ Inserted user: {} ({})", fullname, email);
}
for (id, email, fullname, role_id_str) in users { // role_id_str directly contains UUID
let role_uuid = Some(Uuid::parse_str(role_id_str)
.map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?);
// Build SeaORM ActiveModel for users
let uid = Uuid::parse_str(id)?; // Should always be valid UUID strings from test data
let names: Vec<&str> = fullname.split_whitespace().collect();
let first_name = names.first().map(|s| s.to_string());
let last_name = if names.len() > 1 { Some(names[1..].join(" ")) } else { None };
let password = "password";
let hashed = hash_password(password).unwrap();
// Explicit Upsert Logic
let existing_user = UserEntity::find_by_id(uid).one(db).await?;
let is_update = existing_user.is_some();
let mut user_model: UsersActiveModel = if let Some(existing) = existing_user {
println!("🔄 Updating user: {fullname} ({email})");
existing.into_active_model()
} else {
println!("✅ Inserting user: {fullname} ({email})");
let mut active: UsersActiveModel = Default::default();
active.id = Set(uid);
active.created_at = Set(Utc::now());
active
};
user_model.email = Set(email.to_string());
user_model.password_hash = Set(hashed);
user_model.username = Set(email.to_string());
user_model.first_name = Set(first_name);
user_model.last_name = Set(last_name);
user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string()));
user_model.is_verified = Set(true);
user_model.is_active = Set(true);
user_model.role_id = Set(role_uuid);
user_model.updated_at = Set(Utc::now());
if is_update {
user_model.update(db).await?;
} else {
user_model.insert(db).await?;
}
}
println!("✅ All Users seeded");
Ok(())
}
}
+33 -26
View File
@@ -1,26 +1,33 @@
use std::error::Error;
use std::process::Command;
fn run_seed(bin: &str) -> Result<(), Box<dyn Error>> {
println!("🔧 Seeding: {bin}");
let status = Command::new("cargo").args(["run", "--bin", bin]).status()?;
if !status.success() {
Err(format!("❌ Failed to run seed: {bin}").into())
} else {
Ok(())
}
}
fn main() -> Result<(), Box<dyn Error>> {
println!("🚀 Running all seeders...\n");
run_seed("seed_permissions")?;
run_seed("seed_roles")?;
run_seed("seed_roles_permissions")?;
run_seed("seed_users")?;
run_seed("seed_events")?;
println!("\n✅ All seeding completed successfully.");
Ok(())
}
#![allow(clippy::all)]
use std::error::Error;
use std::process::Command;
fn run_seed(bin: &str) -> Result<(), Box<dyn Error>> {
println!("🔧 Seeding: {bin}");
#[cfg(target_os = "windows")]
let status = Command::new(format!("./target/release/{}.exe", bin)).status()?;
#[cfg(not(target_os = "windows"))]
let status = Command::new(format!("./target/release/{}", bin)).status()?;
if !status.success() {
Err(format!("❌ Failed to run seed: {bin}").into())
} else {
Ok(())
}
}
fn main() -> Result<(), Box<dyn Error>> {
println!("🚀 Running all seeders...\n");
run_seed("seed_permissions")?;
run_seed("seed_roles")?;
run_seed("seed_roles_permissions")?;
run_seed("seed_users")?;
run_seed("seed_events")?;
run_seed("seed_gacha_rolls")?;
run_seed("seed_mentor_user")?;
run_seed("seed_test_data")?;
println!("\n✅ All seeding completed successfully.");
Ok(())
}
+368
View File
@@ -0,0 +1,368 @@
//! PostgreSQL Connection Test Program
//! This program tests the PostgreSQL integration with SeaORM
use std::sync::Arc;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection, PostgresError};
use imphnen_entities::seaorm::auth::users::{Entity as UsersEntity, Model as UserModel};
use imphnen_entities::seaorm::auth::roles::{Entity as RolesEntity, Model as RoleModel};
use sea_orm::{EntityTrait, ActiveModelTrait, Set, TransactionTrait, DbErr, PaginatorTrait};
use uuid::Uuid;
use chrono::Utc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Starting PostgreSQL Connection Test");
println!("=====================================");
// Load configuration from environment
let config = PostgresConfig::from_env()?;
println!("✅ Configuration loaded successfully");
println!(" Database URL: {}", config.database_url.replace("postgres://", "postgres://****:****@"));
println!(" Pool size: {}", config.pool_size);
println!(" Connect timeout: {}s", config.connect_timeout);
println!(" Retry attempts: {}", config.retry_attempts);
// Test connection
println!("\n🔌 Testing PostgreSQL connection...");
match test_connection(config).await {
Ok(()) => {
println!("✅ All PostgreSQL tests passed successfully!");
Ok(())
}
Err(e) => {
println!("❌ PostgreSQL test failed: {}", e);
Err(e.into())
}
}
}
async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> {
// Create connection
println!(" Creating PostgreSQL connection...");
let postgres_conn = PostgresConnection::new(config).await?;
let connection = Arc::new(postgres_conn);
println!(" ✅ Connection established successfully");
// Test basic connectivity
println!(" Testing basic connectivity...");
test_basic_connectivity(&connection).await?;
println!(" ✅ Basic connectivity test passed");
// Test table existence
println!(" Testing table existence...");
test_table_existence(&connection).await?;
println!(" ✅ Table existence test passed");
// Test CRUD operations
println!(" Testing CRUD operations...");
test_crud_operations(&connection).await?;
println!(" ✅ CRUD operations test passed");
// Test transaction support
println!(" Testing transaction support...");
test_transactions(&connection).await?;
println!(" ✅ Transaction support test passed");
// Test error handling
println!(" Testing error handling...");
test_error_handling(&connection).await?;
println!(" ✅ Error handling test passed");
Ok(())
}
async fn test_basic_connectivity(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
// Execute a simple query
let statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT 1 as test_value, current_timestamp as current_time".to_string()
);
let result = connection.query_one(statement).await?
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("No results returned".to_string())))?;
// Verify we got expected results
let test_value: Option<i32> = result.try_get("", "test_value").ok();
let current_time: Option<String> = result.try_get("", "current_time").ok();
if test_value != Some(1) {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
format!("Expected test_value=1, got {:?}", test_value)
)));
}
if current_time.is_none() {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"Expected current_time to be set".to_string()
)));
}
println!(" 📝 Query result: test_value={:?}, current_time={:?}", test_value, current_time);
Ok(())
}
async fn test_table_existence(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
// Test if our tables exist
use sea_orm::EntityTrait;
println!(" 📋 Checking users table...");
let user_count = UsersEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" 📊 Users table accessible, current count: {}", user_count);
println!(" 📋 Checking roles table...");
let role_count = RolesEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" 📊 Roles table accessible, current count: {}", role_count);
Ok(())
}
async fn test_crud_operations(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
use sea_orm::{ActiveModelTrait, Set};
// Create test user
println!(" Creating test user...");
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("test_user_{}@example.com", test_user_id)),
password_hash: Set("test_password_hash".to_string()),
username: Set(format!("testuser_{}", test_user_id)),
first_name: Set(Some("Test".to_string())),
last_name: Set(Some("User".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let created_user = user_model.insert(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Created user with ID: {}", created_user.id);
// Read user
println!(" 🔍 Reading test user...");
let found_user = UsersEntity::find_by_id(test_user_id)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("User not found after creation".to_string())))?;
println!(" ✅ Found user: {} ({})", found_user.username, found_user.email);
// Update user
println!(" ✏️ Updating test user...");
let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel = found_user.into();
update_model.first_name = Set(Some("Updated".to_string()));
update_model.updated_at = Set(Utc::now());
let updated_user = update_model.update(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Updated user first name to: {:?}", updated_user.first_name);
// Delete user
println!(" 🗑️ Deleting test user...");
UsersEntity::delete_by_id(updated_user.id)
.exec(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Test user deleted successfully");
Ok(())
}
async fn test_transactions(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
println!(" 💰 Testing transaction support...");
// Test transaction with rollback
let transaction_result = connection.conn.transaction(|txn| {
Box::pin(async move {
// Create a test user within transaction
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("transaction_test_{}@example.com", test_user_id)),
password_hash: Set("transaction_password_hash".to_string()),
username: Set(format!("transaction_user_{}", test_user_id)),
first_name: Set(Some("Transaction".to_string())),
last_name: Set(Some("Test".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let _created_user = user_model.insert(txn)
.await?;
// Simulate an error to trigger rollback (return a sea_orm::DbErr so the TransactionError matches)
Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string()))
})
}).await;
// Transaction should fail and rollback
match transaction_result {
Err(e) => {
let e_text = format!("{:?}", e);
if e_text.contains("Simulated transaction failure") {
println!(" ✅ Transaction failed as expected, rollback successful");
} else {
return Err(PostgresError::OperationFailed(format!("Unexpected transaction result: {}", e_text)));
}
}
Ok(_) => {
return Err(PostgresError::OperationFailed("Unexpected transaction result: transaction unexpectedly succeeded".to_string()));
}
}
// Verify user was not created (due to rollback)
let user_exists = UsersEntity::find_by_id(Uuid::nil()) // Use nil UUID as we don't know the actual ID
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.is_some();
if user_exists {
println!(" ⚠️ User found despite rollback - this might indicate an issue");
} else {
println!(" ✅ Transaction rollback verified - user not found");
}
Ok(())
}
async fn test_error_handling(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
println!(" ⚠️ Testing error handling...");
// Test invalid UUID
println!(" 🔍 Testing invalid UUID handling...");
let invalid_uuid = Uuid::nil(); // This should exist or be handled gracefully
match UsersEntity::find_by_id(invalid_uuid)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
{
Some(_) => println!(" ✅ Found user with nil UUID (expected in some cases)"),
None => println!(" ✅ No user found with nil UUID (expected)"),
}
// Test invalid query
println!(" 🔍 Testing invalid query handling...");
let invalid_statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT * FROM non_existent_table".to_string()
);
match connection.execute(invalid_statement).await {
Err(_) => println!(" ✅ Invalid query properly handled with error"),
Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"),
}
Ok(())
}
/// Additional utility functions for comprehensive testing
pub mod test_utils {
use super::*;
/// Create a test PostgreSQL configuration
pub fn create_test_config() -> PostgresConfig {
PostgresConfig {
database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test".to_string(),
pool_size: 5,
connect_timeout: 10,
idle_timeout: 30,
max_lifetime: Some(600),
retry_attempts: 2,
retry_delay: 1,
}
}
/// Create a test user model
pub fn create_test_user_model() -> UserModel {
UserModel {
id: Uuid::new_v4(),
email: format!("test_{}@example.com", Uuid::new_v4()),
password_hash: "test_password_hash".to_string(),
username: format!("testuser_{}", Uuid::new_v4()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
avatar_url: None,
is_verified: false,
is_active: true,
metadata: None,
role_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
/// Create a test role model
pub fn create_test_role_model() -> RoleModel {
RoleModel {
id: Uuid::new_v4(),
name: format!("test_role_{}", Uuid::new_v4()),
description: "Test role description".to_string(),
permissions: Some(serde_json::json!(["test.permission"])),
is_system_role: false,
is_default: false,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_test_config() {
let config = test_utils::create_test_config();
assert_eq!(config.pool_size, 5);
assert_eq!(config.connect_timeout, 10);
assert!(config.database_url.contains("imphnen_test"));
}
#[test]
fn test_create_test_user_model() {
let user = test_utils::create_test_user_model();
assert!(!user.email.is_empty());
assert!(!user.username.is_empty());
assert!(user.is_active);
// is_admin field removed; instead, check role-based permission or is_active
}
#[test]
fn test_create_test_role_model() {
let role = test_utils::create_test_role_model();
assert!(!role.name.is_empty());
assert!(role.permissions.is_some());
assert!(!role.is_system_role);
}
}
+13 -10
View File
@@ -1,10 +1,13 @@
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await
})
.await;
}
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let _ = axum_init(|postgres_conn| async {
// PostgreSQL is now the primary database - SurrealDB has been completely removed
gateway_service(postgres_conn).await
})
.await;
}
+20 -7
View File
@@ -1,25 +1,38 @@
[package]
name = "imphnen-cms"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
async-trait.workspace = true
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
zod-rs.workspace = true
zod-rs-util.workspace = true
axum-test.workspace = true
surrealdb.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
log.workspace = true
tracing.workspace = true
sea-orm.workspace = true
uuid.workspace = true
paginator-rs.workspace = true
paginator-utils.workspace = true
paginator-sea-orm.workspace = true
paginator-axum.workspace = true
[package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://"
@@ -0,0 +1,40 @@
use std::sync::Arc;
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use crate::events::domain::{EventEntity, EventRepository, EventService};
pub struct EventServiceImpl {
repo: Arc<dyn EventRepository>,
}
impl EventServiceImpl {
pub fn new(repo: Arc<dyn EventRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl EventService for EventServiceImpl {
async fn list(&self, params: PaginationParams) -> Result<PaginatorResponse<EventEntity>, AppError> {
self.repo.find_all(params).await
}
async fn get(&self, id: Uuid) -> Result<EventEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn create(&self, entity: EventEntity) -> Result<(), AppError> {
self.repo.create(entity).await
}
async fn update(&self, entity: EventEntity) -> Result<(), AppError> {
self.repo.update(entity).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
}
@@ -0,0 +1,3 @@
pub mod event_service;
pub use event_service::EventServiceImpl;
+18
View File
@@ -0,0 +1,18 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct EventEntity {
pub id: Uuid,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub location: Option<String>,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+7
View File
@@ -0,0 +1,7 @@
pub mod event;
pub mod repository;
pub mod service;
pub use event::EventEntity;
pub use repository::EventRepository;
pub use service::EventService;
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::event::EventEntity;
#[async_trait]
pub trait EventRepository: Send + Sync {
async fn find_all(&self, params: PaginationParams) -> Result<PaginatorResponse<EventEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<EventEntity, AppError>;
async fn create(&self, entity: EventEntity) -> Result<(), AppError>;
async fn update(&self, entity: EventEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
+15
View File
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::event::EventEntity;
#[async_trait]
pub trait EventService: Send + Sync {
async fn list(&self, params: PaginationParams) -> Result<PaginatorResponse<EventEntity>, AppError>;
async fn get(&self, id: Uuid) -> Result<EventEntity, AppError>;
async fn create(&self, entity: EventEntity) -> Result<(), AppError>;
async fn update(&self, entity: EventEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,131 @@
use chrono::{DateTime, Utc};
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::events::domain::event::EventEntity;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsCreateRequestDto {
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub location: Option<String>,
pub is_online: bool,
}
impl ZodValidate for EventsCreateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
impl From<EventsCreateRequestDto> for EventEntity {
fn from(dto: EventsCreateRequestDto) -> Self {
EventEntity {
id: Uuid::new_v4(),
name: dto.name,
description: dto.description,
detail_link: dto.detail_link,
price: dto.price,
is_online: dto.is_online,
is_deleted: false,
location: dto.location,
start_date: dto.start_date,
end_date: dto.end_date,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsUpdateRequestDto {
pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub price: f64,
pub is_online: bool,
pub description: String,
pub detail_link: String,
pub location: Option<String>,
}
impl ZodValidate for EventsUpdateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsListItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub location: Option<String>,
pub is_deleted: bool,
}
impl From<EventEntity> for EventsListItemDto {
fn from(e: EventEntity) -> Self {
EventsListItemDto {
id: e.id.to_string(),
name: e.name,
description: e.description,
detail_link: e.detail_link,
price: e.price,
is_online: e.is_online,
start_date: e.start_date.to_rfc3339(),
end_date: e.end_date.to_rfc3339(),
created_at: e.created_at.to_rfc3339(),
location: e.location,
is_deleted: e.is_deleted,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsDetailItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
impl From<EventEntity> for EventsDetailItemDto {
fn from(e: EventEntity) -> Self {
EventsDetailItemDto {
id: e.id.to_string(),
name: e.name,
description: e.description,
detail_link: e.detail_link,
price: e.price,
is_online: e.is_online,
start_date: e.start_date.to_rfc3339(),
end_date: e.end_date.to_rfc3339(),
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
location: e.location,
}
}
}
@@ -0,0 +1,160 @@
use std::sync::Arc;
use axum::{Extension, extract::Path, http::HeaderMap, response::{IntoResponse, Response}};
use paginator_axum::PaginationQuery;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage};
use imphnen_entities::ResponseSuccessDto;
use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::AppError;
use super::dto::{EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsUpdateRequestDto};
use crate::events::domain::EventService;
#[utoipa::path(
get,
path = "/v1/cms/landing/events",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
),
responses(
(status = 200, description = "[PUBLIC] Get event list")
),
tag = "Events"
)]
pub async fn get_event_list(
Extension(service): Extension<Arc<dyn EventService>>,
PaginationQuery(params): PaginationQuery,
) -> Response {
match service.list(params).await {
Ok(result) => {
let mapped = PaginatorResponse {
data: result.data.into_iter().map(EventsListItemDto::from).collect::<Vec<_>>(),
meta: result.meta,
};
ApiPaginated(mapped).into_response()
}
Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/cms/landing/events/detail/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[PUBLIC] Get event by ID", body = ResponseSuccessDto<EventsDetailItemDto>)
),
tag = "Events"
)]
pub async fn get_event_by_id(
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => return ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}")).into_response(),
};
match service.get(uuid).await {
Ok(event) => ApiSuccess(EventsDetailItemDto::from(event)).into_response(),
Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
#[utoipa::path(
post,
security(("Bearer" = [])),
path = "/v1/cms/landing/events/create",
request_body = EventsCreateRequestDto,
responses(
(status = 201, description = "[ADMIN] Create new event")
),
tag = "Events"
)]
pub async fn post_create_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
ValidatedJson(payload): ValidatedJson<EventsCreateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let entity = payload.into();
service.create(entity).await?;
Ok(ApiMessage::created("Event created"))
})
}
#[utoipa::path(
patch,
security(("Bearer" = [])),
path = "/v1/cms/landing/events/update/{id}",
params(
("id" = String, Path, description = "Event ID")
),
request_body = EventsUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Update event")
),
tag = "Events"
)]
pub async fn patch_update_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<EventsUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
let existing = service.get(uuid).await?;
let entity = crate::events::domain::EventEntity {
id: existing.id,
name: payload.name,
description: payload.description,
detail_link: payload.detail_link,
price: payload.price,
is_online: payload.is_online,
location: payload.location,
start_date: payload.start_date,
end_date: payload.end_date,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: chrono::Utc::now(),
};
service.update(entity).await?;
Ok(ApiMessage::ok("Event updated"))
})
}
#[utoipa::path(
delete,
security(("Bearer" = [])),
path = "/v1/cms/landing/events/delete/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[ADMIN] Soft delete event")
),
tag = "Events"
)]
pub async fn delete_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
service.delete(uuid).await?;
Ok(ApiMessage::ok("Event deleted"))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{events_public_routes, events_protected_routes};
@@ -0,0 +1,31 @@
use std::sync::Arc;
use axum::{Router, routing::{delete, get, patch, post}, Extension};
use sea_orm::DatabaseConnection;
use crate::events::application::EventServiceImpl;
use crate::events::domain::EventService;
use crate::events::infrastructure::persistence::PostgresEventRepository;
use super::handlers::{
delete_event, get_event_by_id, get_event_list, patch_update_event, post_create_event,
};
fn build_service(db: DatabaseConnection) -> Arc<dyn EventService> {
let repo = Arc::new(PostgresEventRepository::new(db));
Arc::new(EventServiceImpl::new(repo))
}
pub fn events_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/cms/landing/events", get(get_event_list))
.route("/cms/landing/events/detail/{id}", get(get_event_by_id))
.layer(Extension(service))
}
pub fn events_protected_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/cms/landing/events/create", post(post_create_event))
.route("/cms/landing/events/update/{id}", patch(patch_update_event))
.route("/cms/landing/events/delete/{id}", delete(delete_event))
.layer(Extension(service))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_event_repository;
pub use postgres_event_repository::PostgresEventRepository;
@@ -0,0 +1,149 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait};
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_entities::seaorm::common::events::{
Entity as EventsEntity, Column as EventsColumn,
ActiveModel as EventsActiveModel, Model as EventsModel,
};
use crate::events::domain::{event::EventEntity, repository::EventRepository};
fn to_entity(model: EventsModel) -> EventEntity {
EventEntity {
id: model.id,
name: model.name,
description: model.description,
detail_link: model.detail_link,
price: model.price,
is_online: model.is_online,
is_deleted: model.is_deleted,
location: model.location,
start_date: model.start_date,
end_date: model.end_date,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresEventRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresEventRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl EventRepository for PostgresEventRepository {
async fn find_all(&self, params: PaginationParams) -> Result<PaginatorResponse<EventEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = EventsEntity::find()
.filter(EventsColumn::IsDeleted.eq(false));
if let Some(ref search) = params.search {
query = query.filter(EventsColumn::Name.contains(&search.query));
}
query = match params.sort_by.as_deref() {
Some("name") => match params.sort_direction {
Some(SortDirection::Desc) => query.order_by(EventsColumn::Name, Order::Desc),
_ => query.order_by(EventsColumn::Name, Order::Asc),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(EventsColumn::CreatedAt, Order::Asc),
_ => query.order_by(EventsColumn::CreatedAt, Order::Desc),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator.num_items().await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let events = paginator.fetch_page((page - 1) as u64).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data = events.into_iter().map(to_entity).collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(&self, id: Uuid) -> Result<EventEntity, AppError> {
let event = EventsEntity::find_by_id(id)
.filter(EventsColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?;
Ok(to_entity(event))
}
async fn create(&self, entity: EventEntity) -> Result<(), AppError> {
let active_model = EventsActiveModel {
id: ActiveValue::Set(entity.id),
name: ActiveValue::Set(entity.name),
description: ActiveValue::Set(entity.description),
detail_link: ActiveValue::Set(entity.detail_link),
price: ActiveValue::Set(entity.price),
is_online: ActiveValue::Set(entity.is_online),
is_deleted: ActiveValue::Set(false),
location: ActiveValue::Set(entity.location),
start_date: ActiveValue::Set(entity.start_date),
end_date: ActiveValue::Set(entity.end_date),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
EventsEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn update(&self, entity: EventEntity) -> Result<(), AppError> {
let mut active_model: EventsActiveModel = EventsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?
.into();
active_model.name = ActiveValue::Set(entity.name);
active_model.description = ActiveValue::Set(entity.description);
active_model.detail_link = ActiveValue::Set(entity.detail_link);
active_model.price = ActiveValue::Set(entity.price);
active_model.is_online = ActiveValue::Set(entity.is_online);
active_model.location = ActiveValue::Set(entity.location);
active_model.start_date = ActiveValue::Set(entity.start_date);
active_model.end_date = ActiveValue::Set(entity.end_date);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.db.as_ref()).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
let mut active_model: EventsActiveModel = EventsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?
.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.db.as_ref()).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::{events_public_routes, events_protected_routes};
+5 -2
View File
@@ -1,2 +1,5 @@
pub mod v1;
pub use v1::*;
pub mod events;
pub mod testimonials;
pub use events::{events_public_routes, events_protected_routes};
pub use testimonials::{testimonials_public_routes, testimonials_protected_routes};
@@ -0,0 +1,3 @@
pub mod testimonial_service;
pub use testimonial_service::TestimonialServiceImpl;
@@ -0,0 +1,40 @@
use std::sync::Arc;
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use crate::testimonials::domain::{TestimonialEntity, TestimonialRepository, TestimonialService};
pub struct TestimonialServiceImpl {
repo: Arc<dyn TestimonialRepository>,
}
impl TestimonialServiceImpl {
pub fn new(repo: Arc<dyn TestimonialRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl TestimonialService for TestimonialServiceImpl {
async fn list(&self, params: PaginationParams) -> Result<PaginatorResponse<TestimonialEntity>, AppError> {
self.repo.find_all(params).await
}
async fn get(&self, id: Uuid) -> Result<TestimonialEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn create(&self, entity: TestimonialEntity) -> Result<TestimonialEntity, AppError> {
self.repo.create(entity).await
}
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> {
self.repo.update(entity).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
}
@@ -0,0 +1,7 @@
pub mod testimonial;
pub mod repository;
pub mod service;
pub use testimonial::TestimonialEntity;
pub use repository::TestimonialRepository;
pub use service::TestimonialService;
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::testimonial::TestimonialEntity;
#[async_trait]
pub trait TestimonialRepository: Send + Sync {
async fn find_all(&self, params: PaginationParams) -> Result<PaginatorResponse<TestimonialEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<TestimonialEntity, AppError>;
async fn create(&self, entity: TestimonialEntity) -> Result<TestimonialEntity, AppError>;
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::testimonial::TestimonialEntity;
#[async_trait]
pub trait TestimonialService: Send + Sync {
async fn list(&self, params: PaginationParams) -> Result<PaginatorResponse<TestimonialEntity>, AppError>;
async fn get(&self, id: Uuid) -> Result<TestimonialEntity, AppError>;
async fn create(&self, entity: TestimonialEntity) -> Result<TestimonialEntity, AppError>;
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,13 @@
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct TestimonialEntity {
pub id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub role: String,
pub content: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -0,0 +1,83 @@
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zod_rs::prelude::*;
use crate::testimonials::domain::testimonial::TestimonialEntity;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct TestimonialsCreateRequestDto {
#[zod(min_length(1), max_length(100))]
pub role: String,
#[zod(min_length(1), max_length(1000))]
pub content: String,
}
impl ZodValidate for TestimonialsCreateRequestDto {
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)]
pub struct TestimonialsUpdateRequestDto {
#[zod(min_length(1), max_length(100))]
pub role: String,
#[zod(min_length(1), max_length(1000))]
pub content: String,
}
impl ZodValidate for TestimonialsUpdateRequestDto {
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)]
pub struct TestimonialsListItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
pub is_deleted: bool,
}
impl From<TestimonialEntity> for TestimonialsListItemDto {
fn from(e: TestimonialEntity) -> Self {
TestimonialsListItemDto {
id: e.id.to_string(),
user_id: e.user_id.to_string(),
user_fullname: e.user_fullname,
role: e.role,
content: e.content,
created_at: e.created_at,
is_deleted: e.is_deleted,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TestimonialsDetailItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
pub updated_at: String,
}
impl From<TestimonialEntity> for TestimonialsDetailItemDto {
fn from(e: TestimonialEntity) -> Self {
TestimonialsDetailItemDto {
id: e.id.to_string(),
user_id: e.user_id.to_string(),
user_fullname: e.user_fullname,
role: e.role,
content: e.content,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}
@@ -0,0 +1,181 @@
use std::sync::Arc;
use axum::{Extension, extract::Path, http::HeaderMap, http::StatusCode, response::{IntoResponse, Response}};
use paginator_axum::PaginationQuery;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_utils::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage, extract_email};
use imphnen_entities::ResponseSuccessDto;
use imphnen_iam::require_auth;
use imphnen_utils::AppError;
use super::dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto,
TestimonialsListItemDto, TestimonialsUpdateRequestDto,
};
use crate::testimonials::domain::{TestimonialEntity, TestimonialService};
#[utoipa::path(
get,
path = "/v1/cms/landing/testimonials",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
),
responses(
(status = 200, description = "[PUBLIC] Get testimonial list")
),
tag = "Testimonials"
)]
pub async fn get_testimonial_list(
Extension(service): Extension<Arc<dyn TestimonialService>>,
PaginationQuery(params): PaginationQuery,
) -> Response {
match service.list(params).await {
Ok(result) => {
let mapped = PaginatorResponse {
data: result.data.into_iter()
.filter(|e| !e.is_deleted)
.map(TestimonialsListItemDto::from)
.collect::<Vec<_>>(),
meta: result.meta,
};
ApiPaginated(mapped).into_response()
}
Err(e) => ApiMessage::new(StatusCode::BAD_REQUEST, e.to_string()).into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/cms/landing/testimonials/detail/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "[PUBLIC] Get testimonial by ID", body = ResponseSuccessDto<TestimonialsDetailItemDto>)
),
tag = "Testimonials"
)]
pub async fn get_testimonial_by_id(
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => return ApiMessage::new(StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}")).into_response(),
};
match service.get(uuid).await {
Ok(t) if !t.is_deleted => {
ApiSuccess(TestimonialsDetailItemDto::from(t)).into_response()
}
Ok(_) => ApiMessage::new(StatusCode::NOT_FOUND, "Testimonial not found").into_response(),
Err(e) => ApiMessage::new(StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
#[utoipa::path(
post,
security(("Bearer" = [])),
path = "/v1/cms/landing/testimonials/create",
request_body = TestimonialsCreateRequestDto,
responses(
(status = 201, description = "[USER] Create new testimonial", body = ResponseSuccessDto<TestimonialsDetailItemDto>)
),
tag = "Testimonials"
)]
pub async fn post_create_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
ValidatedJson(payload): ValidatedJson<TestimonialsCreateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers.clone(), state, {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user = user_info.basic_info;
let user_id = Uuid::parse_str(&user.id)
.map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {e}")))?;
let entity = TestimonialEntity {
id: Uuid::new_v4(),
user_id,
user_fullname: user.fullname.clone(),
role: payload.role,
content: payload.content,
is_deleted: false,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let created = service.create(entity).await?;
Ok(ApiCreated(TestimonialsDetailItemDto::from(created)))
})
}
#[utoipa::path(
patch,
security(("Bearer" = [])),
path = "/v1/cms/landing/testimonials/update/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
request_body = TestimonialsUpdateRequestDto,
responses(
(status = 200, description = "[USER] Update testimonial")
),
tag = "Testimonials"
)]
pub async fn patch_update_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<TestimonialsUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers, state, {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
let existing = service.get(uuid).await?;
let entity = TestimonialEntity {
id: existing.id,
user_id: existing.user_id,
user_fullname: existing.user_fullname,
role: payload.role,
content: payload.content,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: chrono::Utc::now().to_rfc3339(),
};
service.update(entity).await?;
Ok(ApiMessage::ok("Testimonial updated"))
})
}
#[utoipa::path(
delete,
security(("Bearer" = [])),
path = "/v1/cms/landing/testimonials/delete/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "[USER] Soft delete testimonial")
),
tag = "Testimonials"
)]
pub async fn delete_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers, state, {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
service.delete(uuid).await?;
Ok(ApiMessage::ok("Testimonial deleted"))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{testimonials_public_routes, testimonials_protected_routes};
@@ -0,0 +1,32 @@
use std::sync::Arc;
use axum::{Router, routing::{delete, get, patch, post}, Extension};
use sea_orm::DatabaseConnection;
use crate::testimonials::application::TestimonialServiceImpl;
use crate::testimonials::domain::TestimonialService;
use crate::testimonials::infrastructure::persistence::PostgresTestimonialRepository;
use super::handlers::{
delete_testimonial, get_testimonial_by_id, get_testimonial_list,
patch_update_testimonial, post_create_testimonial,
};
fn build_service(db: DatabaseConnection) -> Arc<dyn TestimonialService> {
let repo = Arc::new(PostgresTestimonialRepository::new(db));
Arc::new(TestimonialServiceImpl::new(repo))
}
pub fn testimonials_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/cms/landing/testimonials", get(get_testimonial_list))
.route("/cms/landing/testimonials/detail/{id}", get(get_testimonial_by_id))
.layer(Extension(service))
}
pub fn testimonials_protected_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/cms/landing/testimonials/create", post(post_create_testimonial))
.route("/cms/landing/testimonials/update/{id}", patch(patch_update_testimonial))
.route("/cms/landing/testimonials/delete/{id}", delete(delete_testimonial))
.layer(Extension(service))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_testimonial_repository;
pub use postgres_testimonial_repository::PostgresTestimonialRepository;
@@ -0,0 +1,159 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, QueryOrder, PaginatorTrait};
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_entities::seaorm::common::testimonials::{
Entity as TestimonialsEntity, Column as TestimonialsColumn, ActiveModel as TestimonialsActiveModel,
};
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
use crate::testimonials::domain::{testimonial::TestimonialEntity, repository::TestimonialRepository};
pub struct PostgresTestimonialRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresTestimonialRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl TestimonialRepository for PostgresTestimonialRepository {
async fn find_all(&self, params: PaginationParams) -> Result<PaginatorResponse<TestimonialEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = TestimonialsEntity::find()
.filter(TestimonialsColumn::IsDeleted.eq(false))
.find_also_related(UsersEntity);
query = match params.sort_by.as_deref() {
Some("updated_at") => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by_asc(TestimonialsColumn::UpdatedAt),
_ => query.order_by_desc(TestimonialsColumn::UpdatedAt),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by_asc(TestimonialsColumn::CreatedAt),
_ => query.order_by_desc(TestimonialsColumn::CreatedAt),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator.num_items().await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let rows = paginator.fetch_page((page - 1) as u64).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data: Vec<TestimonialEntity> = rows.into_iter()
.filter_map(|(t, u)| {
u.map(|user| TestimonialEntity {
id: t.id,
user_id: t.user_id,
user_fullname: format!(
"{} {}",
user.first_name.as_deref().unwrap_or(""),
user.last_name.as_deref().unwrap_or("")
).trim().to_string(),
role: t.role,
content: t.content,
is_deleted: t.is_deleted,
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
})
})
.collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(&self, id: Uuid) -> Result<TestimonialEntity, AppError> {
let (testimonial, user) = TestimonialsEntity::find_by_id(id)
.filter(TestimonialsColumn::IsDeleted.eq(false))
.find_also_related(UsersEntity)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?;
let user = user.ok_or_else(|| AppError::NotFoundError("User not found for testimonial".to_string()))?;
Ok(TestimonialEntity {
id: testimonial.id,
user_id: testimonial.user_id,
user_fullname: format!(
"{} {}",
user.first_name.as_deref().unwrap_or(""),
user.last_name.as_deref().unwrap_or("")
).trim().to_string(),
role: testimonial.role,
content: testimonial.content,
is_deleted: testimonial.is_deleted,
created_at: testimonial.created_at.to_rfc3339(),
updated_at: testimonial.updated_at.to_rfc3339(),
})
}
async fn create(&self, entity: TestimonialEntity) -> Result<TestimonialEntity, AppError> {
let active_model = TestimonialsActiveModel {
id: ActiveValue::Set(entity.id),
user_id: ActiveValue::Set(entity.user_id),
role: ActiveValue::Set(entity.role.clone()),
content: ActiveValue::Set(entity.content.clone()),
is_deleted: ActiveValue::Set(false),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
let inserted = active_model.insert(self.db.as_ref()).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(TestimonialEntity {
id: inserted.id,
user_id: inserted.user_id,
user_fullname: entity.user_fullname,
role: inserted.role,
content: inserted.content,
is_deleted: inserted.is_deleted,
created_at: inserted.created_at.to_rfc3339(),
updated_at: inserted.updated_at.to_rfc3339(),
})
}
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> {
let mut active_model: TestimonialsActiveModel = TestimonialsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?
.into();
active_model.role = ActiveValue::Set(entity.role);
active_model.content = ActiveValue::Set(entity.content);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.db.as_ref()).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
let mut active_model: TestimonialsActiveModel = TestimonialsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?
.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.db.as_ref()).await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::{testimonials_public_routes, testimonials_protected_routes};
View File
View File
@@ -1,119 +0,0 @@
use super::{
events_dto::{
EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto,
EventsUpdateRequestDto,
},
events_service::EventsService,
};
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use axum::{Extension, Json};
use imphnen_libs::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto,
};
#[utoipa::path(
get,
path = "/v1/cms/landing/events",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
("filter" = Option<String>, Query, description = "Filter value"),
("filter_by" = Option<String>, Query, description = "Field to filter by"),
),
responses(
(status = 200, description = "Get event list", body = ResponseListSuccessDto<Vec<EventsListItemDto>>)
),
tag = "Events"
)]
pub async fn get_event_list(
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
EventsService::get_event_list(&state, meta).await
}
#[utoipa::path(
get,
path = "/v1/cms/landing/events/detail/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "Get event by ID", body = ResponseSuccessDto<EventsDetailItemDto>)
),
tag = "Events"
)]
pub async fn get_event_by_id(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
EventsService::get_event_by_id(&state, id).await
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/create",
request_body = EventsCreateRequestDto,
responses(
(status = 201, description = "Create new event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn post_create_event(
Extension(state): Extension<AppState>,
Json(payload): Json<EventsCreateRequestDto>,
) -> impl IntoResponse {
EventsService::create_event(&state, payload).await
}
#[utoipa::path(
patch,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/update/{id}",
params(
("id" = String, Path, description = "Event ID")
),
request_body = EventsUpdateRequestDto,
responses(
(status = 200, description = "Update event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn patch_update_event(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<EventsUpdateRequestDto>,
) -> impl IntoResponse {
EventsService::update_event(&state, id, payload).await
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/delete/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "Soft delete event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn delete_event(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
EventsService::delete_event(&state, id).await
}
@@ -1,121 +0,0 @@
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
// Lazy static regex for URL validation
lazy_static! {
static ref VALID_URL_REGEX: Regex = Regex::new(r"^https?://").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsCreateRequestDto {
#[validate(length(min = 1, message = "Name is required"))]
pub name: String,
#[validate(length(min = 1, message = "Description is required"))]
pub description: String,
#[validate(regex(
path = "VALID_URL_REGEX",
message = "Detail link must be a valid URL"
))]
pub detail_link: String,
#[validate(range(min = 0, message = "Price cannot be negative"))]
pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub location: Option<String>,
pub is_online: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsUpdateRequestDto {
#[validate(length(min = 1, message = "Name is required"))]
pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub price: f64,
pub is_online: bool,
pub description: String,
pub detail_link: String,
pub location: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsListItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub location: Option<String>,
pub is_deleted: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsDetailItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsQueryDto {
pub id: Thing,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
impl EventsQueryDto {
pub fn from(self) -> EventsListItemDto {
EventsListItemDto {
id: self.id.id.to_raw(),
name: self.name,
description: self.description,
detail_link: self.detail_link,
price: self.price,
location: self.location,
is_online: self.is_online,
start_date: self.start_date,
end_date: self.end_date,
created_at: self.created_at,
is_deleted: self.is_deleted,
}
}
}
@@ -1,113 +0,0 @@
use super::{events_dto::EventsQueryDto, events_schema::EventsSchema};
use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
pub struct EventsRepository<'a> {
state: &'a AppState,
}
impl<'a> EventsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_event_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Events.to_string())
.with_select_fields(vec!["*"])
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let data = ResponseListSuccessDto {
data: res,
meta: None,
};
Ok(data)
}
// Get event by ID
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&id)
.with_select_fields(vec!["*"]);
let sql = builder.build();
let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
match result {
Some(event) => {
if event.is_deleted {
bail!("Event not found");
}
Ok(event)
}
None => bail!("Event not found"),
}
}
// Create new event
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create event".into()),
None => bail!("Failed to create event"),
}
}
// Update existing event
pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> {
let db = &self.state.surrealdb_ws;
// Cek apakah event ada
let existing = self.query_event_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Event already deleted");
}
// Merge field tertentu jika diperlukan
let merged = EventsSchema {
created_at: existing.created_at,
updated_at: get_iso_date(),
..data
};
let record_key = get_id(&merged.id)?;
let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update event".into()),
None => bail!("Failed to update event"),
}
}
// Soft delete event (mark is_deleted = true)
pub async fn query_delete_event(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let event = self.query_event_by_id(id).await?;
if event.is_deleted {
bail!("Event not found");
}
let record_key = get_id(&event.id)?;
let record: Option<EventsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete event".into()),
None => bail!("Failed to delete event"),
}
}
}
@@ -1,100 +0,0 @@
use imphnen_libs::ResourceEnum;
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use serde::{Deserialize, Serialize};
use imphnen_utils::{get_iso_date, make_thing};
use super::events_dto::{EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsSchema {
pub id: Thing,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub name: String,
pub end_date: String,
pub start_date: String,
pub created_at: String,
pub updated_at: String,
pub description: String,
pub detail_link: String,
pub location: Option<String>,
}
impl Default for EventsSchema {
fn default() -> Self {
Self {
id: make_thing(
&ResourceEnum::Events.to_string(),
&Uuid::new_v4().to_string(),
),
name: String::new(),
description: String::new(),
detail_link: String::new(),
price: 0.0,
location: None,
is_online: false,
is_deleted: false,
start_date: String::new(),
end_date: String::new(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl EventsSchema {
pub fn from(dto: EventsQueryDto) -> Self {
Self {
id: dto.id,
name: dto.name,
description: dto.description,
detail_link: dto.detail_link,
price: dto.price,
location: dto.location,
is_online: dto.is_online,
is_deleted: false,
start_date: dto.start_date,
end_date: dto.end_date,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn create(payload: EventsCreateRequestDto) -> Self {
Self {
id: make_thing(
&ResourceEnum::Events.to_string(),
&Uuid::new_v4().to_string(),
),
name: payload.name,
description: payload.description,
detail_link: payload.detail_link,
price: payload.price,
location: payload.location,
is_online: payload.is_online,
is_deleted: false,
end_date: payload.end_date.to_string(),
start_date: payload.start_date.to_string(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn update(payload: EventsUpdateRequestDto, id: String) -> Self {
Self {
id: make_thing(&ResourceEnum::Events.to_string(), &id),
name: payload.name,
price: payload.price,
location: payload.location,
is_online: payload.is_online,
description: payload.description,
detail_link: payload.detail_link,
end_date: payload.end_date.to_string(),
start_date: payload.start_date.to_string(),
updated_at: get_iso_date(),
..Default::default()
}
}
}
@@ -1,86 +0,0 @@
use super::{
events_dto::{EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto, EventsUpdateRequestDto},
events_repository::EventsRepository,
events_schema::EventsSchema,
};
use imphnen_libs::{AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto};
use imphnen_utils::{common_response, success_list_response, success_response, validate_request};
use axum::{http::StatusCode, response::Response};
pub struct EventsService;
impl EventsService {
pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_list(meta).await {
Ok(data) => {
let items: Vec<EventsListItemDto> = data.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(EventsQueryDto::from)
.collect();
let response = ResponseListSuccessDto {
data: items,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_event_by_id(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_by_id(id).await {
Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto {
data: EventsDetailItemDto {
id: event.id.id.to_raw(),
name: event.name,
description: event.description,
detail_link: event.detail_link,
price: event.price,
is_online: event.is_online,
start_date: event.start_date,
end_date: event.end_date,
created_at: event.created_at,
updated_at: event.updated_at,
location: event.location,
},
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_event(state: &AppState, payload: EventsCreateRequestDto) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::create(payload);
match repo.query_create_event(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_event(state: &AppState, id: String, payload: EventsUpdateRequestDto) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::update(payload, id);
match repo.query_update_event(schema).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_event(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_delete_event(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
-44
View File
@@ -1,44 +0,0 @@
use axum::{
Router,
routing::{delete, get, patch, post},
};
pub mod events_controller;
pub mod events_dto;
pub mod events_repository;
pub mod events_schema;
pub mod events_service;
pub use events_controller::*;
pub use events_dto::*;
pub use events_repository::*;
pub use events_schema::*;
pub use events_service::*;
pub fn events_public_routes() -> Router {
Router::new()
.route(
"/cms/landing/events",
get(events_controller::get_event_list),
)
.route(
"/cms/landing/events/detail/{id}",
get(events_controller::get_event_by_id),
)
}
pub fn events_protected_routes() -> Router {
Router::new()
.route(
"/cms/landing/events/create",
post(events_controller::post_create_event),
)
.route(
"/cms/landing/events/update/{id}",
patch(events_controller::patch_update_event),
)
.route(
"/cms/landing/events/delete/{id}",
delete(events_controller::delete_event),
)
}
-5
View File
@@ -1,5 +0,0 @@
pub mod events;
pub mod testimonials;
pub use events::*;
pub use testimonials::*;
@@ -1,44 +0,0 @@
use axum::{
Router,
routing::{delete, get, patch, post},
};
pub mod testimonials_controller;
pub mod testimonials_dto;
pub mod testimonials_repository;
pub mod testimonials_schema;
pub mod testimonials_service;
pub use testimonials_controller::*;
pub use testimonials_dto::*;
pub use testimonials_repository::*;
pub use testimonials_schema::*;
pub use testimonials_service::*;
pub fn testimonials_public_routes() -> Router {
Router::new()
.route(
"/cms/landing/testimonials",
get(testimonials_controller::get_testimonial_list),
)
.route(
"/cms/landing/testimonials/detail/{id}",
get(testimonials_controller::get_testimonial_by_id),
)
}
pub fn testimonials_protected_routes() -> Router {
Router::new()
.route(
"/cms/landing/testimonials/create",
post(testimonials_controller::post_create_testimonial),
)
.route(
"/cms/landing/testimonials/update/{id}",
patch(testimonials_controller::patch_update_testimonial),
)
.route(
"/cms/landing/testimonials/delete/{id}",
delete(testimonials_controller::delete_testimonial),
)
}
@@ -1,125 +0,0 @@
use super::{
testimonials_dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto,
TestimonialsListItemDto, TestimonialsUpdateRequestDto,
},
testimonials_service::TestimonialsService,
};
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use axum::{Extension, Json};
use imphnen_iam::UsersDetailQueryDto;
use imphnen_libs::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto,
};
#[utoipa::path(
get,
path = "/v1/cms/landing/testimonials",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
("filter" = Option<String>, Query, description = "Filter value"),
("filter_by" = Option<String>, Query, description = "Field to filter by"),
),
responses(
(status = 200, description = "Get testimonial list", body = ResponseListSuccessDto<Vec<TestimonialsListItemDto>>)
),
tag = "Testimonials"
)]
pub async fn get_testimonial_list(
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
TestimonialsService::get_testimonial_list(&state, meta).await
}
#[utoipa::path(
get,
path = "/v1/cms/landing/testimonials/detail/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "Get testimonial by ID", body = ResponseSuccessDto<TestimonialsDetailItemDto>)
),
tag = "Testimonials"
)]
pub async fn get_testimonial_by_id(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
TestimonialsService::get_testimonial_by_id(&state, id).await
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/testimonials/create",
request_body = TestimonialsCreateRequestDto,
responses(
(status = 201, description = "Create new testimonial", body = MessageResponseDto)
),
tag = "Testimonials"
)]
pub async fn post_create_testimonial(
Extension(state): Extension<AppState>,
Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsCreateRequestDto>,
) -> impl IntoResponse {
println!("Authenticated User Now: {:?}", authenticated_user);
TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await
}
#[utoipa::path(
patch,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/testimonials/update/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
request_body = TestimonialsUpdateRequestDto,
responses(
(status = 200, description = "Update testimonial", body = MessageResponseDto)
),
tag = "Testimonials"
)]
pub async fn patch_update_testimonial(
Path(id): Path<String>,
Extension(state): Extension<AppState>,
Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsUpdateRequestDto>,
) -> impl IntoResponse {
TestimonialsService::update_testimonial(&state, id, payload, &authenticated_user)
.await
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/testimonials/delete/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "Soft delete testimonial", body = MessageResponseDto)
),
tag = "Testimonials"
)]
pub async fn delete_testimonial(
Extension(state): Extension<AppState>,
Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Path(id): Path<String>,
) -> impl IntoResponse {
TestimonialsService::delete_testimonial(&state, id, &authenticated_user).await
}
@@ -1,78 +0,0 @@
use imphnen_iam::users::UsersSchema;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct TestimonialsCreateRequestDto {
#[validate(length(min = 1, message = "Role is required"))]
pub role: String,
#[validate(length(
min = 1,
max = 500,
message = "Content must be between 1 and 500 characters"
))]
pub content: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct TestimonialsUpdateRequestDto {
#[validate(length(min = 1, message = "Role is required"))]
pub role: String,
#[validate(length(
min = 1,
max = 500,
message = "Content must be between 1 and 500 characters"
))]
pub content: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TestimonialsListItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name
pub role: String,
pub content: String,
pub created_at: String,
pub is_deleted: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TestimonialsDetailItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name
pub role: String,
pub content: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsQueryDto {
pub id: Thing,
pub user: UsersSchema, // Change from Thing to UsersSchema
pub role: String,
pub content: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl TestimonialsQueryDto {
pub fn from(self) -> TestimonialsListItemDto {
TestimonialsListItemDto {
id: self.id.id.to_raw(),
user_id: self.user.id.id.to_raw(),
user_fullname: self.user.fullname, // Extract fullname from UsersSchema
role: self.role,
content: self.content,
created_at: self.created_at,
is_deleted: self.is_deleted,
}
}
}
@@ -1,120 +0,0 @@
use super::{
testimonials_dto::TestimonialsQueryDto, testimonials_schema::TestimonialsSchema,
};
use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
pub struct TestimonialsRepository<'a> {
state: &'a AppState,
}
impl<'a> TestimonialsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_testimonial_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<TestimonialsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Testimonials.to_string())
.with_select_fields(vec!["*", "user.* as user"]) // Select user details
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let data = ResponseListSuccessDto {
data: res,
meta: None,
};
Ok(data)
}
pub async fn query_testimonial_by_id(
&self,
id: String,
) -> Result<TestimonialsQueryDto> {
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_id(&id)
.with_select_fields(vec!["*", "user.* as user"]); // Select user details
let sql = builder.build();
let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
match result {
Some(testimonial) => {
if testimonial.is_deleted {
bail!("Testimonial not found");
}
Ok(testimonial)
}
None => bail!("Testimonial not found"),
}
}
pub async fn query_create_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create testimonial".into()),
None => bail!("Failed to create testimonial"),
}
}
pub async fn query_update_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let existing = self.query_testimonial_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Testimonial already deleted");
}
let merged = TestimonialsSchema {
created_at: existing.created_at,
updated_at: get_iso_date(),
user: existing.user.id, // Preserve user ID
..data
};
let record_key = get_id(&merged.id)?;
let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update testimonial".into()),
None => bail!("Failed to update testimonial"),
}
}
pub async fn query_delete_testimonial(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let testimonial = self.query_testimonial_by_id(id).await?;
if testimonial.is_deleted {
bail!("Testimonial not found");
}
let record_key = get_id(&testimonial.id)?;
let record: Option<TestimonialsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete testimonial".into()),
None => bail!("Failed to delete testimonial"),
}
}
}
@@ -1,84 +0,0 @@
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use super::testimonials_dto::{
TestimonialsCreateRequestDto, TestimonialsQueryDto, TestimonialsUpdateRequestDto,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsSchema {
pub id: Thing,
pub user: Thing, // Link to app_users table
pub role: String,
pub content: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl Default for TestimonialsSchema {
fn default() -> Self {
Self {
id: make_thing(
&ResourceEnum::Testimonials.to_string(),
&Uuid::new_v4().to_string(),
),
user: make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(), // Placeholder, will be replaced by actual user ID
),
role: String::new(),
content: String::new(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl TestimonialsSchema {
pub fn from(dto: TestimonialsQueryDto) -> Self {
Self {
id: dto.id,
user: dto.user.id,
role: dto.role,
content: dto.content,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn create(payload: TestimonialsCreateRequestDto, user_id: &Thing) -> Self {
Self {
id: make_thing(
&ResourceEnum::Testimonials.to_string(),
&Uuid::new_v4().to_string(),
),
user: user_id.clone(),
role: payload.role,
content: payload.content,
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn update(
payload: TestimonialsUpdateRequestDto,
id: String,
user_id: &Thing,
) -> Self {
Self {
id: make_thing(&ResourceEnum::Testimonials.to_string(), &id),
role: payload.role,
content: payload.content,
updated_at: get_iso_date(),
user: user_id.clone(),
..Default::default()
}
}
}
@@ -1,108 +0,0 @@
use super::{
testimonials_dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto,
TestimonialsListItemDto, TestimonialsUpdateRequestDto,
},
testimonials_repository::TestimonialsRepository,
testimonials_schema::TestimonialsSchema,
};
use axum::{http::StatusCode, response::Response};
use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
pub struct TestimonialsService;
impl TestimonialsService {
pub async fn get_testimonial_list(
state: &AppState,
meta: MetaRequestDto,
) -> Response {
let repo = TestimonialsRepository::new(state);
match repo.query_testimonial_list(meta).await {
Ok(data) => {
let items: Vec<TestimonialsListItemDto> = data
.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(|e| e.from())
.collect();
let response = ResponseListSuccessDto {
data: items,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_testimonial_by_id(state: &AppState, id: String) -> Response {
let repo = TestimonialsRepository::new(state);
match repo.query_testimonial_by_id(id).await {
Ok(testimonial) if !testimonial.is_deleted => {
success_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto {
id: testimonial.id.id.to_raw(),
user_id: testimonial.user.id.id.to_raw(),
user_fullname: testimonial.user.fullname,
role: testimonial.role,
content: testimonial.content,
created_at: testimonial.created_at,
updated_at: testimonial.updated_at,
},
})
}
Ok(_) => common_response(StatusCode::NOT_FOUND, "Testimonial not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_testimonial(
state: &AppState,
payload: TestimonialsCreateRequestDto,
authenticated_user: &imphnen_iam::UsersDetailQueryDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = TestimonialsRepository::new(state);
let schema = TestimonialsSchema::create(payload, &authenticated_user.id);
match repo.query_create_testimonial(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_testimonial(
state: &AppState,
id: String,
payload: TestimonialsUpdateRequestDto,
authenticated_user: &imphnen_iam::UsersDetailQueryDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = TestimonialsRepository::new(state);
let schema = TestimonialsSchema::update(payload, id, &authenticated_user.id);
match repo.query_update_testimonial(schema).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_testimonial(
state: &AppState,
id: String,
_authenticated_user: &imphnen_iam::UsersDetailQueryDto,
) -> Response {
let repo = TestimonialsRepository::new(state);
match repo.query_delete_testimonial(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod landing;
pub use landing::*;
+38 -24
View File
@@ -1,24 +1,38 @@
[package]
name = "imphnen-dimentorin"
version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
axum-test.workspace = true
surrealdb.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
[package]
name = "imphnen-dimentorin"
version = "0.2.0"
edition = "2024"
[dependencies]
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
imphnen-iam.workspace = true
imphnen-middleware.workspace = true
axum.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
zod-rs.workspace = true
zod-rs-util.workspace = true
axum-test.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
tracing.workspace = true
uuid.workspace = true
sea-orm.workspace = true
paginator-rs.workspace = true
paginator-utils.workspace = true
paginator-sea-orm.workspace = true
paginator-axum.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
+5 -14
View File
@@ -1,14 +1,5 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
pub mod mentors;
pub mod sessions;
pub use mentors::{mentors_public_routes, mentors_protected_routes};
pub use sessions::{sessions_public_routes, sessions_protected_routes};
@@ -0,0 +1,411 @@
use std::sync::Arc;
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_libs::{AppState, hash_password};
use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto};
use imphnen_iam::users::domain::{UserRepository, UserEntity};
use imphnen_iam::roles::domain::RoleRepository;
use tracing::error;
use crate::mentors::domain::{MentorEntity, MentorRepository, MentorService};
use crate::mentors::infrastructure::http::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
pub struct MentorServiceImpl {
repo: Arc<dyn MentorRepository>,
state: Arc<AppState>,
user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>,
}
impl MentorServiceImpl {
pub fn new(
repo: Arc<dyn MentorRepository>,
state: Arc<AppState>,
user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>,
) -> Self {
Self { repo, state, user_repo, role_repo }
}
fn build_detail_response(
entity: &MentorEntity,
user: Option<&imphnen_entities::UsersDetailQueryDto>,
) -> MentorDetailResponseDto {
MentorDetailResponseDto {
id: entity.id.to_string(),
user_id: entity.user_id.to_string(),
fullname: user.map(|u| u.fullname.clone()),
email: user.map(|u| u.email.clone()),
legal_name: user.and_then(|u| u.legal_name.clone()),
gender: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.gender.clone()),
domicile: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.domicile.clone()),
phone_for_verification: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.phone_for_verification.clone()),
bio: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.bio.clone()),
last_education: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.last_education.clone()),
linkedin_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.linkedin_url.clone()),
github_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.github_url.clone()),
cv_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.cv_url.clone()),
portfolio_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.portfolio_url.clone()),
industries: entity.industries.clone(),
expertise: entity.expertise.clone(),
languages: entity.languages.clone(),
current_company: entity.current_company.clone(),
current_role: entity.current_role.clone(),
years_of_experience: entity.years_of_experience,
topics_of_interest: entity.topics_of_interest.clone(),
preferred_mentee_level: entity.preferred_mentee_level.clone(),
preferred_mentoring_formats: entity.preferred_mentoring_formats.clone(),
availability_commitment: entity.availability_commitment.clone(),
mentoring_rate: entity.mentoring_rate,
status: entity.status.clone(),
created_at: entity.created_at.to_rfc3339(),
updated_at: entity.updated_at.to_rfc3339(),
}
}
}
#[async_trait]
impl MentorService for MentorServiceImpl {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorListResponseDto>, AppError> {
let result = self.repo.find_all(params).await?;
let mut items: Vec<MentorListResponseDto> = Vec::with_capacity(result.data.len());
for entity in &result.data {
let mut item = MentorListResponseDto {
id: entity.id.to_string(),
user_id: entity.user_id.to_string(),
fullname: None,
email: None,
status: entity.status.clone(),
created_at: entity.created_at.to_rfc3339(),
updated_at: entity.updated_at.to_rfc3339(),
};
if let Ok(info) = self.state.user_lookup_service
.get_user_by_id(entity.user_id, self.state.as_ref())
.await
{
item.fullname = Some(info.basic_info.fullname);
item.email = Some(info.basic_info.email);
}
items.push(item);
}
Ok(PaginatorResponse { data: items, meta: result.meta })
}
async fn get_by_id(&self, id: Uuid) -> Result<MentorDetailResponseDto, AppError> {
let entity = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(entity.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&entity, user.as_ref()))
}
async fn get_by_email(&self, email: &str) -> Result<MentorDetailResponseDto, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let entity = self.repo.find_by_user_id(user_id, false).await?;
Ok(Self::build_detail_response(&entity, Some(&user_dto)))
}
async fn register(
&self,
dto: MentorUserRegisterRequestDto,
) -> Result<MentorRegisterResponseDto, AppError> {
let user_email = dto.email.clone();
let user_id: Uuid = match self.user_repo.find_by_email(user_email.clone()).await {
Ok(mut entity) => {
let existing_user_id = Uuid::parse_str(&entity.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if self.repo.find_by_user_id(existing_user_id, false).await.is_ok() {
return Err(AppError::ConflictError(
"Mentor profile already exists for this user".to_string(),
));
}
let mentor_role = self.role_repo
.find_by_name("Mentor".to_string())
.await
.map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?;
entity.fullname = dto.fullname.clone();
entity.is_active = false;
entity.role = RolesDetailQueryDto {
id: mentor_role.id.to_string(),
name: mentor_role.name.clone(),
..Default::default()
};
entity.password = hash_password(&dto.password).map_err(|e| {
error!("Failed to hash password for {}: {}", user_email, e);
AppError::InternalServerError("Failed to hash password".to_string())
})?;
let mut profile_ext = entity.profile_extension.clone().unwrap_or_default();
profile_ext.phone_number = dto.phone_number.clone();
profile_ext.phone_for_verification = dto.identity_and_verification.phone_for_verification.clone();
profile_ext.gender = dto.identity_and_verification.gender.clone();
profile_ext.domicile = dto.identity_and_verification.domicile.clone();
profile_ext.bio = Some(dto.professional_profile.bio.clone());
profile_ext.last_education = dto.professional_profile.last_education.clone();
profile_ext.linkedin_url = dto.professional_profile.linkedin_url.clone();
profile_ext.github_url = dto.professional_profile.github_url.clone();
profile_ext.cv_url = dto.professional_profile.cv_url.clone();
profile_ext.portfolio_url = dto.professional_profile.portfolio_url.clone();
entity.profile_extension = Some(profile_ext);
let uid_str = entity.id.clone();
self.user_repo.update(entity).await.map_err(|e| {
error!("Failed to update user {} to mentor role: {}", user_email, e);
AppError::InternalServerError(e.to_string())
})?;
Uuid::parse_str(&uid_str)
.map_err(|e| AppError::InternalServerError(e.to_string()))?
}
Err(_) => {
let mentor_role = self.role_repo
.find_by_name("Mentor".to_string())
.await
.map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?;
let hashed_password = hash_password(&dto.password).map_err(|e| {
error!("Failed to hash password for new user {}: {}", user_email, e);
AppError::InternalServerError("Failed to hash password".to_string())
})?;
let new_user_id = Uuid::new_v4();
let profile_ext = UserProfileExtensionDto {
phone_number: dto.phone_number.clone(),
phone_for_verification: dto.identity_and_verification.phone_for_verification.clone(),
gender: dto.identity_and_verification.gender.clone(),
domicile: dto.identity_and_verification.domicile.clone(),
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(),
..Default::default()
};
let new_entity = UserEntity {
id: new_user_id.to_string(),
email: dto.email.clone(),
fullname: dto.fullname.clone(),
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
password: hashed_password,
is_active: false,
role: RolesDetailQueryDto {
id: mentor_role.id.to_string(),
name: mentor_role.name.clone(),
..Default::default()
},
profile_extension: Some(profile_ext),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
..Default::default()
};
self.user_repo.create(new_entity).await.map_err(|e| {
error!("Failed to create new user {}: {}", user_email, e);
AppError::InternalServerError(e.to_string())
})?;
new_user_id
}
};
let new_entity = MentorEntity {
id: Uuid::new_v4(),
user_id,
industries: dto.professional_profile.industries.clone(),
expertise: dto.professional_profile.expertise.clone(),
languages: dto.professional_profile.languages.clone(),
current_company: dto.professional_profile.current_company.clone(),
current_role: dto.professional_profile.current_role.clone(),
years_of_experience: dto.professional_profile.years_of_experience,
topics_of_interest: dto.mentoring_logistics.topics_of_interest.clone(),
preferred_mentee_level: dto.mentoring_logistics.preferred_mentee_level.clone(),
preferred_mentoring_formats: dto.mentoring_logistics.preferred_mentoring_formats.clone(),
availability_commitment: dto.mentoring_logistics.availability_commitment.clone(),
mentoring_rate: dto.mentoring_logistics.mentoring_rate_amount as f64,
status: "pending".to_string(),
is_deleted: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mentor_id = self.repo.create(new_entity.clone()).await.map_err(|e| {
error!("Failed to create mentor profile for {}: {}", user_email, e);
e
})?;
Ok(MentorRegisterResponseDto {
id: mentor_id.to_string(),
user_id: user_id.to_string(),
email: Some(user_email),
status: "pending".to_string(),
created_at: new_entity.created_at.to_rfc3339(),
updated_at: new_entity.updated_at.to_rfc3339(),
})
}
async fn update(
&self,
id: Uuid,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let mut entity = self.repo.find_by_id(id, false).await?;
if let Some(val) = dto.industries { entity.industries = val; }
if let Some(val) = dto.expertise { entity.expertise = val; }
if let Some(val) = dto.languages { entity.languages = val; }
if let Some(val) = dto.current_company { entity.current_company = val; }
if let Some(val) = dto.current_role { entity.current_role = val; }
if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; }
if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; }
if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; }
if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; }
if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; }
if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; }
entity.updated_at = chrono::Utc::now();
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, user.as_ref()))
}
async fn update_me(
&self,
email: &str,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mut entity = self.repo.find_by_user_id(user_id, false).await?;
if let Some(val) = dto.industries { entity.industries = val; }
if let Some(val) = dto.expertise { entity.expertise = val; }
if let Some(val) = dto.languages { entity.languages = val; }
if let Some(val) = dto.current_company { entity.current_company = val; }
if let Some(val) = dto.current_role { entity.current_role = val; }
if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; }
if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; }
if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; }
if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; }
if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; }
if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; }
entity.updated_at = chrono::Utc::now();
let entity_id = entity.id;
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(entity_id, false).await?;
let refreshed_user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, refreshed_user.as_ref()))
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.soft_delete(id).await
}
async fn verify(
&self,
id: Uuid,
dto: MentorVerifyRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let mut entity = self.repo.find_by_id(id, false).await?;
entity.status = dto.status;
entity.updated_at = chrono::Utc::now();
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, user.as_ref()))
}
async fn get_status(&self, email: &str) -> Result<String, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| {
AppError::NotFoundError("No mentor application found for current user".to_string())
})?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let entity = self.repo.find_by_user_id(user_id, false).await.map_err(|_| {
AppError::NotFoundError("No mentor application found for current user".to_string())
})?;
Ok(entity.status)
}
async fn get_entity_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
self.repo.find_by_id(id, include_deleted).await
}
}
@@ -0,0 +1,3 @@
pub mod mentor_service;
pub use mentor_service::MentorServiceImpl;
@@ -0,0 +1,22 @@
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct MentorEntity {
pub id: Uuid,
pub user_id: Uuid,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -0,0 +1,7 @@
pub mod mentor;
pub mod repository;
pub mod service;
pub use mentor::MentorEntity;
pub use repository::MentorRepository;
pub use service::MentorService;
@@ -0,0 +1,33 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::mentor::MentorEntity;
#[async_trait]
pub trait MentorRepository: Send + Sync {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorEntity>, AppError>;
async fn find_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
async fn find_by_user_id(
&self,
user_id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError>;
async fn update(&self, entity: MentorEntity) -> Result<(), AppError>;
async fn soft_delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,55 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::mentor::MentorEntity;
use crate::mentors::infrastructure::http::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
#[async_trait]
pub trait MentorService: Send + Sync {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorListResponseDto>, AppError>;
async fn get_by_id(&self, id: Uuid) -> Result<MentorDetailResponseDto, AppError>;
async fn get_by_email(&self, email: &str) -> Result<MentorDetailResponseDto, AppError>;
async fn register(
&self,
dto: MentorUserRegisterRequestDto,
) -> Result<MentorRegisterResponseDto, AppError>;
async fn update(
&self,
id: Uuid,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn update_me(
&self,
email: &str,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn verify(
&self,
id: Uuid,
dto: MentorVerifyRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn get_status(&self, email: &str) -> Result<String, AppError>;
async fn get_entity_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
}
@@ -0,0 +1,261 @@
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zod_rs::prelude::*;
// ============================================================
// Response DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: Option<String>,
pub gender: Option<String>,
pub domicile: Option<String>,
pub phone_for_verification: Option<String>,
pub bio: Option<String>,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorRegisterResponseDto {
pub id: String,
pub user_id: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
// ============================================================
// Request DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentorUserRegisterRequestDto {
#[zod(email, min_length(1))]
pub email: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
pub password: String,
#[zod(min_length(2))]
pub fullname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
pub identity_and_verification: IdentityAndVerification,
pub professional_profile: ProfessionalProfile,
pub mentoring_logistics: MentoringLogistics,
}
impl ZodValidate for MentorUserRegisterRequestDto {
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)]
pub struct IdentityAndVerification {
#[zod(min_length(3))]
pub legal_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[zod(url)]
pub identity_document_url: String,
#[zod(min_length(10), max_length(15))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
}
impl ZodValidate for IdentityAndVerification {
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)]
pub struct ProfessionalProfile {
#[zod(min_length(50))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
#[zod(min_length(1))]
pub current_company: String,
#[zod(min_length(1))]
pub current_role: String,
#[zod(min(2.0), int)]
pub years_of_experience: i32,
}
impl ZodValidate for ProfessionalProfile {
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)]
pub struct MentoringLogistics {
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
#[zod(min_length(5))]
pub availability_commitment: String,
#[zod(min(1.0))]
pub mentoring_rate_amount: u64,
}
impl ZodValidate for MentoringLogistics {
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)]
pub struct MentorUpdateRequestDto {
#[zod(min_length(3))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[zod(min_length(10), max_length(15))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[zod(min_length(50))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[zod(min(2.0), int)]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[zod(min_length(5))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[zod(min(1.0))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
impl ZodValidate for MentorUpdateRequestDto {
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)]
pub struct MentorVerifyRequestDto {
#[zod(min_length(1))]
pub status: String,
}
impl ZodValidate for MentorVerifyRequestDto {
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, Default)]
pub struct MentoringRate {
#[zod(min(1.0))]
pub amount: u64,
#[zod(min_length(1))]
pub currency: String,
#[zod(min_length(1))]
pub per_duration: String,
}
impl ZodValidate for MentoringRate {
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)]
pub struct MentorRegisterFromTokenRequestDto {
pub identity_and_verification: IdentityAndVerification,
pub professional_profile: ProfessionalProfile,
pub mentoring_logistics: MentoringLogistics,
}
impl ZodValidate for MentorRegisterFromTokenRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
@@ -0,0 +1,279 @@
use std::sync::Arc;
use axum::{
extract::{Extension, Path},
http::HeaderMap,
response::{IntoResponse, Response},
};
use paginator_axum::PaginationQuery;
use uuid::Uuid;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage, extract_email};
use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::AppError;
use crate::mentors::domain::MentorService;
use super::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
#[utoipa::path(
post,
path = "/v1/mentors/create",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "[PUBLIC] Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "[PUBLIC] Bad request - validation error"),
(status = 409, description = "[PUBLIC] Conflict - user already has mentor profile"),
(status = 500, description = "[PUBLIC] Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(service): Extension<Arc<dyn MentorService>>,
ValidatedJson(dto): ValidatedJson<MentorUserRegisterRequestDto>,
) -> Response {
match service.register(dto).await {
Ok(resp) => ApiSuccess(resp).into_response(),
Err(e) => ApiMessage::new(e.status_code(), e.to_string()).into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/mentors",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search query"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
),
responses(
(status = 200, description = "[ADMIN] Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
PaginationQuery(params): PaginationQuery,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::ReadListMentors], {
let result = service.list(params).await?;
Ok(ApiPaginated(result))
})
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::ReadDetailMentors], {
let dto = service.get_by_id(mentor_uuid).await?;
Ok(ApiSuccess(dto))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::UpdateMentors], {
let result = service.update(mentor_uuid, dto).await?;
Ok(ApiSuccess(result))
})
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Mentor deleted successfully"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::DeleteMentors], {
service.delete(mentor_uuid).await?;
Ok(ApiMessage::ok("Mentor deleted successfully"))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorVerifyRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::VerifyMentors], {
let result = service.verify(mentor_uuid, dto).await?;
Ok(ApiSuccess(result))
})
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "[MENTOR] Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] Mentor profile not found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_me(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorProfile], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let dto = service.get_by_email(&email).await
.map_err(|_| AppError::ForbiddenError("Mentor profile not found for current user".to_string()))?;
Ok(ApiSuccess(dto))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/me/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[MENTOR] Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[MENTOR] Bad request - validation error"),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 404, description = "[MENTOR] Mentor profile not found"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn put_update_mentor_me(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::UpdateOwnMentorProfile], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.update_me(&email, dto).await?;
Ok(ApiSuccess(resp))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "[PUBLIC] Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> impl IntoResponse {
ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, "Mentor ID is required for update")
}
#[utoipa::path(
get,
path = "/v1/mentors/me/status",
responses(
(status = 200, description = "[MENTOR] Mentor application status", body = String),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] No mentor application found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_status(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorStatus], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let status = service.get_status(&email).await
.map_err(|_| AppError::ForbiddenError("No mentor application found for current user".to_string()))?;
Ok(ApiMessage::ok(&status))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{mentors_protected_routes, mentors_public_routes};
@@ -0,0 +1,47 @@
use std::sync::Arc;
use axum::{
routing::{delete, get, post, put},
Extension, Router,
};
use sea_orm::DatabaseConnection;
use imphnen_libs::AppState;
use imphnen_iam::users::infrastructure::persistence::PostgresUserRepository;
use imphnen_iam::roles::infrastructure::persistence::PostgresRoleRepository;
use crate::mentors::application::MentorServiceImpl;
use crate::mentors::domain::MentorService;
use crate::mentors::infrastructure::persistence::PostgresMentorRepository;
use super::handlers::{
delete_mentor, get_mentor_by_id, get_mentor_list, get_mentor_me, get_mentor_status,
post_register_mentor, put_update_mentor, put_update_mentor_me, put_update_mentor_no_id,
put_verify_mentor,
};
fn build_service(db: DatabaseConnection, state: Arc<AppState>) -> Arc<dyn MentorService> {
let user_repo = Arc::new(PostgresUserRepository::new(db.clone()));
let role_repo = Arc::new(PostgresRoleRepository::new(db.clone()));
let repo = Arc::new(PostgresMentorRepository::new(db));
Arc::new(MentorServiceImpl::new(repo, state, user_repo, role_repo))
}
pub fn mentors_public_routes(db: DatabaseConnection, state: Arc<AppState>) -> Router {
let service = build_service(db, state);
Router::new()
.route("/mentors/create", post(post_register_mentor))
.layer(Extension(service))
}
pub fn mentors_protected_routes(db: DatabaseConnection, state: Arc<AppState>) -> Router {
let svc = build_service(db, Arc::clone(&state));
Router::new()
.route("/mentors", get(get_mentor_list))
.route("/mentors/me", get(get_mentor_me))
.route("/mentors/me/update", put(put_update_mentor_me))
.route("/mentors/me/status", get(get_mentor_status))
.route("/mentors/detail/{id}", get(get_mentor_by_id))
.route("/mentors/update/{id}", put(put_update_mentor))
.route("/mentors/update", put(put_update_mentor_no_id))
.route("/mentors/delete/{id}", delete(delete_mentor))
.route("/mentors/verify/{id}", put(put_verify_mentor))
.layer(Extension(svc))
.layer(Extension((*state).clone()))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_mentor_repository;
pub use postgres_mentor_repository::PostgresMentorRepository;
@@ -0,0 +1,283 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait};
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_entities::seaorm::auth::mentors::{
Entity as MentorsEntity,
Column as MentorColumn,
ActiveModel as MentorActiveModel,
Model as MentorModel,
};
use crate::mentors::domain::{mentor::MentorEntity, repository::MentorRepository};
fn model_to_entity(model: MentorModel) -> MentorEntity {
MentorEntity {
id: model.id,
user_id: model.user_id,
industries: serde_json::from_value(
model.industries.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
expertise: serde_json::from_value(
model.expertise.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
languages: serde_json::from_value(
model.languages.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
current_company: model.current_company.unwrap_or_default(),
current_role: model.current_role.unwrap_or_default(),
years_of_experience: model.years_of_experience.unwrap_or(0),
topics_of_interest: serde_json::from_value(
model.topics_of_interest.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
preferred_mentee_level: serde_json::from_str(
&model.preferred_mentee_level.unwrap_or_default(),
)
.unwrap_or_default(),
preferred_mentoring_formats: serde_json::from_value(
model
.preferred_mentoring_formats
.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
availability_commitment: model.availability_commitment.unwrap_or_default(),
mentoring_rate: model.mentoring_rate.unwrap_or(0.0),
status: model.status.unwrap_or_default(),
is_deleted: model.is_deleted,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresMentorRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresMentorRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl MentorRepository for PostgresMentorRepository {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = MentorsEntity::find()
.filter(MentorColumn::IsDeleted.eq(false));
query = match params.sort_by.as_deref() {
Some("updated_at") => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(MentorColumn::UpdatedAt, Order::Asc),
_ => query.order_by(MentorColumn::UpdatedAt, Order::Desc),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(MentorColumn::CreatedAt, Order::Asc),
_ => query.order_by(MentorColumn::CreatedAt, Order::Desc),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator
.num_items()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mentors = paginator
.fetch_page((page - 1) as u64)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data = mentors.into_iter().map(model_to_entity).collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
let mut query = MentorsEntity::find_by_id(id);
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let model = query
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
Ok(model_to_entity(model))
}
async fn find_by_user_id(
&self,
user_id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
let mut query = MentorsEntity::find()
.filter(MentorColumn::UserId.eq(user_id));
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let model = query
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
Ok(model_to_entity(model))
}
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> {
let active_model = MentorActiveModel {
user_id: ActiveValue::Set(entity.user_id),
industries: ActiveValue::Set(Some(
serde_json::to_value(&entity.industries)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
expertise: ActiveValue::Set(Some(
serde_json::to_value(&entity.expertise)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
languages: ActiveValue::Set(Some(
serde_json::to_value(&entity.languages)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
current_company: ActiveValue::Set(Some(entity.current_company)),
current_role: ActiveValue::Set(Some(entity.current_role)),
years_of_experience: ActiveValue::Set(Some(entity.years_of_experience)),
topics_of_interest: ActiveValue::Set(Some(
serde_json::to_value(&entity.topics_of_interest)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
preferred_mentee_level: ActiveValue::Set(Some(
serde_json::to_string(&entity.preferred_mentee_level)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
preferred_mentoring_formats: ActiveValue::Set(Some(
serde_json::to_value(&entity.preferred_mentoring_formats)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
availability_commitment: ActiveValue::Set(Some(entity.availability_commitment)),
mentoring_rate: ActiveValue::Set(Some(entity.mentoring_rate)),
status: ActiveValue::Set(Some(entity.status)),
is_deleted: ActiveValue::Set(false),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
..Default::default()
};
let result = MentorsEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(result.last_insert_id)
}
async fn update(&self, entity: MentorEntity) -> Result<(), AppError> {
let mut active_model: MentorActiveModel = MentorsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?
.into();
if !entity.industries.is_empty() {
active_model.industries = ActiveValue::Set(Some(
serde_json::to_value(&entity.industries)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.expertise.is_empty() {
active_model.expertise = ActiveValue::Set(Some(
serde_json::to_value(&entity.expertise)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.languages.is_empty() {
active_model.languages = ActiveValue::Set(Some(
serde_json::to_value(&entity.languages)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.current_company.is_empty() {
active_model.current_company = ActiveValue::Set(Some(entity.current_company));
}
if !entity.current_role.is_empty() {
active_model.current_role = ActiveValue::Set(Some(entity.current_role));
}
active_model.years_of_experience = ActiveValue::Set(Some(entity.years_of_experience));
if !entity.topics_of_interest.is_empty() {
active_model.topics_of_interest = ActiveValue::Set(Some(
serde_json::to_value(&entity.topics_of_interest)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.preferred_mentee_level.is_empty() {
active_model.preferred_mentee_level = ActiveValue::Set(Some(
serde_json::to_string(&entity.preferred_mentee_level)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.preferred_mentoring_formats.is_empty() {
active_model.preferred_mentoring_formats = ActiveValue::Set(Some(
serde_json::to_value(&entity.preferred_mentoring_formats)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.availability_commitment.is_empty() {
active_model.availability_commitment =
ActiveValue::Set(Some(entity.availability_commitment));
}
active_model.mentoring_rate = ActiveValue::Set(Some(entity.mentoring_rate));
if !entity.status.is_empty() {
active_model.status = ActiveValue::Set(Some(entity.status));
}
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn soft_delete(&self, id: Uuid) -> Result<(), AppError> {
let model = MentorsEntity::find_by_id(id)
.filter(MentorColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
let mut active_model: MentorActiveModel = model.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::{mentors_protected_routes, mentors_public_routes};

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