Compare commits

...
Author SHA1 Message Date
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
MythEclipse 02be09506a refactor: update surrealdb client usage to support 'any' engine type 2025-06-29 23:23:18 +07:00
Maulana SodiqinandGitHub b88ecd1361 Merge pull request #37 from bakunya/develop
crud testi: acc banh
2025-06-29 00:34:44 +07:00
0x6d696b7566616e fa435994f2 crud testi 2025-06-29 00:01:00 +07:00
Maulana SodiqinandGitHub 16c613d699 Merge pull request #36 from IMPHNEN/feat/dotenv-dynamic-engine-seeder
feat: add dotenvy for environment variable management
2025-06-28 20:50:20 +07:00
MythEclipse f598dedb3b feat: add dotenvy for environment variable management and improve error handling in Env struct 2025-06-28 20:21:20 +07:00
Maulana SodiqinandGitHub cfa251337f Create deploy-to-ancikri.yml 2025-05-30 15:40:41 +07:00
Maulana Sodiqin cca9aa6318 chore: optimize dockerfile 2025-05-26 23:41:00 +07:00
Maulana Sodiqin f1e4e02697 fix: landing 2025-05-26 23:09:01 +07:00
Maulana SodiqinandGitHub a3c2715a85 Merge pull request #25 from bakunya/develop
feat: cms event
2025-05-26 15:28:05 +07:00
0x6d696b7566616e 89cc631fb0 feat: cms event 2025-05-26 14:11:17 +07:00
Maulana Sodiqin 36c12110c2 fixing dockerfile 2025-05-22 22:29:28 +07:00
Maulana Sodiqin 070060365a docs: update readme 2025-05-22 16:18:15 +07:00
Maulana Sodiqin 68e816f956 fix: schema default value 2025-05-22 15:25:24 +07:00
Maulana Sodiqin 48db5f07ef fix: register roles missmatch 2025-05-22 15:13:58 +07:00
Maulana Sodiqin a4b5d91906 chore: setup folder structure 2025-05-22 00:40:40 +07:00
Maulana Sodiqin 9878279660 feat: add gacha credits 2025-05-22 00:31:36 +07:00
Maulana Sodiqin 465cbee2c3 feat: finishing gacha feature 2025-05-21 19:37:30 +07:00
Maulana Sodiqin 33a2ae86c6 fix: unit tests 2025-05-21 11:35:35 +07:00
Maulana Sodiqin d906fc4747 chore: update query list 2025-05-20 16:57:45 +07:00
Maulana Sodiqin 055ef6ac8e feat: adjust readme and add new seeder script 2025-05-20 11:54:15 +07:00
Maulana Sodiqin e63658c082 feat: add gacha and init cms 2025-05-20 10:45:01 +07:00
Maulana SodiqinandGitHub f4b0ed0a4a Merge pull request #24 from IMPHNEN/chore/normalize-code
chore: normalize code
2025-05-20 01:50:58 +07:00
Maulana Sodiqin 1e5ed26f76 chore: cleaning code 2025-05-20 01:49:50 +07:00
Maulana Sodiqin d97095a38d chore: cleaning code 2025-05-20 00:26:24 +07:00
Maulana Sodiqin e48507901e chore: normalize code #2 2025-05-18 03:12:20 +07:00
Maulana Sodiqin a43ef8ec04 chore: normalize code 2025-05-17 02:08:59 +07:00
Maulana SodiqinandGitHub 874f25aa80 Merge pull request #23 from bakunya/develop
transform id(thing) to id(string) on get role, permission, users
2025-05-04 17:24:13 +07:00
Bakunya 12a730a394 transform id(thing) to id(string) on get role, permission, users 2025-05-04 17:07:20 +07:00
Bakunya bdcd41f54d transform id(thing) to id(string) on get role, permission, users 2025-05-04 16:37:11 +07:00
Maulana SodiqinandGitHub 96fb8eb7d3 Merge pull request #22 from IMPHNEN/feat/split-middleware
feat: split middleware
2025-04-07 20:43:59 +07:00
Maulana Sodiqin d2c333dbd6 feat: split middleware 2025-04-07 20:41:14 +07:00
Maulana Sodiqin 054f0f63ff chore: adjust README.md 2025-04-07 20:08:26 +07:00
Maulana Sodiqin eb05ea5994 feat: add gacha service 2025-04-07 00:50:57 +07:00
Maulana Sodiqin 4f870ec4c9 fix: unit test 2025-04-06 23:35:02 +07:00
Maulana Sodiqin 31f9b6cfea feat: microservice ready 2025-04-06 22:13:46 +07:00
Maulana SodiqinandGitHub 07a93ff0b7 Merge pull request #21 from bakunya/develop
fix otp and password validation
2025-04-06 20:09:17 +07:00
Bakunya d2b098ed92 fix otp and password validation 2025-04-06 19:15:01 +07:00
Maulana Sodiqin 09a7f4371a chore: remove uneeded cargo package 2025-04-06 13:28:15 +07:00
Maulana Sodiqin b4bf78f0d6 chore: re-map project structure to using multi workspace paradigm 2025-04-06 13:25:44 +07:00
Maulana Sodiqin de9c5a6dc1 feat: implement guard 2025-04-02 09:35:37 +07:00
Maulana Sodiqin 8a661df059 feat: add image url to gacha DTO 2025-04-01 20:05:07 +07:00
Maulana Sodiqin c7c16d4197 feat: add image url 2025-04-01 20:04:28 +07:00
Maulana Sodiqin 3aec0346a9 fix: wrong id 2025-04-01 20:01:40 +07:00
Maulana Sodiqin 8d2f720de1 feat: add gacha 2025-04-01 20:00:00 +07:00
Maulana Sodiqin 4b8da68df4 feat: gacha 2025-03-30 18:58:17 +07:00
Maulana Sodiqin 6460979bab feat: complete backend service 2025-03-30 16:08:58 +07:00
Maulana Sodiqin 3ae9dbd717 feat: success test 2025-03-30 14:13:45 +07:00
Maulana Sodiqin 0816566d03 feat: update logic 2025-03-30 02:46:16 +07:00
Maulana Sodiqin a97044edfc fix: seeder roles permissions 2025-03-28 17:18:07 +07:00
Maulana Sodiqin 9d92a9a136 feat: pagination 2025-03-26 13:06:34 +07:00
Maulana Sodiqin 8e12c79ea6 feat: permissions controller 2025-03-25 20:50:42 +07:00
Maulana Sodiqin 5777268441 feat: fixing role and auth test 2025-03-25 19:30:02 +07:00
Maulana Sodiqin 34eaa418d5 feat: roles router 2025-03-25 10:48:34 +07:00
Maulana Sodiqin 77f12610dd fix: test user 2025-03-24 13:12:54 +07:00
Maulana Sodiqin 5be737ad70 chore: add test coverage 2025-03-23 20:42:38 +07:00
Maulana Sodiqin 0ae1e26ba8 chore: complete unit test of user and authentication system 2025-03-23 12:32:00 +07:00
Maulana Sodiqin 79721b7af6 chore: implement unit test 2025-03-23 07:56:43 +07:00
Maulana Sodiqin 093e290221 feat: complete auth system 2025-03-23 01:19:17 +07:00
Maulana Sodiqin 1e6957720e feat: user management 2025-03-21 17:00:19 +07:00
Maulana Sodiqin a0f263bb4f feat: update query 2025-03-21 13:25:22 +07:00
Maulana Sodiqin 413a28c089 feat: setup data 2025-03-21 05:36:00 +07:00
Maulana Sodiqin a90e8662e1 feat: initial commit 2025-03-20 22:41:20 +07:00
Maulana Sodiqin 955d384f96 fix: docker 2025-03-20 17:36:43 +07:00
Maulana Sodiqin ea5ece9ab2 feat: initial commit 2025-03-20 17:33:57 +07:00
Maulana Sodiqin 8ff532bb67 fix: cors 2025-03-20 17:07:38 +07:00
Maulana Sodiqin eb6cfd51ae feat: auth functionality 2025-03-19 23:17:50 +07:00
Maulana Sodiqin 11d6249007 feat: docs 2025-03-19 20:55:29 +07:00
Maulana Sodiqin 497c250e6d fix: change deployed branch 2025-03-19 19:34:57 +07:00
Maulana Sodiqin 2872c1827a feat: deploy 2025-03-19 19:33:56 +07:00
Maulana Sodiqin f9c49d44b5 feat: gacha roll 2025-03-19 05:34:39 +07:00
Maulana Sodiqin 12043f027c feat: gacha roll, user auth 2025-03-18 14:13:53 +07:00
Maulana Sodiqin 0c28d34d57 feat: apply env 2025-03-17 09:27:19 +07:00
Maulana Sodiqin 271b00c2c4 feat: create gacha item 2025-03-15 02:23:27 +07:00
Maulana Sodiqin b096f4129a feat: add middleware 2025-03-15 01:53:20 +07:00
Maulana Sodiqin 7ab036f5f7 feat: user relation 2025-03-14 17:50:37 +07:00
Maulana Sodiqin 9ffe808797 feat: basic auth 2025-03-14 09:53:20 +07:00
Maulana Sodiqin acfb8259bc feat: auth 2025-03-13 23:40:16 +07:00
Maulana Sodiqin 298144bd36 chore: fix nix 2025-02-26 15:55:48 +07:00
Maulana Sodiqin ff9d8cba69 feat: add cargo 2025-02-26 15:45:39 +07:00
Maulana Sodiqin 8b2769706e feat: add cargo 2025-02-26 15:45:05 +07:00
Maulana Sodiqin b8606ef0bb chore: remove seaorm 2025-02-25 02:02:01 +07:00
Maulana Sodiqin 44de53a49d feat: implement surrealdb and redis 2025-02-25 02:00:19 +07:00
Maulana Sodiqin 9abab01b9c chore: re-format file 2025-02-16 01:09:55 +07:00
Maulana Sodiqin 53414687ff chore: initial commit 2025-02-16 01:02:51 +07:00
Maulana SodiqinandGitHub d2ec21c7b1 chore: add readme 2025-02-08 01:16:14 +07:00
Maulana SodiqinandGitHub 4deee3a9cb chore: configure action 2025-02-08 01:10:48 +07:00
Maulana SodiqinandGitHub b50ce2b060 Merge pull request #1 from IMPHNEN/feat/auth-login
feat: auth login
2025-02-08 01:09:57 +07:00
209 changed files with 22146 additions and 1383 deletions
+49 -4
View File
@@ -1,4 +1,49 @@
PORT=
DATABASE_URL=
ACCESS_TOKEN_SECRET=
REFRESH_TOKEN_SECRET=
# RUST_ENV : Mode environment aplikasi (misal: development, production)
# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error)
# PORT : Port yang digunakan backend service
# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket)
# SURREALDB_USERNAME : Username untuk SurrealDB
# SURREALDB_PASSWORD : Password untuk SurrealDB
# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan
# SURREALDB_DBNAME : Nama database SurrealDB
# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia)
# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia)
# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP)
# SMTP_PASSWORD : Password email SMTP
# SMTP_NAME : Nama pengirim email (misal: nama aplikasi)
# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com)
# REDISDB_URL : URL/host Redis database
# FE_URL : URL frontend aplikasi (misal: http://localhost)
# MINIO_ENDPOINT : Endpoint MinIO object storage
# MINIO_BUCKET_NAME : Nama bucket default MinIO
# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER
# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD
# MINIO_ROOT_USER : Username root MinIO (default: minioadmin)
# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin)
# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google)
# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google
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"
+65
View File
@@ -0,0 +1,65 @@
name: Deploy to Ancikri
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_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_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_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_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_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_PORT }}
script: |
set -e
echo "Restarting the service"
sudo systemctl daemon-reload
sudo systemctl restart imphnen-backend-service
echo "Deployment completed successfully"
+19
View File
@@ -0,0 +1,19 @@
name: Rust
on:
push:
branches: ["develop"]
pull_request:
branches: ["develop"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
+1
View File
@@ -4,6 +4,7 @@
# Nix
/.direnv
/Cargo.nix
# Environment
.envrc
Generated
+3822 -1009
View File
File diff suppressed because it is too large Load Diff
+78 -15
View File
@@ -1,21 +1,84 @@
[package]
name = "imphnen-cms-be"
version = "0.1.0"
edition = "2021"
[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",
]
[dependencies]
axum = { version = "0.8.1", features = ["multipart"] }
log = "0.4.25"
sea-orm = { version = "1.1.4", features = ["sqlx-postgres", "macros", "with-json", "with-uuid", "runtime-tokio", "runtime-tokio-native-tls"] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138"
tokio = { version = "1.43.0", features = ["full"] }
[workspace.dependencies]
oauth2 = "5.0.0"
reqwest = { version = "0.12.23", features = ["json"] }
serde_json = "1.0.142"
axum = { version = "0.8.4", features = ["multipart"] }
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.39"
utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
redis = "0.28.2"
chrono = "0.4.41"
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"] }
surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
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.6", features = ["cors", "trace"] }
http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.5.0"
regex = "1.11.1"
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"
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"
tests = { path = "./tests" }
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" }
[profile.release]
lto = "fat"
+37 -12
View File
@@ -1,22 +1,47 @@
FROM rust:1.83-slim-bullseye AS builder
FROM rust:1.86-alpine AS builder
RUN apt-get update && apt-get install -y \
build-essential \
libssl-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache \
curl \
musl-dev \
openssl-dev \
openssl-libs-static \
pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY ./src ./src
RUN cargo build --release && strip /app/target/release/imphnen-cms-api
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"' _ {} \;
FROM gcr.io/distroless/cc AS runner
RUN echo '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
WORKDIR /app
COPY --from=builder /app/target/release/imphnen-cms-api .
RUN echo -e '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
CMD ["/app/imphnen-cms-api"]
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
View File
@@ -0,0 +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)._
+35
View File
@@ -0,0 +1,35 @@
@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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,25 @@
#!/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"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Script deploy ke VPS
set -e
REMOTE_USER="asephs"
REMOTE_HOST="70.153.9.42"
REMOTE_PATH="/home/asephs/imphnen-backend-service"
# Build project
taskset -c 0,1 cargo build --release -j 2
# Rsync hasil build dan file yang diperlukan
rsync -avz --delete \
target/release/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/target/release/
# Sync file konfigurasi dan source code (jika perlu)
rsync -avz --delete \
imphnen-backend/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/imphnen-backend/
rsync -avz --delete \
docker-compose.yml Dockerfile \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/
# Tambahkan file lain jika diperlukan
ssh $REMOTE_USER@$REMOTE_HOST << 'EOF'
cd /home/asephs/imphnen-backend-service
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin"
export PATH=$PATH:/root/.local/share/pnpm
export PATH=$PATH:/home/asephs/.nvm/versions/node/v22.17.1/bin
export PATH=$PATH:/home/asephs/.bun/bin/bun
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
pm2 restart 4 --update-env
EOF
echo "Deploy selesai ke $REMOTE_HOST:$REMOTE_PATH"
+24
View File
@@ -6,3 +6,27 @@ services:
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"
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:
minio_data:
+9
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

Generated
+27
View File
@@ -0,0 +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
}
+13 -5
View File
@@ -1,24 +1,32 @@
{
description = "IMPHNEN CMS API Nix Flake";
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;
pkgsFor = nixpkgs.legacyPackages;
in {
packages = forAllSystems (system: {
default = pkgsFor.${system}.callPackage ./default.nix {};
default = (pkgsFor system).callPackage ./default.nix {};
});
devShells = forAllSystems (system: {
default = pkgsFor.${system}.callPackage ./shell.nix {};
default = (pkgsFor system).callPackage ./shell.nix {};
});
dockerImages = forAllSystems (system: {
tryOutApi = pkgsFor.${system}.callPackage ./docker.nix {};
tryOutApi = (pkgsFor system).callPackage ./docker.nix {};
});
};
}
+75
View File
@@ -0,0 +1,75 @@
[package]
name = "imphnen-backend"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "api"
path = "src/main.rs"
[[bin]]
name = "clear_db_test"
path = "src/bin/clear_db_test.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_users"
path = "src/bin/seed_users.rs"
[dependencies]
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-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
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
tokio-tungstenite.workspace = true
url.workspace = true
futures-util.workspace = true
+15
View File
@@ -0,0 +1,15 @@
use axum::Router;
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 {
let app = gateway_service(surrealdb_ws, surrealdb_mem).await;
let mut router = Router::new();
router = router.nest("/api/v1/auth", imphnen_iam::v1::auth::auth_router());
app.merge(router)
})
.await;
}
+169
View File
@@ -0,0 +1,169 @@
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;
use futures_util::{StreamExt, SinkExt};
use serde_json::json;
// Menentukan kredensial dan detail koneksi secara langsung sebagai string statis
static SURREALDB_URL_WS: &str = "ws://localhost:8000/rpc";
static SURREALDB_USERNAME: &str = "root";
static SURREALDB_PASSWORD: &str = "root";
static SURREALDB_NAMESPACE: &str = "test";
static SURREALDB_DBNAME: &str = "test";
// Daftar tabel sebagai variabel static yang tidak dapat diubah
static TABLES_TO_CLEAR: &[&str] = &[
"app_events", "users", "roles", "permissions", "gacha_rolls",
"mentor_users", "gacha_claims", "gacha_credits", "gacha_items",
"mentor_profiles", "roles_permissions", "testimonials",
];
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Tidak perlu memuat env lagi, karena kita menggunakan nilai hardcoded
// imphnen_libs::enviroment::load_env(); // Baris ini tidak lagi dibutuhkan
// let env = Env::new(); // Baris ini tidak lagi dibutuhkan
println!("DEBUG: URL WS: {}", SURREALDB_URL_WS);
println!("DEBUG: Username: {}", SURREALDB_USERNAME);
println!("DEBUG: Namespace: {}", SURREALDB_NAMESPACE);
println!("DEBUG: Database: {}", SURREALDB_DBNAME);
let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis
let (ws_stream, _) = connect_async(url.as_str()).await?;
let (mut write, mut read) = ws_stream.split();
// Authenticate (signin)
let signin_query = json!({
"method": "signin",
"params": [{
"user": SURREALDB_USERNAME, // Menggunakan SURREALDB_USERNAME statis
"pass": SURREALDB_PASSWORD, // Menggunakan SURREALDB_PASSWORD statis
}],
"id": 1
}).to_string();
println!("DEBUG: Sending signin query: {}", signin_query);
write.send(Message::Text(signin_query.into())).await?;
let signin_response = read.next().await.ok_or("Failed to read signin response")?;
let signin_response_msg = signin_response?;
let signin_response_str = signin_response_msg.to_text()?;
println!("DEBUG: Signin response: {}", signin_response_str);
if signin_response_str.contains("\"error\":") {
return Err(format!("Signin failed: {}", signin_response_str).into());
}
// Use namespace and database
let use_query = json!({
"method": "use",
"params": [SURREALDB_NAMESPACE, SURREALDB_DBNAME], // Menggunakan NS & DB statis
"id": 2
}).to_string();
println!("DEBUG: Sending use query: {}", use_query);
write.send(Message::Text(use_query.into())).await?;
let use_response = read.next().await.ok_or("Failed to read use response")?;
let use_response_msg = use_response?;
let use_response_str = use_response_msg.to_text()?;
println!("DEBUG: Use response: {}", use_response_str);
if use_response_str.contains("\"error\":") {
return Err(format!("USE command failed: {}", use_response_str).into());
}
println!("INFO: Attempting to clear database tables via WebSocket...");
let mut all_clear = true;
for (i, table) in TABLES_TO_CLEAR.iter().enumerate() {
let remove_query = format!("REMOVE TABLE {};", table);
let query_json = json!({
"method": "query",
"params": [remove_query],
"id": i + 3
}).to_string();
println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json);
write.send(Message::Text(query_json.into())).await?;
let response_result = read.next().await.ok_or("Stream ended unexpectedly")?;
match response_result {
Ok(msg) => {
let response_str = msg.to_text()?;
if response_str.contains("\"error\":") {
println!("WARN: Failed to REMOVE TABLE {}: {}. Attempting DELETE type::{}.", table, response_str, table);
let delete_all_query = format!("DELETE FROM {};", table);
let delete_all_json = json!({
"method": "query",
"params": [delete_all_query],
"id": i + 300
}).to_string();
println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json);
write.send(Message::Text(delete_all_json.into())).await?;
let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?;
match delete_response_result {
Ok(delete_msg) => {
let delete_response_str = delete_msg.to_text()?;
if delete_response_str.contains("\"error\":") {
println!("ERROR: Failed to DELETE type::{} : {}", table, delete_response_str);
all_clear = false;
} else {
println!("INFO: Successfully DELETED type:: table: {}", table);
}
},
Err(delete_e) => {
println!("ERROR: Error receiving response for DELETE type:: table {}: {}", table, delete_e);
all_clear = false;
}
}
} else {
println!("INFO: Successfully REMOVED TABLE: {}", table);
}
},
Err(e) => {
println!("ERROR: Error receiving response for REMOVE TABLE {}: {}", table, e);
all_clear = false;
}
}
// Check if table is empty after deletion attempt
let select_query = format!("SELECT * FROM {} LIMIT 1;", table);
let select_json = json!({
"method": "query",
"params": [select_query],
"id": i + 1000
}).to_string();
write.send(Message::Text(select_json.into())).await?;
let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?;
match select_response_result {
Ok(select_msg) => {
let select_response_str = select_msg.to_text()?;
if select_response_str.contains("does not exist") {
println!("CHECK: Table '{}' does not exist after clear attempt (success).", table);
} else if select_response_str.contains("\"result\":[]") || select_response_str.contains("\"result\":[[]]") {
println!("CHECK: Table '{}' is empty after clear attempt.", table);
} else {
println!("WARNING: Table '{}' is NOT empty after clear attempt! Response: {}", table, select_response_str);
all_clear = false;
}
},
Err(e) => {
println!("ERROR: Error receiving response for SELECT check on table {}: {}", table, e);
all_clear = false;
}
}
}
println!("INFO: Database clearing complete.");
if !all_clear {
eprintln!("ERROR: One or more tables could not be cleared. Check logs for details.");
return Err("Database clearing failed for one or more tables.".into());
}
Ok(())
}
+103
View File
@@ -0,0 +1,103 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_utils::{get_iso_date};
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
let events = vec![
(
"Tech Conference 2025",
"Annual technology conference featuring the latest innovations in software development, AI, and cloud computing.",
"https://techconf2025.example.com",
150.0,
Some("Jakarta Convention Center".to_string()),
false,
"2025-06-15T09:00:00Z",
"2025-06-17T18:00:00Z",
),
(
"Online Web Development Workshop",
"Comprehensive workshop covering modern web development frameworks including React, Vue, and Angular.",
"https://webdev-workshop.example.com",
75.0,
None,
true,
"2025-07-10T14:00:00Z",
"2025-07-10T17:00:00Z",
),
(
"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",
25.0,
Some("Innovation Hub Surabaya".to_string()),
false,
"2025-08-05T10:00:00Z",
"2025-08-05T16:00:00Z",
),
(
"Digital Marketing Masterclass",
"Learn advanced digital marketing strategies, social media optimization, and data-driven marketing techniques.",
"https://digital-marketing.example.com",
100.0,
None,
true,
"2025-09-20T13:00:00Z",
"2025-09-22T15:00:00Z",
),
];
for (
name,
description,
detail_link,
price,
location,
is_online,
start_date,
end_date,
) in events
{
let uuid = Uuid::new_v4().to_string(); // Generate new UUID
let event = EventsSchema {
id: Thing::from(("app_events", uuid.as_str())), // Use generated UUID
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(),
};
db.create::<Option<EventsSchema>>(("app_events", uuid.as_str())) // Use generated UUID
.content(event)
.await?;
println!(
"✅ Inserted event: {} ({})",
name,
if is_online { "Online" } else { "In-person" }
);
}
println!("✅ All Events seeded");
Ok(())
}
@@ -0,0 +1,52 @@
use imphnen_utils::{get_iso_date};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
db.query("DELETE type::thing('app_gacha_items', $id)")
.bind(("id", "gacha_item_test_id"))
.await?;
db.query("DELETE type::thing('app_gacha_rolls', $id)")
.bind(("id", "gacha_roll_test_id"))
.await?;
let gacha_item_id = "gacha_item_test_id";
db.query("CREATE type::thing('app_gacha_items', $id) SET name = $name, image_url = $image_url, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_item_id))
.bind(("name", "Test Gacha Item"))
.bind(("image_url", "https://example.com/gacha_item.png"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Item seeded successfully!");
let gacha_roll_id = "gacha_roll_test_id";
db.query("CREATE type::thing('app_gacha_rolls', $id) SET item = $item, quantity = $quantity, weight = $weight, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_roll_id))
.bind(("item", Thing::from(("app_gacha_items", gacha_item_id))))
.bind(("quantity", 10))
.bind(("weight", 1.0))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Roll seeded successfully!");
println!("✅ Gacha items and rolls seeded.");
Ok(())
}
@@ -0,0 +1,94 @@
use imphnen_utils::{get_iso_date, hash_password};
use serde_json::json;
use std::error::Error;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
db.query("DELETE type::thing('app_mentors', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
use surrealdb::sql::Thing;
db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, legal_name = $legal_name, domicile = $domicile, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, last_education = $last_education, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, portfolio_url = $portfolio_url, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("fullname", "Mentor User"))
.bind(("email", "mentor@example.com"))
.bind(("password", hash_password("password").unwrap()))
.bind(("avatar", Option::<String>::None))
.bind(("phone_number", "081234567890"))
.bind(("is_active", true))
.bind(("is_deleted", false))
.bind(("mentor_id", Option::<Thing>::None))
.bind(("gender", "male"))
.bind(("birthdate", "1990-05-15"))
.bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"))))
.bind(("legal_name", "Mentor User"))
.bind(("domicile", "Jakarta, Indonesia"))
// .bind(("identity_document_url", "https://example.com/ktp.jpg"))
.bind(("phone_for_verification", "081234567890"))
.bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant."))
.bind(("last_education", "S1 Teknik Informatika"))
.bind(("linkedin_url", "https://linkedin.com/in/mentor"))
.bind(("github_url", "https://github.com/mentor"))
.bind(("cv_url", Option::<String>::None))
.bind(("portfolio_url", Option::<String>::None))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901"))))
.bind(("industries", vec!["Software", "Education"]))
.bind(("expertise", vec!["Rust", "Microservices"]))
.bind(("languages", vec!["Indonesian", "English"]))
.bind(("current_company", "PT Contoh"))
.bind(("current_role", "Senior Backend Engineer"))
.bind(("years_of_experience", 5))
.bind(("topics_of_interest", vec!["Rust Programming", "Backend Development"]))
.bind(("preferred_mentee_level", vec!["beginner", "intermediate"]))
.bind(("preferred_mentoring_formats", vec!["online", "offline"]))
.bind(("availability_commitment", "2 jam per minggu untuk mentoring online dan offline"))
.bind(("mentoring_rate", json!({
"amount": 100000,
"currency": "IDR",
"per_duration": "hour"
})))
.bind(("status", "verified"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Mentor created successfully!");
println!("Updating user with mentor_id...");
db.query("UPDATE type::thing('app_users', $id) SET mentor_id = $mentor_id")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind((
"mentor_id",
Thing::from(("app_mentors", "e6f78d23-83bf-5c2b-bcd4-001345678901")),
))
.await?;
println!("User updated with mentor_id successfully!");
println!("✅ Inserted mentor user: mentor@example.com");
println!("✅ Mentor user seeded");
Ok(())
}
@@ -0,0 +1,76 @@
use imphnen_iam::PermissionsEnum;
use imphnen_utils::{get_iso_date};
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>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.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,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
] {
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}");
}
println!("✅ All Permissions seeded");
Ok(())
}
+79
View File
@@ -0,0 +1,79 @@
use imphnen_utils::{get_iso_date};
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>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
let roles = vec![
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
"Staf",
Some("2025-02-24T16:52:27.630453+00"),
Some("2025-02-24T16:52:27.630461+00"),
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
"User",
None,
Some("2025-02-28T14:53:58.576688+00"),
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
"Staff Aktivasi User",
Some("2025-02-20T02:47:09.660640+00"),
Some("2025-02-20T02:48:30.083283+00"),
),
(
"6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0",
"Admin Pembayaran",
Some("2025-01-29T05:39:28.562667+00"),
Some("2025-03-12T22:56:29.597416+00"),
),
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
"Admin",
None,
Some("2025-02-22T15:38:39.868306+00"),
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
"Mentor",
None,
Some("2025-07-06T10:00:00.000000+00"),
),
];
for (id, name, _created_at, _updated_at) in roles {
db.query("DELETE type::thing('app_roles', $id)")
.bind(("id", id))
.await?;
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}");
}
println!("✅ All Roles seeded");
Ok(())
}
@@ -0,0 +1,143 @@
use imphnen_iam::{get_iso_date, make_thing, PermissionsEnum};
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
db.query("DEFINE INDEX user_email_index ON TABLE users COLUMNS email UNIQUE;")
.await?;
db.query("DEFINE INDEX role_name_idx ON TABLE roles COLUMNS name UNIQUE;")
.await?;
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'.");
let roles_permissions = vec![
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
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,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
],
),
(
"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![
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadListPermissions,
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 permission_refs: Vec<_> = permissions
.iter()
.map(|perm| make_thing("app_permissions", perm.id()))
.collect();
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false")
.bind(("role_id", role_id))
.bind(("permissions", permission_refs))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ Permissions updated for role: {role_id}");
}
println!("✅ All roles permissions updated!");
Ok(())
}
+89
View File
@@ -0,0 +1,89 @@
use imphnen_iam::UsersSchema;
use imphnen_utils::{get_iso_date, hash_password};
use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = &imphnen_libs::enviroment::ENV;
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.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
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",
),
];
for (id, email, fullname, role_id) in users {
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", id))
.await?;
let user = UsersSchema {
id: Thing::from(("app_users", id)),
fullname: fullname.into(),
legal_name: Some(format!("{} Legal Name", fullname)),
email: email.into(),
password: hash_password("password").unwrap(),
avatar: Some("https://example.com/avatar.jpg".into()),
phone_number: "081234567890".into(),
phone_for_verification: Some("081234567890".into()),
is_active: true,
is_deleted: false,
mentor_id: None,
gender: Some("male".into()),
birthdate: Some("1990-05-15".into()),
domicile: Some("Jakarta, Indonesia".into()),
// identity_document_url: None, // Sudah tidak dipakai, bisa dihapus dari schema jika tidak diperlukan
bio: Some(format!("{} adalah user dengan data pribadi lengkap untuk testing.", fullname)),
last_education: Some("S1 Teknik Informatika".into()),
linkedin_url: Some("https://linkedin.com/in/user".into()),
github_url: Some("https://github.com/user".into()),
cv_url: Some("https://example.com/cv.pdf".into()),
portfolio_url: Some("https://example.com/portfolio".into()),
website_url: Some("https://example.com/website".into()),
twitter_url: Some("https://twitter.com/user".into()),
location: Some("Jakarta, Indonesia".into()),
skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]),
experience: None,
education: None,
career_status: Some("Senior Developer".into()),
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})");
}
println!("✅ All Users seeded");
Ok(())
}
+27
View File
@@ -0,0 +1,27 @@
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")?;
run_seed("seed_gacha_rolls")?;
run_seed("seed_mentor_user")?;
println!("\n✅ All seeding completed successfully.");
Ok(())
}
+12
View File
@@ -0,0 +1,12 @@
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await
})
.await;
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "imphnen-cms"
version = "0.1.0"
edition = "2024"
[dependencies]
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
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
[package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://"
+2
View File
@@ -0,0 +1,2 @@
pub mod v1;
pub use v1::*;
@@ -0,0 +1,119 @@
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
}
@@ -0,0 +1,118 @@
use lazy_static::lazy_static;
lazy_static! {
pub static ref VALID_URL_REGEX: regex::Regex =
regex::Regex::new(r"^https?://").unwrap();
}
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[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(url(message = "Detail link must be a valid URL"))]
pub detail_link: String,
#[validate(range(min = 0.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>,
#[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64,
pub is_online: bool,
pub description: String,
#[validate(url(message = "Detail link must be a valid URL"))]
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,
}
}
}
@@ -0,0 +1,167 @@
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, make_thing};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct EventsRepository<'a> {
state: &'a AppState,
}
impl<'a> EventsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_event_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> {
let now = Instant::now();
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();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto {
data: res,
meta: None,
};
Ok(data)
}
#[instrument(skip(self, id), err)]
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
// Attempt to parse the ID. If it's a full Thing (e.g., "events:some_id"), extract the ID part.
// Otherwise, assume it's already the raw ID.
let parsed_id = if id.contains(":") {
let thing = make_thing(ResourceEnum::Events.to_string().as_str(), &id);
get_id(&thing)?.1.to_string()
} else {
id.clone()
};
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&parsed_id)
.with_select_fields(vec!["*"]);
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_by_id' took: {elapsed:.2?}");
}
match result {
Some(event) => {
if event.is_deleted {
bail!("Event not found");
}
Ok(event)
}
None => bail!("Event not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let query_str = format!("CREATE {} CONTENT ...", ResourceEnum::Events.to_string());
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create event".into()),
None => bail!("Failed to create event"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let existing = self.query_event_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Event already deleted");
}
let merged = EventsSchema {
created_at: existing.created_at,
updated_at: get_iso_date(),
..data
};
let record_key = get_id(&merged.id)?;
let query_str = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update event".into()),
None => bail!("Failed to update event"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_event(&self, id: String) -> Result<String> {
let now = Instant::now();
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 query_str = format!("UPDATE {:?} MERGE {{ is_deleted: true }}", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete event".into()),
None => bail!("Failed to delete event"),
}
}
}
@@ -0,0 +1,102 @@
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::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()
}
}
}
@@ -0,0 +1,101 @@
use super::{
events_dto::{
EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto,
EventsUpdateRequestDto,
},
events_repository::EventsRepository,
events_schema::EventsSchema,
};
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 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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,5 @@
pub mod events;
pub mod testimonials;
pub use events::*;
pub use testimonials::*;
@@ -0,0 +1,44 @@
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),
)
}
@@ -0,0 +1,124 @@
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 {
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
}
@@ -0,0 +1,78 @@
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,
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,
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,
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,
role: self.role,
content: self.content,
created_at: self.created_at,
is_deleted: self.is_deleted,
}
}
}
@@ -0,0 +1,181 @@
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};
use serde_json;
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct TestimonialsRepository<'a> {
state: &'a AppState,
}
impl<'a> TestimonialsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_testimonial_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<TestimonialsQueryDto>>> {
let now = Instant::now();
let query = ListQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_select_fields(vec!["*", "user.* as user"])
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto {
data: res,
meta: None,
};
Ok(data)
}
#[instrument(skip(self, id), err)]
pub async fn query_testimonial_by_id(
&self,
id: String,
) -> Result<TestimonialsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_id(&id)
.with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_by_id' took: {elapsed:.2?}");
}
match result {
Some(testimonial) => {
if testimonial.is_deleted {
bail!("Testimonial not found");
}
Ok(testimonial)
}
None => bail!("Testimonial not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<TestimonialsSchema> { // Change return type from String to TestimonialsSchema
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::Testimonials.to_string(),
payload = ?data,
"Executing SurrealDB create"
);
let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_testimonial' took: {elapsed:.2?}");
}
match record {
Some(created_testimonial) => Ok(created_testimonial), // Return the created testimonial
None => bail!("Failed to create testimonial"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
let now = Instant::now();
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,
..data
};
let record_key = get_id(&merged.id)?;
info!(
record_key = ?record_key,
payload = ?merged,
"Executing SurrealDB update"
);
let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_testimonial' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update testimonial".into()),
None => bail!("Failed to update testimonial"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_testimonial(&self, id: String) -> Result<String> {
let now = Instant::now();
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)?;
info!(
record_key = ?record_key,
"Executing SurrealDB soft delete"
);
let record: Option<TestimonialsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_testimonial' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete testimonial".into()),
None => bail!("Failed to delete testimonial"),
}
}
}
@@ -0,0 +1,84 @@
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,
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(),
),
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()
}
}
}
@@ -0,0 +1,120 @@
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, success_created_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.to_raw(),
user_id: testimonial.user.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(created_testimonial) => {
success_created_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto {
id: created_testimonial.id.to_raw(),
user_id: created_testimonial.user.id.to_raw(),
user_fullname: authenticated_user.fullname.clone(),
role: created_testimonial.role,
content: created_testimonial.content,
created_at: created_testimonial.created_at,
updated_at: created_testimonial.updated_at,
},
})
}
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
@@ -0,0 +1,3 @@
pub mod landing;
pub use landing::*;
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "imphnen-dimentorin"
version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
imphnen-iam.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
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
tracing.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
+2
View File
@@ -0,0 +1,2 @@
pub mod v1;
pub use v1::*;
@@ -0,0 +1,356 @@
use super::{
MentorDetailResponseDto, MentorListResponseDto, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsService,
};
use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto;
use ::axum::{
extract::{Extension, Json, Path, Query},
http::HeaderMap,
response::{IntoResponse, Response},
};
use imphnen_entities::*;
use imphnen_iam::{PermissionsEnum, permissions_guard};
use imphnen_utils::extract_email;
use serde_json::json;
#[utoipa::path(
post,
path = "/v1/mentors/register",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 409, description = "Conflict - user already has mentor profile"),
(status = 500, description = "Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUserRegisterRequestDto>,
) -> Response {
MentorsService::register_mentor(&app_state, dto).await
}
#[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 = "Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> Response {
match permissions_guard(
headers,
Extension(app_state),
vec![PermissionsEnum::ReadListMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
headers,
Extension(app_state),
vec![PermissionsEnum::ReadDetailMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
headers,
Extension(app_state),
vec![PermissionsEnum::UpdateMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Mentor deleted successfully"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
headers,
Extension(app_state),
vec![PermissionsEnum::DeleteMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorVerifyRequestDto>,
) -> Response {
match permissions_guard(
headers,
Extension(app_state),
vec![PermissionsEnum::VerifyMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "Mentor profile not found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_me(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
match permissions_guard(
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorProfile],
)
.await
{
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_me(&app_state, &email).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/me",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 401, description = "Unauthorized - invalid token"),
(status = 404, description = "Mentor profile not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor_me(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::UpdateOwnMentorProfile],
)
.await
{
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::update_mentor_me(&app_state, &email, dto).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> Response {
imphnen_utils::common_response(
axum::http::StatusCode::BAD_REQUEST,
"Mentor ID is required for update",
)
}
#[utoipa::path(
get,
path = "/v1/mentors/status",
responses(
(status = 200, description = "Mentor application status", body = String),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "No mentor application found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_status(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
match permissions_guard(
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorStatus],
)
.await
{
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_status(&app_state, &email).await
}
Err(response) => response,
}
}
@@ -0,0 +1,438 @@
use crate::v1::mentors::MentorSchema;
use imphnen_utils::extract_id;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub 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)]
pub struct MentorDetailWithUserDto {
pub id: Thing,
pub user_id: Thing,
// Personal data is now in UsersSchema, access via user_id
// Removed: fullname, email, legal_name, identity_document_url,
// phone_for_verification, bio, linkedin_url, github_url, cv_url
pub industries: Vec<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: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
// Personal data fields from UsersSchema
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>,
// Professional data from MentorSchema
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: MentoringRate,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
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,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUpdateRequestDto {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
#[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>,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 language required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[validate(length(min = 1, message = "Current company required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[validate(length(min = 1, message = "Current role required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[validate(length(min = 1, message = "At least 1 topic required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUserRegisterRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
#[validate(length(
min = 8,
message = "Password must have at least 8 characters"
))]
#[validate(custom(
function = "imphnen_iam::auth_dto::validate_password_complexity",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String,
#[validate(length(min = 1, message = "Phone number is required"))]
pub phone_number: String,
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterFromTokenRequestDto {
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct IdentityAndVerification {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
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>,
#[validate(url(message = "Invalid identity document URL"))]
pub identity_document_url: String,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
pub phone_for_verification: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct ProfessionalProfile {
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV 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>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
pub industries: Vec<String>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
pub expertise: Vec<String>,
#[validate(length(min = 1, message = "At least 1 language required"))]
pub languages: Vec<String>,
#[validate(length(min = 1, message = "Current company required"))]
pub current_company: String,
#[validate(length(min = 1, message = "Current role required"))]
pub current_role: String,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
pub years_of_experience: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentoringLogistics {
#[validate(length(min = 1, message = "At least 1 topic required"))]
pub topics_of_interest: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
pub preferred_mentee_level: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
pub preferred_mentoring_formats: Vec<String>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
pub availability_commitment: String,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub mentoring_rate_amount: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate, Default)]
pub struct MentoringRate {
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub amount: u64,
#[validate(length(min = 1, message = "Currency is required"))]
pub currency: String,
#[validate(length(min = 1, message = "Per duration is required"))]
pub per_duration: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorInsertDto {
pub id: Thing,
pub user_id: Option<Thing>,
// Personal data removed - now stored in UsersSchema
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: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorSchema> for MentorInsertDto {
fn from(schema: MentorSchema) -> Self {
MentorInsertDto {
id: schema.id,
user_id: schema.user_id,
// Personal data removed from MentorSchema
industries: schema.industries,
expertise: schema.expertise,
languages: schema.languages,
current_company: schema.current_company,
current_role: schema.current_role,
years_of_experience: schema.years_of_experience,
topics_of_interest: schema.topics_of_interest,
preferred_mentee_level: schema.preferred_mentee_level,
preferred_mentoring_formats: schema.preferred_mentoring_formats,
availability_commitment: schema.availability_commitment,
mentoring_rate: schema.mentoring_rate,
status: schema.status,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorVerifyRequestDto {
#[validate(length(min = 1, message = "Status is required"))]
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailQueryDto {
pub id: Thing,
pub user_id: Thing,
// Personal data has been moved to UsersSchema
// Use user_id to get: fullname, email, legal_name, gender, domicile,
// identity_document_url, phone_for_verification, bio, last_education,
// linkedin_url, github_url, cv_url, portfolio_url
pub industries: Vec<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: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
fullname: None, // now in user table, must be populated from service layer
email: None, // now in user table, must be populated from service layer
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id),
// Personal data fields are populated in service layer from UsersSchema
fullname: None, // populated from user table in service layer
email: None, // populated from user table in service layer
legal_name: None, // populated from user table in service layer
gender: None, // populated from user table in service layer
domicile: None, // populated from user table in service layer
phone_for_verification: None, // populated from user table in service layer
bio: None, // populated from user table in service layer
last_education: None, // populated from user table in service layer
linkedin_url: None, // populated from user table in service layer
github_url: None, // populated from user table in service layer
cv_url: None, // populated from user table in service layer
portfolio_url: None, // populated from user table in service layer
// Professional data from mentor
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorSchema> for MentorRegisterResponseDto {
fn from(schema: MentorSchema) -> Self {
Self {
id: schema.id.to_string(),
user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(),
email: None, // schema.email - now in user table
status: schema.status,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
fn from(dto: MentorDetailWithUserDto) -> Self {
MentorDetailQueryDto {
id: dto.id,
user_id: dto.user_id,
// Personal data removed from MentorDetailWithUserDto - now in UsersSchema
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
@@ -0,0 +1,282 @@
use anyhow::{Result, bail};
use imphnen_iam::{get_id, make_thing};
use surrealdb::sql::Thing;
use crate::v1::mentors::{MentorDetailWithUserDto, MentorInsertDto, MentorSchema};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct MentorsRepository<'a> {
pub state: &'a AppState,
}
impl<'a> MentorsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_mentor_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<MentorDetailWithUserDto>>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("user_id.legal_name") // Search in user data instead
.select_fields(vec![
"id",
"user_id",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
let result = builder.build().await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_list' took: {elapsed:.2?}");
}
let data = result.data.into_iter().collect();
Ok(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
#[instrument(skip(self, email, include_deleted), err)]
pub async fn query_mentor_by_email(
&self,
email: String,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("user_id.email", Some(email.clone())) // Search in user table
.with_select_fields(vec![
"id",
"user_id",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_email");
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_email' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found");
};
Ok(mentor)
}
#[instrument(skip(self, id, include_deleted), err)]
pub async fn query_mentor_by_id(
&self,
id: &Thing,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_id(get_id(id)?.1)
.with_select_fields(vec![
"id",
"user_id",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_id");
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_id' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found in database");
};
if mentor.is_deleted && !include_deleted {
bail!("Mentor has been deleted");
}
Ok(mentor)
}
#[instrument(skip(self, data), err)]
pub async fn query_create_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let dto: MentorInsertDto = data.into();
let resource = ResourceEnum::Mentors.to_string();
info!(query = %resource, "Executing SurrealDB create in query_create_mentor");
let record: Option<MentorSchema> = db
.create(resource)
.content(dto.clone())
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_mentor' took: {elapsed:.2?}");
}
match record {
Some(mentor) => {
let id_str = mentor.id.id.to_raw();
let _user = format!("{:?}", mentor.user_id);
Ok(id_str)
}
None => {
bail!("Failed to create mentor")
}
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let id_ref = &data.id;
let record_key = get_id(id_ref)?;
let _existing = self.query_mentor_by_id(id_ref, false).await?;
let mut merged_data_json: Map<String, Value> =
serde_json::to_value(data.clone())
.map_err(|e| anyhow::anyhow!("Failed to serialize MentorSchema: {}", e))?
.as_object()
.cloned()
.unwrap_or_default();
merged_data_json.remove("id");
merged_data_json.remove("user_id");
merged_data_json.remove("created_at");
merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB update in query_update_mentor");
let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update mentor".into()),
None => {
bail!("Failed to update mentor")
}
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_mentor(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let thing = make_thing(ResourceEnum::Mentors.to_string().as_str(), &id);
let record_key = get_id(&thing)?;
let mentor_to_delete_res = self.query_mentor_by_id(&thing, true).await;
let _mentor_to_delete = match mentor_to_delete_res {
Ok(mentor) => {
if mentor.is_deleted {
bail!("Mentor is already soft deleted");
}
mentor
}
Err(e) => {
if e.to_string().contains("Mentor has been deleted") {
bail!("Mentor is already soft deleted");
} else {
return Err(e);
}
}
};
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB soft delete in query_delete_mentor");
let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete mentor".into()),
None => {
bail!("Failed to soft delete mentor")
}
}
}
}
@@ -0,0 +1,175 @@
use super::{
MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorSchema {
pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>,
// Personal data has been moved to UsersSchema - use user_id to reference
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url
pub industries: Vec<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: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl Default for MentorSchema {
fn default() -> Self {
Self {
id: make_thing(
ResourceEnum::Mentors.to_string().as_str(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(
ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(),
)),
industries: Vec::new(),
expertise: Vec::new(),
languages: Vec::new(),
current_company: String::new(),
current_role: String::new(),
years_of_experience: 0,
topics_of_interest: Vec::new(),
preferred_mentee_level: Vec::new(),
preferred_mentoring_formats: Vec::new(),
availability_commitment: String::new(),
mentoring_rate: MentoringRate {
amount: 0,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl MentorSchema {
pub fn create(
professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics,
user_id_raw: String,
) -> Self {
Self {
id: make_thing(
&ResourceEnum::Mentors.to_string(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
// Personal data now stored in UsersSchema, not here
industries: professional_profile.industries,
expertise: professional_profile.expertise,
languages: professional_profile.languages,
current_company: professional_profile.current_company,
current_role: professional_profile.current_role,
years_of_experience: professional_profile.years_of_experience,
topics_of_interest: mentoring_logistics.topics_of_interest,
preferred_mentee_level: mentoring_logistics.preferred_mentee_level,
preferred_mentoring_formats: mentoring_logistics.preferred_mentoring_formats,
availability_commitment: mentoring_logistics.availability_commitment,
mentoring_rate: MentoringRate {
amount: mentoring_logistics.mentoring_rate_amount,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id,
user_id: Some(dto.user_id),
// Personal data now comes from UsersSchema via user_id
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url) are now updated in UsersSchema, not here
// Only update professional fields that are still in MentorSchema
if let Some(val) = dto.industries {
self.industries = val;
}
if let Some(val) = dto.expertise {
self.expertise = val;
}
if let Some(val) = dto.languages {
self.languages = val;
}
if let Some(val) = dto.current_company {
self.current_company = val;
}
if let Some(val) = dto.current_role {
self.current_role = val;
}
if let Some(val) = dto.years_of_experience {
self.years_of_experience = val;
}
if let Some(val) = dto.topics_of_interest {
self.topics_of_interest = val;
}
if let Some(val) = dto.preferred_mentee_level {
self.preferred_mentee_level = val;
}
if let Some(val) = dto.preferred_mentoring_formats {
self.preferred_mentoring_formats = val;
}
if let Some(val) = dto.availability_commitment {
self.availability_commitment = val;
}
if let Some(val) = dto.mentoring_rate_amount {
self.mentoring_rate.amount = val;
}
self.updated_at = get_iso_date();
self
}
pub fn update_status(mut self, status: String) -> Self {
self.status = status;
self.updated_at = get_iso_date();
self
}
}
@@ -0,0 +1,463 @@
use crate::v1::mentors::{
MentorDetailQueryDto, MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterResponseDto, MentorSchema, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsRepository,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_entities::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_iam::{
AuthRepository, RolesEnum, RolesRepository, UsersRepository, UsersSchema,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use tracing::error;
pub struct MentorsService;
impl MentorsService {
pub async fn register_mentor(
state: &AppState,
dto: MentorUserRegisterRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let mentor_repo = MentorsRepository::new(state);
let role_repo = RolesRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_email = dto.email.clone();
let mut _user_to_update: Option<UsersSchema> = None;
let existing_user_result =
user_repo.query_user_by_email(user_email.clone()).await;
let user_id;
let final_user_email = user_email.clone();
if let Ok(user_detail_query_dto) = existing_user_result {
if mentor_repo
.query_mentor_by_email(user_email.clone(), false)
.await
.is_ok()
{
return common_response(
StatusCode::CONFLICT,
"Mentor profile already exists for this user",
);
}
let mut user_schema = UsersSchema::from(user_detail_query_dto.clone());
user_schema.fullname = dto.fullname.clone();
user_schema.phone_number = dto.phone_number.clone();
// Update personal data from identity_and_verification
user_schema.legal_name = Some(dto.identity_and_verification.legal_name.clone());
user_schema.gender = dto.identity_and_verification.gender.clone();
user_schema.domicile = dto.identity_and_verification.domicile.clone();
user_schema.phone_for_verification = Some(dto.identity_and_verification.phone_for_verification.clone());
// Update personal data from professional_profile
user_schema.bio = Some(dto.professional_profile.bio.clone());
user_schema.last_education = dto.professional_profile.last_education.clone();
user_schema.linkedin_url = dto.professional_profile.linkedin_url.clone();
user_schema.github_url = dto.professional_profile.github_url.clone();
user_schema.cv_url = dto.professional_profile.cv_url.clone();
user_schema.portfolio_url = dto.professional_profile.portfolio_url.clone();
user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password during update for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
user_schema.password = hashed_password;
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
user_schema.role =
imphnen_utils::make_thing(&ResourceEnum::Roles.to_string(), &mentor_role.id);
user_schema.is_active = false;
if let Err(_err) = user_repo.query_update_user(user_schema.clone()).await {
error!(
"Failed to update existing user {} to mentor role: {}",
final_user_email, _err
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
user_id = user_schema.id.clone();
} else {
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password for new user {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let new_user_schema = UsersSchema {
id: imphnen_utils::make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
email: dto.email.clone(),
fullname: dto.fullname.clone(),
password: hashed_password,
phone_number: dto.phone_number.clone(),
// Store personal data from identity_and_verification in user
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
gender: dto.identity_and_verification.gender.clone(),
domicile: dto.identity_and_verification.domicile.clone(),
phone_for_verification: Some(dto.identity_and_verification.phone_for_verification.clone()),
// Store personal data from professional_profile in user
bio: Some(dto.professional_profile.bio.clone()),
last_education: dto.professional_profile.last_education.clone(),
linkedin_url: dto.professional_profile.linkedin_url.clone(),
github_url: dto.professional_profile.github_url.clone(),
cv_url: dto.professional_profile.cv_url.clone(),
portfolio_url: dto.professional_profile.portfolio_url.clone(),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing(
&ResourceEnum::Roles.to_string(),
&mentor_role.id,
),
is_active: false,
..Default::default()
};
user_id = new_user_schema.id.clone();
match user_repo.query_create_user(new_user_schema).await {
Ok(_) => {}
Err(_err) => {
error!("Failed to create new user {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
}
let otp = imphnen_utils::generate_otp::OtpManager::generate_otp();
match auth_repo
.query_store_otp(final_user_email.clone(), otp)
.await
{
Ok(_) => {
let message = format!("your otp code is {otp}");
if let Err(_err) =
imphnen_utils::send_email(&final_user_email, "OTP Verification", &message)
{
error!("Failed to send OTP email to {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
Err(_err) => {
error!("Failed to store OTP for {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
let mentor_schema = MentorSchema::create(
dto.professional_profile,
dto.mentoring_logistics,
user_id.to_raw(),
);
match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
Ok(mentor_profile_id) => {
let user_after_mentor_creation_dto = user_repo
.query_user_by_email(final_user_email.clone())
.await
.unwrap();
let mut user_after_mentor_creation_schema =
UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema
.update_mentor_id(Some(mentor_profile_id));
if let Err(_e) = user_repo
.query_update_user(user_after_mentor_creation_schema)
.await
{
error!(
"Failed to update user's mentor_id for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_e.to_string(),
);
}
let response_dto = MentorRegisterResponseDto::from(mentor_schema);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => {
error!(
"Failed to create mentor profile for {}: {}",
final_user_email, _e
);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string())
}
}
}
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
match repo.query_mentor_list(meta).await {
Ok(result) => {
let mut mentor_list_data: Vec<MentorListResponseDto> = Vec::new();
for mentor_with_user in result.data {
let mentor_dto = MentorDetailQueryDto::from(mentor_with_user);
let mut list_item = MentorListResponseDto::from(mentor_dto.clone());
// Get user data to populate personal fields
if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await {
list_item.fullname = Some(user.fullname);
list_item.email = Some(user.email);
}
mentor_list_data.push(list_item);
}
success_list_response(ResponseListSuccessDto {
data: mentor_list_data,
meta: result.meta,
})
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let mentor_repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
match mentor_repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => {
// Get user data separately
let user_result = user_repo.query_user_by_id(&mentor.user_id).await;
match user_result {
Ok(user) => {
// Combine mentor and user data
let dto = MentorDetailResponseDto {
id: mentor.id.to_raw(),
user_id: mentor.user_id.to_raw(),
// Personal data from user
fullname: Some(user.fullname),
email: Some(user.email),
legal_name: user.legal_name,
gender: user.gender,
domicile: user.domicile,
phone_for_verification: user.phone_for_verification,
bio: user.bio,
last_education: user.last_education,
linkedin_url: user.linkedin_url,
github_url: user.github_url,
cv_url: user.cv_url,
portfolio_url: user.portfolio_url,
// Professional data from mentor
industries: mentor.industries,
expertise: mentor.expertise,
languages: mentor.languages,
current_company: mentor.current_company,
current_role: mentor.current_role,
years_of_experience: mentor.years_of_experience,
topics_of_interest: mentor.topics_of_interest,
preferred_mentee_level: mentor.preferred_mentee_level,
preferred_mentoring_formats: mentor.preferred_mentoring_formats,
availability_commitment: mentor.availability_commitment,
mentoring_rate: mentor.mentoring_rate,
status: mentor.status,
created_at: mentor.created_at,
updated_at: mentor.updated_at,
};
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => {
error!("Failed to get user data for mentor {}: {}", id, _e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get mentor user data")
}
}
}
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn update_mentor(
state: &AppState,
id: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn delete_mentor(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_delete_mentor(id.to_string()).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn get_mentor_me(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"Mentor profile not found for current user",
),
}
}
pub async fn update_mentor_me(
state: &AppState,
email: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let existing_mentor =
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::FORBIDDEN, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor = repo
.query_mentor_by_email(email.to_string(), false)
.await
.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_status(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => common_response(StatusCode::OK, &mentor.status),
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"No mentor application found for current user",
),
}
}
pub async fn verify_mentor(
state: &AppState,
id: &str,
dto: MentorVerifyRequestDto,
) -> Response {
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update_status(dto.status);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use axum::{
Router,
routing::{delete, get, post, put},
};
pub mod mentors_controller;
pub mod mentors_dto;
pub mod mentors_repository;
pub mod mentors_schema;
pub mod mentors_service;
pub use mentors_controller::*;
pub use mentors_dto::*;
pub use mentors_repository::*;
pub use mentors_schema::*;
pub use mentors_service::*;
pub fn mentors_router() -> Router {
Router::new()
.route("/", get(get_mentor_list))
.route("/register", post(post_register_mentor))
.route("/me", get(get_mentor_me))
.route("/update/me", put(put_update_mentor_me))
.route("/status", get(get_mentor_status))
.route("/detail/{id}", get(get_mentor_by_id))
.route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put_verify_mentor))
}
+7
View File
@@ -0,0 +1,7 @@
use axum::Router;
pub mod mentors;
pub fn dimentorin_router() -> Router {
Router::new().nest("/mentors", mentors::mentors_router())
}
View File
+1
View File
@@ -0,0 +1 @@
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "imphnen-entities"
version = "0.1.0"
edition = "2024"
[dependencies]
axum.workspace = true
serde.workspace = true
utoipa.workspace = true
surrealdb.workspace = true
anyhow.workspace = true
thiserror.workspace = true
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use surrealdb::{Surreal, engine::any::Any, engine::local::Db};
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MessageResponseDto {
pub message: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct MetaRequestDto {
pub page: Option<u64>,
pub per_page: Option<u64>,
pub search: Option<String>,
pub sort_by: Option<String>,
pub order: Option<String>,
pub filter: Option<String>,
pub filter_by: Option<String>,
}
impl Default for MetaRequestDto {
fn default() -> Self {
MetaRequestDto {
page: Some(1),
per_page: Some(10),
search: None,
sort_by: None,
order: None,
filter: None,
filter_by: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct MetaResponseDto {
pub page: Option<u64>,
pub per_page: Option<u64>,
pub total: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ResponseSuccessDto<T: Serialize> {
pub data: T,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ResponseListSuccessDto<T: Serialize> {
pub data: T,
pub meta: Option<MetaResponseDto>,
}
pub type SurrealWsClient = Surreal<Any>;
pub type SurrealMemClient = Surreal<Db>;
#[derive(Clone)]
pub struct AppState {
pub surrealdb_ws: SurrealWsClient,
pub surrealdb_mem: SurrealMemClient,
}
#[derive(Debug, serde::Deserialize)]
pub struct CountResult {
pub count: u64,
}
+58
View File
@@ -0,0 +1,58 @@
pub mod error {
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("database error: {0}")]
Db(String),
#[error("anyhow error: {0}")]
Anyhow(#[from] anyhow::Error),
#[error("HTTP status code error: {0}")]
StatusCode(StatusCode),
#[error("authentication error: {0}")]
Auth(String),
#[error("validation error: {0}")]
Validation(String),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let (status, error_message) = match self {
Error::Db(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {detail}"),
),
Error::Anyhow(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal server error: {detail}"),
),
Error::StatusCode(s) => (s, format!("HTTP error: {}", s)),
Error::Auth(detail) => (
StatusCode::UNAUTHORIZED,
format!("Authentication error: {detail}"),
),
Error::Validation(detail) => (
StatusCode::BAD_REQUEST,
format!("Validation error: {detail}"),
),
};
(status, Json(error_message)).into_response()
}
}
impl From<surrealdb::Error> for Error {
fn from(error: surrealdb::Error) -> Self {
Self::Db(error.to_string())
}
}
impl From<StatusCode> for Error {
fn from(status: StatusCode) -> Self {
Self::StatusCode(status)
}
}
}
@@ -1,3 +1,4 @@
pub mod common_dto;
pub mod error_dto;
pub use common_dto::*;
pub use error_dto::*;
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "imphnen-gacha"
version = "0.1.0"
edition = "2024"
[dependencies]
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
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
rand_distr.workspace = true
log.workspace = true
tracing.workspace = true
+6
View File
@@ -0,0 +1,6 @@
pub mod v1;
pub use imphnen_entities::*;
pub use imphnen_libs::*;
pub use imphnen_utils::*;
pub use v1::*;
@@ -0,0 +1,66 @@
use axum::Extension;
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{Json, extract::Path};
use imphnen_iam::{PermissionsEnum, permissions_guard};
use imphnen_libs::{AppState, MessageResponseDto, ResponseSuccessDto};
use super::{GachaClaimItemDto, GachaClaimRequestDto, GachaClaimService};
#[utoipa::path(
get,
path = "/v1/gacha/claims/detail/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Claim ID")),
responses(
(status = 200, description = "Get Gacha Claim by ID", body = ResponseSuccessDto<GachaClaimItemDto>)
),
tag = "Gacha"
)]
pub async fn get_detail_gacha_claim(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaClaims],
)
.await
{
Ok((_user, state)) => GachaClaimService::get_gacha_claim_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/gacha/claims/create",
request_body = GachaClaimRequestDto,
responses(
(status = 201, description = "Create new gacha claim", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn post_create_gacha_claim(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<GachaClaimRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaClaims],
)
.await
{
Ok((_user, state)) => GachaClaimService::create_gacha_claim(&state, payload).await,
Err(response) => response,
}
}
@@ -0,0 +1,47 @@
use crate::{GachaItemDto, GachaItemSchema};
use imphnen_iam::{UsersDetailItemDto, UsersDetailQueryDto};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaClaimRequestDto {
#[validate(length(min = 1, message = "User ID must not be empty"))]
pub user_id: String,
#[validate(length(min = 1, message = "Item ID must not be empty"))]
pub item_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaClaimItemDto {
pub id: String,
pub user: UsersDetailItemDto,
pub item: GachaItemDto,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaClaimQueryDto {
pub id: Thing,
pub user: UsersDetailQueryDto,
pub item: GachaItemSchema,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl GachaClaimItemDto {
pub fn from(dto: &GachaClaimQueryDto) -> Self {
Self {
id: dto.id.id.to_raw(),
user: UsersDetailItemDto::from(&dto.user),
item: GachaItemDto::from(dto.item.clone()),
is_deleted: dto.is_deleted,
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
}
}
}
@@ -0,0 +1,72 @@
use super::{GachaClaimQueryDto, GachaClaimSchema};
use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail};
use imphnen_iam::DetailQueryBuilder;
use std::time::Instant;
use tracing::{instrument, info};
pub struct GachaClaimRepository<'a> {
state: &'a AppState,
}
impl<'a> GachaClaimRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_claim_by_id(
&self,
id: String,
) -> Result<GachaClaimQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaClaims.to_string())
.with_id(id.clone())
.with_select_fields(vec!["*"])
.with_fetch("item")
.with_fetch("user");
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaClaimQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_claim_by_id' took: {elapsed:.2?}");
}
match result {
Some(claim) if !claim.is_deleted => Ok(claim),
_ => bail!("Gacha Claim not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_claim(
&self,
data: GachaClaimSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::GachaClaims.to_string(),
content = ?data,
"Executing SurrealDB create query"
);
let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_claim' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Claim".into()),
None => bail!("Failed to create Gacha Claim"),
}
}
}
@@ -0,0 +1,64 @@
use crate::{GachaRollQueryDto, ResourceEnum, make_thing};
use imphnen_iam::get_iso_date;
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
use super::GachaClaimRequestDto;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaClaimSchema {
pub id: Thing,
pub user: Thing,
pub item: Thing,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaClaimSchema {
fn default() -> Self {
GachaClaimSchema {
id: make_thing(
&ResourceEnum::GachaClaims.to_string(),
&Uuid::new_v4().to_string(),
),
user: make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
item: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl GachaClaimSchema {
pub fn from(dto: GachaClaimRequestDto) -> Self {
Self {
id: make_thing(
&ResourceEnum::GachaClaims.to_string(),
&Uuid::new_v4().to_string(),
),
user: make_thing(&ResourceEnum::Users.to_string(), &dto.user_id),
item: make_thing(&ResourceEnum::GachaItems.to_string(), &dto.item_id),
..Default::default()
}
}
pub fn roll(roll: GachaRollQueryDto, user_id: Thing) -> Self {
Self {
id: make_thing(
&ResourceEnum::GachaClaims.to_string(),
&Uuid::new_v4().to_string(),
),
user: user_id,
item: roll.item.id.clone(),
..Default::default()
}
}
}
@@ -0,0 +1,36 @@
use crate::{
AppState, GachaClaimItemDto, GachaClaimRepository, GachaClaimRequestDto,
GachaClaimSchema, ResponseSuccessDto, common_response, success_response,
validate_request,
};
use axum::http::StatusCode;
use axum::response::Response;
pub struct GachaClaimService;
impl GachaClaimService {
pub async fn get_gacha_claim_by_id(state: &AppState, id: String) -> Response {
let repo = GachaClaimRepository::new(state);
match repo.query_gacha_claim_by_id(id).await {
Ok(claim) => success_response(ResponseSuccessDto {
data: GachaClaimItemDto::from(&claim),
}),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_gacha_claim(
state: &AppState,
payload: GachaClaimRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = GachaClaimRepository::new(state);
let schema = GachaClaimSchema::from(payload);
match repo.query_create_gacha_claim(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
}
+22
View File
@@ -0,0 +1,22 @@
use axum::{
Router,
routing::{get, post},
};
pub mod gacha_claims_controller;
pub mod gacha_claims_dto;
pub mod gacha_claims_repository;
pub mod gacha_claims_schema;
pub mod gacha_claims_service;
pub use gacha_claims_controller::*;
pub use gacha_claims_dto::*;
pub use gacha_claims_repository::*;
pub use gacha_claims_schema::*;
pub use gacha_claims_service::*;
pub fn gacha_claim_router() -> Router {
Router::new()
.route("/create", post(post_create_gacha_claim))
.route("/detail/{id}", get(get_detail_gacha_claim))
}
@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GachaCreditRequestDto {
pub user_id: String,
pub amount: i32,
}
@@ -0,0 +1,128 @@
use super::{GachaCreditRequestDto, GachaCreditSchema};
use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail};
use imphnen_iam::make_thing;
use std::time::Instant;
use surrealdb::Uuid;
use tracing::{instrument, info};
pub struct GachaCreditRepository<'a> {
state: &'a AppState,
}
impl<'a> GachaCreditRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, user_id), err)]
pub async fn query_by_user_id(
&self,
user_id: String,
) -> Result<Option<GachaCreditSchema>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1",
ResourceEnum::GachaCredits,
ResourceEnum::Users
);
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaCreditSchema> =
db.query(sql).bind(("user_id", user_id)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_by_user_id' took: {elapsed:.2?}");
}
Ok(result.into_iter().next())
}
#[instrument(skip(self, user_id), err)]
pub async fn query_consume_credit(&self, user_id: String) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let credit_opt = self.query_by_user_id(user_id).await?;
let Some(mut credit) = credit_opt else {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no credit to consume)"
);
}
return Ok(());
};
if credit.available_rolls <= 0 {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no rolls remaining)"
);
}
bail!("No extra roll credits remaining");
}
credit.available_rolls -= 1;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for consume_credit");
let _: Option<GachaCreditSchema> = db
.update((
&ResourceEnum::GachaCredits.to_string(),
credit.id.id.to_raw(),
))
.merge(credit)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_consume_credit' took: {elapsed:.2?}");
}
Ok(())
}
#[instrument(skip(self, payload), err)]
pub async fn query_add_credit(
&self,
payload: GachaCreditRequestDto,
) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? {
credit.available_rolls += payload.amount;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for add_credit");
let _: Option<GachaCreditSchema> = db
.update((
&ResourceEnum::GachaCredits.to_string(),
credit.id.id.to_raw(),
))
.merge(credit)
.await?;
} else {
let data = GachaCreditSchema {
id: make_thing(
&ResourceEnum::GachaCredits.to_string(),
&Uuid::new_v4().to_string(),
),
user: make_thing(&ResourceEnum::Users.to_string(), &payload.user_id),
available_rolls: payload.amount,
..Default::default()
};
info!(operation = "create", table = %ResourceEnum::GachaCredits.to_string(), "Executing SurrealDB create for add_credit");
let _: Option<GachaCreditSchema> = db
.create(ResourceEnum::GachaCredits.to_string())
.content(data)
.await?;
}
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_add_credit' took: {elapsed:.2?}");
}
Ok(())
}
}
@@ -0,0 +1,39 @@
use imphnen_iam::get_iso_date;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GachaCreditSchema {
pub id: Thing,
pub user: Thing,
pub available_rolls: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaCreditSchema {
fn default() -> Self {
GachaCreditSchema {
id: Thing::from(("app_gacha_credits", "uuid")),
user: Thing::from(("app_users", "uuid")),
available_rolls: 0,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl GachaCreditSchema {
pub fn from(&self) -> Self {
Self {
id: self.id.clone(),
user: self.user.clone(),
available_rolls: self.available_rolls,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
@@ -0,0 +1,7 @@
pub mod gacha_credits_dto;
pub mod gacha_credits_repository;
pub mod gacha_credits_schema;
pub use gacha_credits_dto::*;
pub use gacha_credits_repository::*;
pub use gacha_credits_schema::*;
@@ -0,0 +1,164 @@
use crate::{
AppState, GachaItemDto, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemService, MessageResponseDto,
MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use axum::{
Extension, Json,
extract::{Path, Query},
http::HeaderMap,
response::IntoResponse,
};
use imphnen_iam::{PermissionsEnum, permissions_guard};
#[utoipa::path(
get,
path = "/v1/gacha/items",
security(
("Bearer" = [])
),
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 gacha item list", body = ResponseListSuccessDto<Vec<GachaItemDto>>)
),
tag = "Gacha"
)]
pub async fn get_gacha_item_list(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::ReadListGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/gacha/items/detail/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Item ID")),
responses(
(status = 200, description = "Get gacha item by ID", body = ResponseSuccessDto<GachaItemDto>)
),
tag = "Gacha"
)]
pub async fn get_gacha_item_by_id(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
path = "/v1/gacha/items/create",
security(
("Bearer" = [])
),
request_body = GachaItemRequestDto,
responses(
(status = 201, description = "Create gacha item", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn post_create_gacha_item(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<GachaItemRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/gacha/items/update/{id}",
security(
("Bearer" = [])
),
request_body = GachaItemUpdateRequestDto,
responses(
(status = 200, description = "Update gacha item", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn put_update_gacha_item(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<GachaItemUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::UpdateGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/gacha/items/delete/{id}",
security(
("Bearer" = [])
),
responses(
(status = 200, description = "Delete gacha item", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn delete_gacha_item(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::DeleteGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await,
Err(response) => response,
}
}
@@ -0,0 +1,43 @@
use super::GachaItemSchema;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaItemRequestDto {
#[validate(length(min = 1, message = "Item name must not be empty"))]
pub name: String,
#[validate(length(min = 1, message = "Image URL must not be empty"))]
pub image_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaItemUpdateRequestDto {
#[validate(length(min = 1, message = "Item name must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[validate(length(min = 1, message = "Image URL must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaItemDto {
pub id: String,
pub name: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl GachaItemDto {
pub fn from(dto: GachaItemSchema) -> Self {
Self {
id: dto.id.id.to_raw(),
name: dto.name.clone(),
is_deleted: dto.is_deleted,
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
}
}
}
@@ -0,0 +1,168 @@
use super::GachaItemSchema;
use crate::{
AppState, GachaItemDto, MetaRequestDto, ResourceEnum, ResponseListSuccessDto,
get_id, make_thing,
};
use anyhow::{Result, bail};
use imphnen_iam::QueryListBuilder;
use imphnen_utils::get_iso_date;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct GachaItemRepository<'a> {
state: &'a AppState,
}
impl<'a> GachaItemRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_gacha_item_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
let now = Instant::now();
let surreal_query = format!(
"SELECT * FROM {} WHERE is_deleted = false AND name LIKE ?",
ResourceEnum::GachaItems.to_string()
);
info!(query = %surreal_query, "Executing SurrealDB query");
let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> =
QueryListBuilder::new(
&self.state.surrealdb_ws,
&ResourceEnum::GachaItems.to_string(),
&meta,
)
.with_condition("is_deleted = false")
.search_field("name")
.select_fields(vec!["*"])
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_list' took: {elapsed:.2?}");
}
let data = raw_result
.data
.into_iter()
.map(GachaItemDto::from)
.collect();
Ok(ResponseListSuccessDto {
data,
meta: raw_result.meta,
})
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let surreal_query = format!("SELECT * FROM {} WHERE id = '{}'", ResourceEnum::GachaItems.to_string(), id);
info!(query = %surreal_query, "Executing SurrealDB query");
let result: Option<GachaItemSchema> = db
.select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_by_id' took: {elapsed:.2?}");
}
match result {
Some(item) if !item.is_deleted => Ok(item),
_ => bail!("Gacha Item not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let surreal_query = format!("CREATE {} CONTENT ...", ResourceEnum::GachaItems.to_string());
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Item".into()),
None => bail!("Failed to create Gacha Item"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let existing = self.query_gacha_item_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Gacha Item already deleted");
}
let merged = GachaItemSchema {
created_at: existing.created_at,
..data.clone()
};
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update Gacha Item".into()),
None => bail!("Failed to update Gacha Item"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_gacha_item(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let item_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
let item = self.query_gacha_item_by_id(item_id.id.to_raw()).await?;
if item.is_deleted {
bail!("Gacha Item already deleted");
}
let record_key = get_id(&item.id)?;
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete Gacha Item".into()),
None => bail!("Failed to soft delete Gacha Item"),
}
}
}
@@ -0,0 +1,50 @@
use crate::{ResourceEnum, make_thing};
use imphnen_iam::get_iso_date;
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
use super::GachaItemRequestDto;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaItemSchema {
pub id: Thing,
pub name: String,
pub image_url: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaItemSchema {
fn default() -> Self {
GachaItemSchema {
id: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
name: String::new(),
image_url: String::new(),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl GachaItemSchema {
pub fn from(dto: GachaItemRequestDto) -> Self {
Self {
id: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
name: dto.name,
image_url: dto.image_url,
..Default::default()
}
}
pub fn from_existing(existing: GachaItemSchema) -> Self {
existing
}
}
@@ -0,0 +1,114 @@
use crate::{
AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemSchema,
MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
common_response, make_thing, success_list_response, success_response,
validate_request,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_utils::get_iso_date;
pub struct GachaItemService;
impl GachaItemService {
pub async fn get_gacha_item_list(
state: &AppState,
meta: MetaRequestDto,
) -> Response {
let repo = GachaItemRepository::new(state);
match repo.query_gacha_item_list(meta).await {
Ok(data) => {
let response = ResponseListSuccessDto {
data: data.data,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_gacha_item_by_id(state: &AppState, id: String) -> Response {
let repo = GachaItemRepository::new(state);
match repo.query_gacha_item_by_id(id).await {
Ok(item) => success_response(ResponseSuccessDto {
data: GachaItemDto::from(item),
}),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_gacha_item(
state: &AppState,
payload: GachaItemRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &payload.name), // Fixed: Use payload.name or some other identifier
name: payload.name,
image_url: payload.image_url,
..Default::default()
};
match repo.query_create_gacha_item(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_gacha_item(
state: &AppState,
payload: GachaItemUpdateRequestDto,
id: String,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state);
// Get current gacha item data first
let _thing_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
let current_item = match repo.query_gacha_item_by_id(id.clone()).await {
Ok(item) => item,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Gacha Item not found"),
};
let mut updated_item = current_item;
updated_item.updated_at = Some(get_iso_date());
// Only update fields that are provided
if let Some(name) = payload.name {
updated_item.name = name;
}
if let Some(image_url) = payload.image_url {
updated_item.image_url = image_url;
}
match repo.query_update_gacha_item(updated_item).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => {
if e.to_string().contains("not found") {
common_response(StatusCode::NOT_FOUND, "Gacha Item not found")
} else {
common_response(StatusCode::BAD_REQUEST, &e.to_string())
}
}
}
}
pub async fn delete_gacha_item(state: &AppState, id: String) -> Response {
let repo = GachaItemRepository::new(state);
match repo.query_delete_gacha_item(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => {
if e.to_string().contains("not found") {
common_response(StatusCode::NOT_FOUND, "Gacha Item not found")
} else {
common_response(StatusCode::BAD_REQUEST, &e.to_string())
}
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
use axum::{
Router,
routing::{delete, get, post, put},
};
pub mod gacha_items_controller;
pub mod gacha_items_dto;
pub mod gacha_items_repository;
pub mod gacha_items_schema;
pub mod gacha_items_service;
pub use gacha_items_controller::*;
pub use gacha_items_dto::*;
pub use gacha_items_repository::*;
pub use gacha_items_schema::*;
pub use gacha_items_service::*;
pub fn gacha_item_router() -> Router {
Router::new()
.route("/", get(get_gacha_item_list))
.route("/create", post(post_create_gacha_item))
.route("/detail/{id}", get(get_gacha_item_by_id))
.route("/update/{id}", put(put_update_gacha_item))
.route("/delete/{id}", delete(delete_gacha_item))
}
@@ -0,0 +1,122 @@
use crate::{
AppState, GachaRollItemDto, GachaRollRequestDto, GachaRollService,
MessageResponseDto, ResponseSuccessDto,
};
use axum::{
Extension, Json, extract::Path, http::HeaderMap, response::IntoResponse,
};
use imphnen_iam::{PermissionsEnum, permissions_guard};
#[utoipa::path(
get,
path = "/v1/gacha/rolls/detail/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Roll ID")),
responses(
(status = 200, description = "Get Gacha Roll by ID", body = ResponseSuccessDto<GachaRollItemDto>)
),
tag = "Gacha"
)]
pub async fn get_detail_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaRolls],
)
.await
{
Ok((_user, state)) => GachaRollService::get_gacha_roll_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
path = "/v1/gacha/rolls/create",
security(
("Bearer" = [])
),
request_body = GachaRollRequestDto,
responses(
(status = 201, description = "Create new gacha roll", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn post_create_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<GachaRollRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaRolls],
)
.await
{
Ok((_user, state)) => GachaRollService::create_gacha_roll(&state, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
path = "/v1/gacha/rolls/execute",
security(
("Bearer" = [])
),
responses(
(status = 200, description = "Execute and get 1 gacha result", body = ResponseSuccessDto<GachaRollItemDto>)
),
tag = "Gacha"
)]
pub async fn post_execute_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> impl IntoResponse {
match permissions_guard(
headers.clone(),
Extension(state),
vec![PermissionsEnum::ExecuteGachaRolls],
)
.await
{
Ok((_user, state)) => GachaRollService::execute_roll_once(headers, &state).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/gacha/rolls/delete/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Roll ID")),
responses(
(status = 200, description = "Delete Gacha Roll (soft delete)", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn delete_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![PermissionsEnum::DeleteGachaRolls],
)
.await
{
Ok((_user, state)) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response,
}
}
@@ -0,0 +1,50 @@
use crate::{GachaItemDto, GachaItemSchema};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaRollRequestDto {
#[validate(length(min = 1, message = "Item ID must not be empty"))]
pub item_id: String,
pub weight: f32,
#[validate(range(min = 1, message = "Quantity must be at least 1"))]
pub quantity: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaRollItemDto {
pub id: String,
pub item: GachaItemDto,
pub weight: f32,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl GachaRollItemDto {
pub fn from(dto: &GachaRollQueryDto) -> Self {
Self {
id: dto.id.id.to_raw(),
item: GachaItemDto::from(dto.item.clone()),
weight: dto.weight,
quantity: dto.quantity,
is_deleted: dto.is_deleted,
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaRollQueryDto {
pub id: Thing,
pub item: GachaItemSchema,
pub weight: f32,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,141 @@
use super::GachaRollQueryDto;
use super::GachaRollSchema;
use crate::{AppState, DetailQueryBuilder, ResourceEnum, get_id, make_thing};
use anyhow::{Result, bail};
use rand::prelude::*;
use imphnen_utils::get_iso_date;
use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct GachaRollRepository<'a> {
state: &'a AppState,
}
impl<'a> GachaRollRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_roll_by_id(
&self,
id: String,
) -> Result<GachaRollQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaRolls.to_string())
.with_id(id.clone())
.with_condition("is_deleted = false")
.with_select_fields(vec!["*"])
.with_fetch("item");
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaRollQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_roll_by_id' took: {elapsed:.2?}");
}
match result {
Some(roll) if !roll.is_deleted => Ok(roll),
_ => bail!("Gacha Roll not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_roll(
&self,
data: GachaRollSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(query = "CREATE", "Executing SurrealDB create operation for GachaRolls");
let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_roll' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Roll".into()),
None => bail!("Failed to create Gacha Roll"),
}
}
#[instrument(skip(self), err)]
pub async fn query_all_active_rolls(&self) -> Result<Vec<GachaRollQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let table_name = ResourceEnum::GachaRolls.to_string();
let sql =
format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item");
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_all_active_rolls' took: {elapsed:.2?}");
}
Ok(result)
}
#[instrument]
pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> {
let filtered: Vec<_> = rolls
.iter()
.filter(|r| !r.is_deleted && r.quantity > 0)
.collect();
let weights: Vec<f32> = filtered
.iter()
.map(|r| r.weight * r.quantity as f32)
.collect();
if weights.iter().all(|&w| w <= 0.0) {
return None;
}
let dist = WeightedIndex::new(&weights).ok()?;
let mut rng = rand::rngs::ThreadRng::default();
let index = dist.sample(&mut rng);
Some(filtered[index].clone())
}
#[instrument(skip(self, id), err)]
pub async fn query_soft_delete_gacha_roll(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let roll_id_thing = make_thing(&ResourceEnum::GachaRolls.to_string(), &id);
let roll = self.query_gacha_roll_by_id(id.clone()).await?;
if roll.is_deleted {
bail!("Gacha Roll already deleted");
}
let record_key = get_id(&roll_id_thing)?;
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = "UPDATE", record_key = ?record_key, "Executing SurrealDB update operation for GachaRolls");
let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_soft_delete_gacha_roll' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete Gacha Roll".into()),
None => bail!("Failed to soft delete Gacha Roll"),
}
}
}
@@ -0,0 +1,52 @@
use crate::{ResourceEnum, make_thing};
use imphnen_iam::get_iso_date;
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
use super::GachaRollRequestDto;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaRollSchema {
pub id: Thing,
pub item: Thing,
pub weight: f32,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaRollSchema {
fn default() -> Self {
GachaRollSchema {
id: make_thing(
&ResourceEnum::GachaRolls.to_string(),
&Uuid::new_v4().to_string(),
),
item: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
weight: 0.0,
quantity: 0,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl GachaRollSchema {
pub fn create(dto: GachaRollRequestDto) -> Self {
Self {
id: make_thing(
&ResourceEnum::GachaRolls.to_string(),
&Uuid::new_v4().to_string(),
),
item: make_thing(&ResourceEnum::GachaItems.to_string(), &dto.item_id),
weight: dto.weight,
quantity: dto.quantity,
..Default::default()
}
}
}
@@ -0,0 +1,74 @@
use crate::{
AppState, GachaClaimRepository, GachaClaimSchema, GachaRollItemDto,
GachaRollRepository, GachaRollRequestDto, GachaRollSchema, ResponseSuccessDto,
common_response, success_response, validate_request,
};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use imphnen_iam::{UsersRepository, extract_email};
pub struct GachaRollService;
impl GachaRollService {
pub async fn get_gacha_roll_by_id(state: &AppState, id: String) -> Response {
let repo = GachaRollRepository::new(state);
match repo.query_gacha_roll_by_id(id).await {
Ok(roll) => success_response(ResponseSuccessDto {
data: GachaRollItemDto::from(&roll),
}),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_gacha_roll(
state: &AppState,
payload: GachaRollRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let schema = GachaRollSchema::create(payload);
let repo = GachaRollRepository::new(state);
match repo.query_create_gacha_roll(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn execute_roll_once(headers: HeaderMap, state: &AppState) -> Response {
let repo = GachaRollRepository::new(state);
let repo_claim = GachaClaimRepository::new(state);
let repo_user = UsersRepository::new(state);
let Some(email) = extract_email(&headers) else {
return common_response(StatusCode::UNAUTHORIZED, "Unauthorized");
};
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
return common_response(StatusCode::NOT_FOUND, "User not found");
};
match repo.query_all_active_rolls().await {
Ok(rolls) => match GachaRollRepository::roll_once(&rolls) {
Some(roll) => {
let claim = GachaClaimSchema::roll(roll.clone(), user.id);
match repo_claim.query_create_gacha_claim(claim).await {
Ok(_) => success_response(ResponseSuccessDto {
data: GachaRollItemDto::from(&roll),
}),
Err(e) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
}
}
}
None => common_response(StatusCode::NOT_FOUND, "No rollable item available"),
},
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn soft_delete_gacha_roll(state: &AppState, id: String) -> Response {
let repo = GachaRollRepository::new(state);
match repo.query_soft_delete_gacha_roll(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
}
+22
View File
@@ -0,0 +1,22 @@
pub mod gacha_rolls_controller;
pub mod gacha_rolls_dto;
pub mod gacha_rolls_repository;
pub mod gacha_rolls_schema;
pub mod gacha_rolls_service;
use axum::{
Router,
routing::{get, post},
};
pub use gacha_rolls_controller::*;
pub use gacha_rolls_dto::*;
pub use gacha_rolls_repository::*;
pub use gacha_rolls_schema::*;
pub use gacha_rolls_service::*;
pub fn gacha_roll_router() -> Router {
Router::new()
.route("/create", post(post_create_gacha_roll))
.route("/execute", post(post_execute_gacha_roll))
.route("/detail/{id}", get(get_detail_gacha_roll))
}
+18
View File
@@ -0,0 +1,18 @@
use axum::Router;
pub mod gacha_claims;
pub mod gacha_credits;
pub mod gacha_items;
pub mod gacha_rolls;
pub use gacha_claims::*;
pub use gacha_credits::*;
pub use gacha_items::*;
pub use gacha_rolls::*;
pub fn gacha_router() -> Router {
Router::new()
.nest("/gacha/claims", gacha_claim_router())
.nest("/gacha/items", gacha_item_router())
.nest("/gacha/rolls", gacha_roll_router())
}
View File

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