From 331a4a4e880aecd4c0424d026dff805564b200e2 Mon Sep 17 00:00:00 2001 From: maulanasdqn Date: Thu, 2 Apr 2026 22:29:08 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.0=20=E2=80=94=20standardize=20code?= =?UTF-8?q?base,=20centralize=20infra,=20merge=20QR=20into=20CMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce axum best practices across all 13 workspace crates (max 200 LOC/file, no comments, no unwrap, clean architecture) - Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin - Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates - Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS - Centralize SMTP through imphnen-email; remove dead HackathonConfig - Centralize database: QR crate now shares main DB pool (single DATABASE_URL) - Rename QR users table to qr_users to avoid collision with main users table - Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14) - Restructure imphnen-hackathon flat modules into clean architecture - Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra) - Fix Dockerfile to include all current workspace crates - Bump all crate versions 0.2.0 → 0.3.0 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 92 +- Cargo.lock | 69 +- Cargo.toml | 30 +- Dockerfile | 102 ++- imphnen-backend/Cargo.toml | 2 +- imphnen-backend/src/bin/api.rs | 24 +- imphnen-backend/src/bin/clear_db.rs | 184 ++-- imphnen-backend/src/bin/create_schema.rs | 105 ++- imphnen-backend/src/bin/mk_token.rs | 41 +- imphnen-backend/src/bin/seed_events.rs | 44 +- imphnen-backend/src/bin/seed_gacha_rolls.rs | 56 +- imphnen-backend/src/bin/seed_mentor_user.rs | 50 +- imphnen-backend/src/bin/seed_permissions.rs | 160 ++-- imphnen-backend/src/bin/seed_roles.rs | 22 +- .../src/bin/seed_roles_permissions.rs | 221 +++-- imphnen-backend/src/bin/seed_test_data.rs | 130 +-- imphnen-backend/src/bin/seed_users.rs | 289 +++--- imphnen-backend/src/bin/test_postgres.rs | 763 ++++++++-------- imphnen-backend/src/main.rs | 23 +- imphnen-cms/Cargo.toml | 5 +- .../src/events/application/event_service.rs | 47 +- imphnen-cms/src/events/domain/event.rs | 24 +- imphnen-cms/src/events/domain/repository.rs | 17 +- imphnen-cms/src/events/domain/service.rs | 17 +- .../src/events/infrastructure/http/dto.rs | 190 ++-- .../events/infrastructure/http/handlers.rs | 172 ++-- .../src/events/infrastructure/http/mod.rs | 2 +- .../src/events/infrastructure/http/routes.rs | 40 +- .../persistence/postgres_event_repository.rs | 246 +++--- imphnen-cms/src/events/mod.rs | 2 +- imphnen-cms/src/lib.rs | 6 +- .../campaigns/application/campaign_service.rs | 101 +++ .../src/qr}/campaigns/application/mod.rs | 0 imphnen-cms/src/qr/campaigns/domain/entity.rs | 22 + .../src/qr}/campaigns/domain/mod.rs | 0 .../src/qr/campaigns/domain/repository.rs | 17 + .../src/qr/campaigns/domain/service.rs | 20 + .../qr/campaigns/infrastructure/http/dto.rs | 22 + .../campaigns/infrastructure/http/handlers.rs | 103 +++ .../qr}/campaigns/infrastructure/http/mod.rs | 0 .../campaigns/infrastructure/http/routes.rs | 41 + .../src/qr}/campaigns/infrastructure/mod.rs | 0 .../infrastructure/persistence/mod.rs | 0 .../postgres_campaign_repository.rs | 147 ++++ .../src/qr}/campaigns/mod.rs | 2 +- .../src/qr}/middleware/mod.rs | 0 imphnen-cms/src/qr/middleware/qr_auth.rs | 69 ++ .../src/lib.rs => imphnen-cms/src/qr/mod.rs | 7 +- .../src/qr}/users/application/mod.rs | 0 .../src/qr/users/application/user_service.rs | 62 ++ imphnen-cms/src/qr/users/domain/entity.rs | 19 + .../src/qr}/users/domain/mod.rs | 0 imphnen-cms/src/qr/users/domain/repository.rs | 22 + imphnen-cms/src/qr/users/domain/service.rs | 22 + .../src/qr}/users/infrastructure/http/dto.rs | 16 +- .../qr/users/infrastructure/http/handlers.rs | 82 ++ .../src/qr}/users/infrastructure/http/mod.rs | 0 .../qr/users/infrastructure/http/routes.rs | 37 + .../src/qr}/users/infrastructure/mod.rs | 0 .../users/infrastructure/persistence/mod.rs | 0 .../persistence/postgres_user_repository.rs | 112 +++ .../src => imphnen-cms/src/qr}/users/mod.rs | 2 +- .../application/testimonial_service.rs | 52 +- imphnen-cms/src/testimonials/domain/mod.rs | 4 +- .../src/testimonials/domain/repository.rs | 20 +- .../src/testimonials/domain/service.rs | 20 +- .../src/testimonials/domain/testimonial.rs | 16 +- .../testimonials/infrastructure/http/dto.rs | 102 +-- .../infrastructure/http/handlers.rs | 215 ++--- .../testimonials/infrastructure/http/mod.rs | 2 +- .../infrastructure/http/routes.rs | 53 +- .../postgres_testimonial_repository.rs | 290 ++++--- imphnen-cms/src/testimonials/mod.rs | 4 +- imphnen-dimentorin/Cargo.toml | 2 +- imphnen-dimentorin/src/lib.rs | 10 +- .../application/mentor_query_service.rs | 170 ++++ .../mentor_registration_service.rs | 203 +++++ .../src/mentors/application/mentor_service.rs | 484 ++--------- .../application/mentor_update_service.rs | 135 +++ .../src/mentors/application/mod.rs | 3 + .../src/mentors/domain/mentor.rs | 34 +- .../src/mentors/domain/mentor_types.rs | 110 +++ imphnen-dimentorin/src/mentors/domain/mod.rs | 5 + .../src/mentors/domain/repository.rs | 39 +- .../src/mentors/domain/service.rs | 77 +- .../src/mentors/infrastructure/http/dto.rs | 261 ------ .../mentors/infrastructure/http/dto/mod.rs | 14 + .../mentors/infrastructure/http/dto/nested.rs | 92 ++ .../infrastructure/http/dto/request.rs | 187 ++++ .../infrastructure/http/dto/response.rs | 118 +++ .../mentors/infrastructure/http/handlers.rs | 279 ------ .../infrastructure/http/handlers/mod.rs | 10 + .../http/handlers/mutation_handlers.rs | 192 ++++ .../http/handlers/query_handlers.rs | 153 ++++ .../src/mentors/infrastructure/http/routes.rs | 81 +- .../mentors/infrastructure/persistence/mod.rs | 2 + .../persistence/postgres_mentor_queries.rs | 70 ++ .../persistence/postgres_mentor_repository.rs | 431 ++++----- .../persistence/postgres_mentor_write.rs | 65 ++ imphnen-dimentorin/src/mentors/mod.rs | 4 +- .../src/sessions/application/mod.rs | 2 + .../application/session_booking_service.rs | 146 ++++ .../application/session_query_service.rs | 174 ++++ .../sessions/application/session_service.rs | 368 ++------ imphnen-dimentorin/src/sessions/domain/mod.rs | 6 + .../src/sessions/domain/repository.rs | 69 +- .../src/sessions/domain/service.rs | 82 +- .../src/sessions/domain/session.rs | 30 +- .../src/sessions/domain/session_types.rs | 98 +++ .../src/sessions/infrastructure/http/dto.rs | 153 ---- .../sessions/infrastructure/http/dto/mod.rs | 11 + .../infrastructure/http/dto/request.rs | 89 ++ .../infrastructure/http/dto/response.rs | 212 +++++ .../sessions/infrastructure/http/handlers.rs | 177 ---- .../infrastructure/http/handlers/mod.rs | 9 + .../http/handlers/mutation_handlers.rs | 112 +++ .../http/handlers/query_handlers.rs | 94 ++ .../sessions/infrastructure/http/routes.rs | 56 +- .../infrastructure/persistence/mod.rs | 1 + .../persistence/postgres_session_queries.rs | 142 +++ .../postgres_session_repository.rs | 377 ++++---- imphnen-dimentorin/src/sessions/mod.rs | 4 +- imphnen-email/Cargo.toml | 9 + imphnen-email/src/error.rs | 20 + imphnen-email/src/lib.rs | 5 + imphnen-email/src/service.rs | 48 + imphnen-entities/Cargo.toml | 2 +- imphnen-entities/src/audit_log.rs | 6 +- imphnen-entities/src/common_dto.rs | 68 +- imphnen-entities/src/error_dto.rs | 104 +-- imphnen-entities/src/lib.rs | 53 +- imphnen-entities/src/permissions.rs | 273 ------ .../src/permissions/definitions.rs | 143 +++ imphnen-entities/src/permissions/mappings.rs | 186 ++++ imphnen-entities/src/permissions/mod.rs | 4 + imphnen-entities/src/seaorm/auth/mentors.rs | 496 +++++------ .../src/seaorm/auth/mentors_queries.rs | 84 ++ imphnen-entities/src/seaorm/auth/mod.rs | 14 +- .../src/seaorm/auth/permissions.rs | 115 ++- imphnen-entities/src/seaorm/auth/roles.rs | 298 ++++--- .../src/seaorm/auth/roles_permissions.rs | 324 +++---- imphnen-entities/src/seaorm/auth/sessions.rs | 140 +-- imphnen-entities/src/seaorm/auth/users.rs | 358 ++++---- .../src/seaorm/common/audit_log.rs | 30 +- .../src/seaorm/common/enum_impls.rs | 8 + imphnen-entities/src/seaorm/common/enums.rs | 307 +++---- imphnen-entities/src/seaorm/common/events.rs | 100 ++- imphnen-entities/src/seaorm/common/mod.rs | 11 +- .../src/seaorm/common/rate_limit.rs | 16 +- .../src/seaorm/common/testimonials.rs | 100 ++- imphnen-entities/src/seaorm/common/types.rs | 57 +- imphnen-entities/src/seaorm/common/utils.rs | 164 ++-- .../src/seaorm/gacha/gacha_claims.rs | 348 ++++---- .../src/seaorm/gacha/gacha_credits.rs | 68 +- .../src/seaorm/gacha/gacha_items.rs | 403 ++++----- .../src/seaorm/gacha/gacha_items_queries.rs | 70 ++ .../src/seaorm/gacha/gacha_rolls.rs | 100 +-- imphnen-entities/src/seaorm/gacha/mod.rs | 9 +- imphnen-entities/src/seaorm/lib.rs | 132 ++- .../src/seaorm/migration_status.rs | 190 ++-- imphnen-entities/src/seaorm/mod.rs | 12 +- imphnen-entities/src/users.rs | 336 ++++--- imphnen-gacha/Cargo.toml | 2 +- .../application/gacha_claim_service.rs | 30 +- .../src/gacha_claims/domain/gacha_claim.rs | 43 +- .../src/gacha_claims/domain/repository.rs | 10 +- .../src/gacha_claims/domain/service.rs | 10 +- .../gacha_claims/infrastructure/http/dto.rs | 50 +- .../infrastructure/http/handlers.rs | 92 +- .../infrastructure/http/routes.rs | 35 +- .../postgres_gacha_claim_repository.rs | 170 ++-- .../application/gacha_credit_service.rs | 37 +- .../src/gacha_credits/domain/gacha_credit.rs | 12 +- .../src/gacha_credits/domain/repository.rs | 15 +- .../src/gacha_credits/domain/service.rs | 15 +- .../gacha_credits/infrastructure/http/dto.rs | 42 +- .../infrastructure/http/handlers.rs | 143 +-- .../infrastructure/http/routes.rs | 27 +- .../postgres_gacha_credit_repository.rs | 175 ++-- .../application/gacha_item_service.rs | 49 +- .../src/gacha_items/domain/gacha_item.rs | 32 +- .../src/gacha_items/domain/repository.rs | 17 +- .../src/gacha_items/domain/service.rs | 17 +- .../gacha_items/infrastructure/http/dto.rs | 126 +-- .../infrastructure/http/handlers.rs | 164 ++-- .../gacha_items/infrastructure/http/routes.rs | 35 +- .../postgres_gacha_item_repository.rs | 280 +++--- .../application/gacha_roll_service.rs | 220 +++-- .../src/gacha_rolls/domain/gacha_roll.rs | 18 +- .../src/gacha_rolls/domain/repository.rs | 14 +- .../src/gacha_rolls/domain/service.rs | 14 +- .../gacha_rolls/infrastructure/http/dto.rs | 58 +- .../infrastructure/http/handlers.rs | 153 ++-- .../gacha_rolls/infrastructure/http/routes.rs | 49 +- .../postgres_gacha_roll_repository.rs | 148 ++-- imphnen-gacha/src/lib.rs | 59 +- imphnen-gateway/Cargo.toml | 4 +- imphnen-gateway/src/docs.rs | 264 ------ imphnen-gateway/src/docs/mod.rs | 5 + imphnen-gateway/src/docs/openapi.rs | 156 ++++ imphnen-gateway/src/docs/security.rs | 49 ++ imphnen-gateway/src/lib.rs | 135 ++- imphnen-hackathon/Cargo.toml | 4 +- .../src/admin/application/admin_service.rs | 84 ++ .../src/admin/application/mod.rs | 1 + imphnen-hackathon/src/admin/domain/entity.rs | 44 + imphnen-hackathon/src/admin/domain/mod.rs | 3 + .../src/admin/domain/repository.rs | 38 + imphnen-hackathon/src/admin/domain/service.rs | 38 + .../src/admin/infrastructure/http/dto.rs | 40 + .../src/admin/infrastructure/http/handlers.rs | 119 +++ .../src/admin/infrastructure/http/mod.rs | 3 + .../src/admin/infrastructure/http/routes.rs | 40 + .../src/admin/infrastructure/mod.rs | 2 + .../admin/infrastructure/persistence/mod.rs | 2 + .../persistence/postgres_admin_repository.rs | 131 +++ imphnen-hackathon/src/admin/mod.rs | 7 +- imphnen-hackathon/src/admin/routes.rs | 189 ---- .../application/certificate_service.rs | 31 + .../src/certificates/application/mod.rs | 1 + .../src/certificates/domain/entity.rs | 16 + .../src/certificates/domain/mod.rs | 3 + .../src/certificates/domain/repository.rs | 12 + .../src/certificates/domain/service.rs | 12 + .../certificates/infrastructure/http/dto.rs | 37 + .../infrastructure/http/handlers.rs | 14 + .../certificates/infrastructure/http/mod.rs | 3 + .../infrastructure/http/routes.rs | 17 + .../src/certificates/infrastructure/mod.rs | 2 + .../infrastructure/persistence/mod.rs | 2 + .../postgres_certificate_repository.rs | 67 ++ imphnen-hackathon/src/certificates/mod.rs | 7 +- imphnen-hackathon/src/certificates/routes.rs | 44 - .../src/chat/application/chat_service.rs | 136 +-- imphnen-hackathon/src/chat/domain/entity.rs | 32 +- .../src/chat/domain/repository.rs | 49 +- imphnen-hackathon/src/chat/domain/service.rs | 30 +- .../src/chat/infrastructure/http/dto.rs | 52 +- .../src/chat/infrastructure/http/handlers.rs | 48 +- .../src/chat/infrastructure/http/routes.rs | 33 +- .../persistence/postgres_chat_repository.rs | 194 +++-- imphnen-hackathon/src/chat/mod.rs | 2 +- imphnen-hackathon/src/common/cities.rs | 619 ++++++++++--- imphnen-hackathon/src/config.rs | 23 - .../application/invitation_service.rs | 273 +++--- .../src/invitations/domain/entity.rs | 32 +- .../src/invitations/domain/repository.rs | 70 +- .../src/invitations/domain/service.rs | 47 +- .../invitations/infrastructure/http/dto.rs | 58 +- .../infrastructure/http/handlers.rs | 56 +- .../invitations/infrastructure/http/routes.rs | 38 +- .../postgres_invitation_repository.rs | 256 +++--- imphnen-hackathon/src/invitations/mod.rs | 2 +- .../application/join_request_service.rs | 254 +++--- .../src/join_requests/domain/entity.rs | 34 +- .../src/join_requests/domain/repository.rs | 78 +- .../src/join_requests/domain/service.rs | 45 +- .../join_requests/infrastructure/http/dto.rs | 58 +- .../infrastructure/http/handlers.rs | 71 +- .../infrastructure/http/routes.rs | 43 +- .../postgres_join_request_repository.rs | 285 +++--- imphnen-hackathon/src/join_requests/mod.rs | 2 +- imphnen-hackathon/src/lib.rs | 45 +- .../src/middleware/admin_only.rs | 32 +- .../src/middleware/hackathon_auth.rs | 77 +- .../src/storage/application/mod.rs | 1 + .../storage/application/storage_service.rs | 38 + imphnen-hackathon/src/storage/domain/mod.rs | 1 + .../src/storage/domain/service.rs | 15 + .../src/storage/infrastructure/http/dto.rs | 14 + .../storage/infrastructure/http/handlers.rs | 74 ++ .../src/storage/infrastructure/http/mod.rs | 3 + .../src/storage/infrastructure/http/routes.rs | 23 + .../src/storage/infrastructure/mod.rs | 1 + imphnen-hackathon/src/storage/mod.rs | 7 +- imphnen-hackathon/src/storage/routes.rs | 69 -- imphnen-hackathon/src/storage/service.rs | 22 - .../application/submission_service.rs | 219 +++-- .../src/submissions/domain/entity.rs | 52 +- .../src/submissions/domain/repository.rs | 46 +- .../src/submissions/domain/service.rs | 44 +- .../submissions/infrastructure/http/dto.rs | 111 ++- .../infrastructure/http/handlers.rs | 82 +- .../submissions/infrastructure/http/routes.rs | 51 +- .../postgres_submission_repository.rs | 228 +++-- imphnen-hackathon/src/submissions/mod.rs | 2 +- .../src/teams/application/team_service.rs | 437 ++++++---- imphnen-hackathon/src/teams/domain/entity.rs | 132 +-- .../src/teams/domain/repository.rs | 91 +- imphnen-hackathon/src/teams/domain/service.rs | 43 +- .../src/teams/infrastructure/http/dto.rs | 228 +++-- .../src/teams/infrastructure/http/handlers.rs | 118 ++- .../src/teams/infrastructure/http/routes.rs | 52 +- .../teams/infrastructure/persistence/mod.rs | 2 +- .../persistence/postgres_team_queries.rs | 327 ++++--- .../persistence/postgres_team_repository.rs | 375 +++++--- imphnen-hackathon/src/teams/mod.rs | 2 +- .../src/users/application/user_service.rs | 41 +- imphnen-hackathon/src/users/domain/entity.rs | 36 +- .../src/users/domain/repository.rs | 19 +- imphnen-hackathon/src/users/domain/service.rs | 19 +- .../src/users/infrastructure/http/dto.rs | 77 +- .../src/users/infrastructure/http/handlers.rs | 44 +- .../src/users/infrastructure/http/routes.rs | 30 +- .../persistence/postgres_user_repository.rs | 195 +++-- imphnen-hackathon/src/users/mod.rs | 2 +- .../src/winners/application/mod.rs | 1 + .../src/winners/application/winner_service.rs | 23 + .../src/winners/domain/entity.rs | 13 + imphnen-hackathon/src/winners/domain/mod.rs | 3 + .../src/winners/domain/repository.rs | 8 + .../src/winners/domain/service.rs | 8 + .../src/winners/infrastructure/http/dto.rs | 30 + .../winners/infrastructure/http/handlers.rs | 14 + .../src/winners/infrastructure/http/mod.rs | 3 + .../src/winners/infrastructure/http/routes.rs | 17 + .../src/winners/infrastructure/mod.rs | 2 + .../winners/infrastructure/persistence/mod.rs | 2 + .../persistence/postgres_winner_repository.rs | 56 ++ imphnen-hackathon/src/winners/mod.rs | 7 +- imphnen-hackathon/src/winners/routes.rs | 37 - imphnen-iam/Cargo.toml | 4 +- imphnen-iam/src/auth/application/mod.rs | 428 +++++---- imphnen-iam/src/auth/domain/mod.rs | 29 +- imphnen-iam/src/auth/domain/types.rs | 63 ++ .../src/auth/infrastructure/http/dto.rs | 90 +- .../src/auth/infrastructure/http/handlers.rs | 146 +++- .../src/auth/infrastructure/http/routes.rs | 53 +- .../auth/infrastructure/persistence/mod.rs | 2 +- imphnen-iam/src/auth/mod.rs | 4 +- imphnen-iam/src/lib.rs | 99 +-- imphnen-iam/src/permission_macros.rs | 38 +- .../application/permission_service.rs | 78 +- .../src/permissions/domain/permission.rs | 10 +- .../src/permissions/domain/repository.rs | 19 +- imphnen-iam/src/permissions/domain/service.rs | 17 +- .../permissions/infrastructure/http/dto.rs | 66 +- .../infrastructure/http/handlers.rs | 129 +-- .../permissions/infrastructure/http/mod.rs | 2 +- .../permissions/infrastructure/http/routes.rs | 46 +- .../postgres_permission_repository.rs | 255 +++--- imphnen-iam/src/permissions/mod.rs | 6 +- imphnen-iam/src/permissions_guard.rs | 129 +-- .../src/roles/application/role_service.rs | 109 +-- imphnen-iam/src/roles/domain/mod.rs | 4 +- imphnen-iam/src/roles/domain/repository.rs | 24 +- imphnen-iam/src/roles/domain/role.rs | 18 +- imphnen-iam/src/roles/domain/service.rs | 26 +- .../src/roles/infrastructure/http/dto.rs | 136 +-- .../src/roles/infrastructure/http/handlers.rs | 123 +-- .../src/roles/infrastructure/http/mod.rs | 2 +- .../src/roles/infrastructure/http/routes.rs | 44 +- .../persistence/postgres_role_repository.rs | 275 +++--- imphnen-iam/src/roles/mod.rs | 6 +- .../src/users/application/user_service.rs | 158 ++-- imphnen-iam/src/users/domain/mod.rs | 6 +- imphnen-iam/src/users/domain/repository.rs | 36 +- imphnen-iam/src/users/domain/service.rs | 36 +- imphnen-iam/src/users/domain/user.rs | 26 +- .../src/users/infrastructure/http/dto.rs | 196 ++--- .../src/users/infrastructure/http/handlers.rs | 418 --------- .../http/handlers/get_handlers.rs | 106 +++ .../users/infrastructure/http/handlers/mod.rs | 9 + .../http/handlers/mutation_handlers.rs | 159 ++++ .../http/handlers/profile_handlers.rs | 216 +++++ .../src/users/infrastructure/http/mod.rs | 2 +- .../src/users/infrastructure/http/routes.rs | 55 +- .../users/infrastructure/persistence/mod.rs | 1 + .../persistence/postgres_user_queries.rs | 152 ++++ .../persistence/postgres_user_repository.rs | 456 ++++------ imphnen-iam/src/users/mod.rs | 6 +- imphnen-iam/src/v2/.gitkeep | 0 imphnen-libs/Cargo.toml | 8 +- imphnen-libs/src/argon/mod.rs | 97 +-- imphnen-libs/src/axum/app_state.rs | 59 ++ imphnen-libs/src/axum/mod.rs | 542 +++++------- imphnen-libs/src/axum/validated_json.rs | 84 +- imphnen-libs/src/axum/zod_validate.rs | 2 +- imphnen-libs/src/environment/mod.rs | 454 +++++----- imphnen-libs/src/jsonwebtoken/mod.rs | 273 +++--- imphnen-libs/src/lettre/mod.rs | 120 --- imphnen-libs/src/lib.rs | 93 +- imphnen-libs/src/minio.rs | 697 --------------- imphnen-libs/src/postgres.rs | 292 ------- imphnen-libs/src/postgres/connection.rs | 164 ++++ imphnen-libs/src/postgres/examples.rs | 171 ---- imphnen-libs/src/postgres/helpers.rs | 97 +++ imphnen-libs/src/postgres/mod.rs | 5 + imphnen-libs/src/services.rs | 819 ------------------ imphnen-libs/src/services/auth_repository.rs | 336 +++++++ imphnen-libs/src/services/dto.rs | 95 ++ imphnen-libs/src/services/error.rs | 26 + imphnen-libs/src/services/mod.rs | 11 + imphnen-libs/src/services/user_lookup.rs | 257 ++++++ imphnen-macros/Cargo.toml | 2 +- imphnen-macros/src/lib.rs | 194 +++-- imphnen-middleware/Cargo.toml | 2 +- .../src/audit_logging_middleware/mod.rs | 374 ++++---- imphnen-middleware/src/auth_middleware/mod.rs | 139 +-- imphnen-middleware/src/cors_middleware/mod.rs | 60 +- imphnen-middleware/src/lib.rs | 31 +- .../src/payment_middleware/mod.rs | 196 ++--- .../src/permissions_middleware/mod.rs | 401 +++++---- .../src/rate_limiting_middleware/mod.rs | 360 ++++---- .../src/security_headers_middleware/mod.rs | 228 +++-- imphnen-qr/Cargo.toml | 20 - .../campaigns/application/campaign_service.rs | 88 -- imphnen-qr/src/campaigns/domain/entity.rs | 24 - imphnen-qr/src/campaigns/domain/repository.rs | 14 - imphnen-qr/src/campaigns/domain/service.rs | 15 - .../src/campaigns/infrastructure/http/dto.rs | 22 - .../campaigns/infrastructure/http/handlers.rs | 85 -- .../campaigns/infrastructure/http/routes.rs | 36 - .../postgres_campaign_repository.rs | 107 --- imphnen-qr/src/common/mod.rs | 1 - imphnen-qr/src/middleware/qr_auth.rs | 55 -- .../src/users/application/user_service.rs | 51 -- imphnen-qr/src/users/domain/entity.rs | 21 - imphnen-qr/src/users/domain/repository.rs | 14 - imphnen-qr/src/users/domain/service.rs | 14 - .../src/users/infrastructure/http/handlers.rs | 73 -- .../src/users/infrastructure/http/routes.rs | 36 - .../persistence/postgres_user_repository.rs | 74 -- imphnen-storage/Cargo.toml | 17 + imphnen-storage/src/config.rs | 35 + imphnen-storage/src/helpers.rs | 38 + imphnen-storage/src/lib.rs | 13 + imphnen-storage/src/service.rs | 253 ++++++ imphnen-storage/src/signing.rs | 115 +++ imphnen-storage/src/types.rs | 157 ++++ imphnen-utils/Cargo.toml | 2 +- imphnen-utils/src/csrf_token.rs | 410 ++++----- imphnen-utils/src/errors.rs | 238 ++--- imphnen-utils/src/extract_email.rs | 291 +++---- imphnen-utils/src/extract_ip.rs | 280 +++--- imphnen-utils/src/generate_date.rs | 57 +- imphnen-utils/src/generate_otp.rs | 167 ++-- imphnen-utils/src/lib.rs | 38 +- imphnen-utils/src/logger.rs | 30 +- imphnen-utils/src/pagination.rs | 4 +- imphnen-utils/src/response_format.rs | 135 +-- imphnen-utils/src/sanitization.rs | 299 ++----- 442 files changed, 22226 insertions(+), 18700 deletions(-) create mode 100644 imphnen-cms/src/qr/campaigns/application/campaign_service.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/application/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/campaigns/domain/entity.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/domain/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/campaigns/domain/repository.rs create mode 100644 imphnen-cms/src/qr/campaigns/domain/service.rs create mode 100644 imphnen-cms/src/qr/campaigns/infrastructure/http/dto.rs create mode 100644 imphnen-cms/src/qr/campaigns/infrastructure/http/handlers.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/infrastructure/http/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/campaigns/infrastructure/http/routes.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/infrastructure/mod.rs (100%) rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/infrastructure/persistence/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/campaigns/infrastructure/persistence/postgres_campaign_repository.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/campaigns/mod.rs (100%) rename {imphnen-qr/src => imphnen-cms/src/qr}/middleware/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/middleware/qr_auth.rs rename imphnen-qr/src/lib.rs => imphnen-cms/src/qr/mod.rs (64%) rename {imphnen-qr/src => imphnen-cms/src/qr}/users/application/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/users/application/user_service.rs create mode 100644 imphnen-cms/src/qr/users/domain/entity.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/users/domain/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/users/domain/repository.rs create mode 100644 imphnen-cms/src/qr/users/domain/service.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/users/infrastructure/http/dto.rs (58%) create mode 100644 imphnen-cms/src/qr/users/infrastructure/http/handlers.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/users/infrastructure/http/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/users/infrastructure/http/routes.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/users/infrastructure/mod.rs (100%) rename {imphnen-qr/src => imphnen-cms/src/qr}/users/infrastructure/persistence/mod.rs (100%) create mode 100644 imphnen-cms/src/qr/users/infrastructure/persistence/postgres_user_repository.rs rename {imphnen-qr/src => imphnen-cms/src/qr}/users/mod.rs (100%) create mode 100644 imphnen-dimentorin/src/mentors/application/mentor_query_service.rs create mode 100644 imphnen-dimentorin/src/mentors/application/mentor_registration_service.rs create mode 100644 imphnen-dimentorin/src/mentors/application/mentor_update_service.rs create mode 100644 imphnen-dimentorin/src/mentors/domain/mentor_types.rs delete mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/dto.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/dto/mod.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/dto/nested.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/dto/request.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/dto/response.rs delete mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/handlers.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mod.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mutation_handlers.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/http/handlers/query_handlers.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_queries.rs create mode 100644 imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_write.rs create mode 100644 imphnen-dimentorin/src/sessions/application/session_booking_service.rs create mode 100644 imphnen-dimentorin/src/sessions/application/session_query_service.rs create mode 100644 imphnen-dimentorin/src/sessions/domain/session_types.rs delete mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/dto.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/dto/request.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs delete mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/handlers.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs create mode 100644 imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_queries.rs create mode 100644 imphnen-email/Cargo.toml create mode 100644 imphnen-email/src/error.rs create mode 100644 imphnen-email/src/lib.rs create mode 100644 imphnen-email/src/service.rs delete mode 100644 imphnen-entities/src/permissions.rs create mode 100644 imphnen-entities/src/permissions/definitions.rs create mode 100644 imphnen-entities/src/permissions/mappings.rs create mode 100644 imphnen-entities/src/permissions/mod.rs create mode 100644 imphnen-entities/src/seaorm/auth/mentors_queries.rs create mode 100644 imphnen-entities/src/seaorm/common/enum_impls.rs create mode 100644 imphnen-entities/src/seaorm/gacha/gacha_items_queries.rs delete mode 100644 imphnen-gateway/src/docs.rs create mode 100644 imphnen-gateway/src/docs/mod.rs create mode 100644 imphnen-gateway/src/docs/openapi.rs create mode 100644 imphnen-gateway/src/docs/security.rs create mode 100644 imphnen-hackathon/src/admin/application/admin_service.rs create mode 100644 imphnen-hackathon/src/admin/application/mod.rs create mode 100644 imphnen-hackathon/src/admin/domain/entity.rs create mode 100644 imphnen-hackathon/src/admin/domain/mod.rs create mode 100644 imphnen-hackathon/src/admin/domain/repository.rs create mode 100644 imphnen-hackathon/src/admin/domain/service.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/http/dto.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/http/handlers.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/http/mod.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/http/routes.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/mod.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/persistence/mod.rs create mode 100644 imphnen-hackathon/src/admin/infrastructure/persistence/postgres_admin_repository.rs delete mode 100644 imphnen-hackathon/src/admin/routes.rs create mode 100644 imphnen-hackathon/src/certificates/application/certificate_service.rs create mode 100644 imphnen-hackathon/src/certificates/application/mod.rs create mode 100644 imphnen-hackathon/src/certificates/domain/entity.rs create mode 100644 imphnen-hackathon/src/certificates/domain/mod.rs create mode 100644 imphnen-hackathon/src/certificates/domain/repository.rs create mode 100644 imphnen-hackathon/src/certificates/domain/service.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/http/dto.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/http/handlers.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/http/mod.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/http/routes.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/mod.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/persistence/mod.rs create mode 100644 imphnen-hackathon/src/certificates/infrastructure/persistence/postgres_certificate_repository.rs delete mode 100644 imphnen-hackathon/src/certificates/routes.rs delete mode 100644 imphnen-hackathon/src/config.rs create mode 100644 imphnen-hackathon/src/storage/application/mod.rs create mode 100644 imphnen-hackathon/src/storage/application/storage_service.rs create mode 100644 imphnen-hackathon/src/storage/domain/mod.rs create mode 100644 imphnen-hackathon/src/storage/domain/service.rs create mode 100644 imphnen-hackathon/src/storage/infrastructure/http/dto.rs create mode 100644 imphnen-hackathon/src/storage/infrastructure/http/handlers.rs create mode 100644 imphnen-hackathon/src/storage/infrastructure/http/mod.rs create mode 100644 imphnen-hackathon/src/storage/infrastructure/http/routes.rs create mode 100644 imphnen-hackathon/src/storage/infrastructure/mod.rs delete mode 100644 imphnen-hackathon/src/storage/routes.rs delete mode 100644 imphnen-hackathon/src/storage/service.rs create mode 100644 imphnen-hackathon/src/winners/application/mod.rs create mode 100644 imphnen-hackathon/src/winners/application/winner_service.rs create mode 100644 imphnen-hackathon/src/winners/domain/entity.rs create mode 100644 imphnen-hackathon/src/winners/domain/mod.rs create mode 100644 imphnen-hackathon/src/winners/domain/repository.rs create mode 100644 imphnen-hackathon/src/winners/domain/service.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/http/dto.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/http/handlers.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/http/mod.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/http/routes.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/mod.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/persistence/mod.rs create mode 100644 imphnen-hackathon/src/winners/infrastructure/persistence/postgres_winner_repository.rs delete mode 100644 imphnen-hackathon/src/winners/routes.rs create mode 100644 imphnen-iam/src/auth/domain/types.rs delete mode 100644 imphnen-iam/src/users/infrastructure/http/handlers.rs create mode 100644 imphnen-iam/src/users/infrastructure/http/handlers/get_handlers.rs create mode 100644 imphnen-iam/src/users/infrastructure/http/handlers/mod.rs create mode 100644 imphnen-iam/src/users/infrastructure/http/handlers/mutation_handlers.rs create mode 100644 imphnen-iam/src/users/infrastructure/http/handlers/profile_handlers.rs create mode 100644 imphnen-iam/src/users/infrastructure/persistence/postgres_user_queries.rs delete mode 100644 imphnen-iam/src/v2/.gitkeep create mode 100644 imphnen-libs/src/axum/app_state.rs delete mode 100644 imphnen-libs/src/lettre/mod.rs delete mode 100644 imphnen-libs/src/minio.rs delete mode 100644 imphnen-libs/src/postgres.rs create mode 100644 imphnen-libs/src/postgres/connection.rs delete mode 100644 imphnen-libs/src/postgres/examples.rs create mode 100644 imphnen-libs/src/postgres/helpers.rs create mode 100644 imphnen-libs/src/postgres/mod.rs delete mode 100644 imphnen-libs/src/services.rs create mode 100644 imphnen-libs/src/services/auth_repository.rs create mode 100644 imphnen-libs/src/services/dto.rs create mode 100644 imphnen-libs/src/services/error.rs create mode 100644 imphnen-libs/src/services/mod.rs create mode 100644 imphnen-libs/src/services/user_lookup.rs delete mode 100644 imphnen-qr/Cargo.toml delete mode 100644 imphnen-qr/src/campaigns/application/campaign_service.rs delete mode 100644 imphnen-qr/src/campaigns/domain/entity.rs delete mode 100644 imphnen-qr/src/campaigns/domain/repository.rs delete mode 100644 imphnen-qr/src/campaigns/domain/service.rs delete mode 100644 imphnen-qr/src/campaigns/infrastructure/http/dto.rs delete mode 100644 imphnen-qr/src/campaigns/infrastructure/http/handlers.rs delete mode 100644 imphnen-qr/src/campaigns/infrastructure/http/routes.rs delete mode 100644 imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs delete mode 100644 imphnen-qr/src/common/mod.rs delete mode 100644 imphnen-qr/src/middleware/qr_auth.rs delete mode 100644 imphnen-qr/src/users/application/user_service.rs delete mode 100644 imphnen-qr/src/users/domain/entity.rs delete mode 100644 imphnen-qr/src/users/domain/repository.rs delete mode 100644 imphnen-qr/src/users/domain/service.rs delete mode 100644 imphnen-qr/src/users/infrastructure/http/handlers.rs delete mode 100644 imphnen-qr/src/users/infrastructure/http/routes.rs delete mode 100644 imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs create mode 100644 imphnen-storage/Cargo.toml create mode 100644 imphnen-storage/src/config.rs create mode 100644 imphnen-storage/src/helpers.rs create mode 100644 imphnen-storage/src/lib.rs create mode 100644 imphnen-storage/src/service.rs create mode 100644 imphnen-storage/src/signing.rs create mode 100644 imphnen-storage/src/types.rs diff --git a/.env.example b/.env.example index 78ff467..18f743a 100644 --- a/.env.example +++ b/.env.example @@ -1,59 +1,33 @@ -RUST_ENV=development -RUST_LOG=debug -PORT=4099 -SURREALDB_URL=ws://localhost:8000/rpc -SURREALDB_USERNAME=root -SURREALDB_PASSWORD=root -SURREALDB_NAMESPACE=test -SURREALDB_DBNAME=test -ACCESS_TOKEN_SECRET=your-access-token-secret-key-here -REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here -SMTP_EMAIL=your-email@example.com -SMTP_PASSWORD=your-smtp-password -SMTP_NAME="Your App Name" -SMTP_HOST=smtp.gmail.com -REDISDB_URL=localhost -FE_URL=http://localhost -MINIO_ENDPOINT=http://localhost:9000 -MINIO_BUCKET_NAME=default_bucket -MINIO_ACCESS_KEY=minioadmin -MINIO_SECRET_KEY=minioadmin -MINIO_SECURE=false - -GOOGLE_CLIENT_ID="your_google_client_id" -GOOGLE_CLIENT_SECRET="your_google_client_secret" -POOL_SIZE=10 -CONNECT_TIMEOUT=30 -IDLE_TIMEOUT=60 -MAX_LIFETIME=1800 -STATEMENT_TIMEOUT=30000 -IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000 -SSLMODE=require -RETRY_ATTEMPTS=3 -RETRY_DELAY=1 -GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback - -# QR campaign service -QR_DATABASE_URL=postgres://imphnen_qr@127.0.0.1:5432/imphnen_qr?sslmode=disable -QR_JWT_SECRET=your-qr-jwt-secret-at-least-32-chars -QR_JWT_EXPIRY_MINUTES=15 -QR_JWT_REFRESH_EXPIRY_DAYS=7 -QR_GOOGLE_CLIENT_ID=your-google-client-id -QR_GOOGLE_CLIENT_SECRET=your-google-client-secret -QR_GOOGLE_REDIRECT_URL=http://localhost:8080/v1/qr/auth/google/callback - -# Hackathon feature -HACKATHON_JWT_SECRET=your-hackathon-jwt-secret-at-least-32-chars -HACKATHON_JWT_EXPIRY_HOURS=168 -HACKATHON_SUPABASE_URL=https://your-project.supabase.co -HACKATHON_SUPABASE_ANON_KEY=your-supabase-anon-key -HACKATHON_SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key -HACKATHON_STORAGE_BUCKET=hackathon-uploads -HACKATHON_GITHUB_CLIENT_ID=your-github-client-id -HACKATHON_GITHUB_CLIENT_SECRET=your-github-client-secret -HACKATHON_GITHUB_REDIRECT_URL=http://localhost:8080/v1/hackathon/auth/github/callback -HACKATHON_SMTP_HOST=smtp.gmail.com -HACKATHON_SMTP_USER=your-email@gmail.com -HACKATHON_SMTP_PASSWORD=your-smtp-password -HACKATHON_FROM_EMAIL=noreply@yourdomain.com -HACKATHON_FRONTEND_URL=https://hackathon.imphnen.dev +RUST_ENV=development +RUST_LOG=debug +PORT=4099 +ACCESS_TOKEN_SECRET=your-access-token-secret-key-here +REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here +SMTP_EMAIL=your-email@example.com +SMTP_PASSWORD=your-smtp-password +SMTP_NAME="Your App Name" +SMTP_HOST=smtp.gmail.com +REDISDB_URL=localhost +FE_URL=http://localhost +MINIO_ENDPOINT=http://localhost:9000 +MINIO_BUCKET_NAME=default_bucket +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_SECURE=false + +GOOGLE_CLIENT_ID="your_google_client_id" +GOOGLE_CLIENT_SECRET="your_google_client_secret" +POOL_SIZE=10 +CONNECT_TIMEOUT=30 +IDLE_TIMEOUT=60 +MAX_LIFETIME=1800 +STATEMENT_TIMEOUT=30000 +IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000 +SSLMODE=require +RETRY_ATTEMPTS=3 +RETRY_DELAY=1 +GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback + +CDN_URL=https://cdn.asepharyana.tech +CORS_ALLOWED_ORIGINS=http://localhost:3000,https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev + diff --git a/Cargo.lock b/Cargo.lock index 80cbfe1..b18be08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1720,7 +1720,7 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "imphnen-backend" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -1752,13 +1752,14 @@ dependencies = [ [[package]] name = "imphnen-cms" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", "axum", "axum-test", "chrono", + "image", "imphnen-entities", "imphnen-iam", "imphnen-libs", @@ -1769,11 +1770,13 @@ dependencies = [ "paginator-rs", "paginator-sea-orm", "paginator-utils", + "qrcode", "rand 0.9.2", "regex", "sea-orm", "serde", "serde_json", + "sqlx", "tokio", "tower-http", "tracing", @@ -1786,7 +1789,7 @@ dependencies = [ [[package]] name = "imphnen-dimentorin" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", @@ -1820,9 +1823,18 @@ dependencies = [ "zod-rs-util", ] +[[package]] +name = "imphnen-email" +version = "0.3.0" +dependencies = [ + "imphnen-libs", + "lettre", + "tracing", +] + [[package]] name = "imphnen-entities" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -1840,7 +1852,7 @@ dependencies = [ [[package]] name = "imphnen-gacha" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", @@ -1875,7 +1887,7 @@ dependencies = [ [[package]] name = "imphnen-gateway" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -1889,7 +1901,7 @@ dependencies = [ "imphnen-iam", "imphnen-libs", "imphnen-middleware", - "imphnen-qr", + "imphnen-storage", "imphnen-utils", "lazy_static", "rand 0.9.2", @@ -1905,15 +1917,15 @@ dependencies = [ [[package]] name = "imphnen-hackathon" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "axum", "base64", "chrono", "imphnen-libs", + "imphnen-storage", "imphnen-utils", - "lettre", "reqwest", "sea-orm", "serde", @@ -1928,7 +1940,7 @@ dependencies = [ [[package]] name = "imphnen-iam" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", @@ -1938,8 +1950,10 @@ dependencies = [ "chrono", "dotenvy", "http-body-util", + "imphnen-email", "imphnen-entities", "imphnen-libs", + "imphnen-storage", "imphnen-utils", "lazy_static", "log", @@ -1971,21 +1985,17 @@ dependencies = [ [[package]] name = "imphnen-libs" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "argon2", "async-trait", "axum", - "base64", "chrono", "dotenvy", "env_logger", - "hex", - "hmac", "imphnen-entities", "jsonwebtoken", - "lettre", "log", "num_cpus", "once_cell", @@ -1993,19 +2003,17 @@ dependencies = [ "sea-orm", "serde", "serde_json", - "sha2", "thiserror 2.0.17", "tokio", "tracing", "tracing-subscriber", - "urlencoding", "uuid", "zod-rs", ] [[package]] name = "imphnen-macros" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -2014,7 +2022,7 @@ dependencies = [ [[package]] name = "imphnen-middleware" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -2042,28 +2050,25 @@ dependencies = [ ] [[package]] -name = "imphnen-qr" -version = "0.2.0" +name = "imphnen-storage" +version = "0.3.0" dependencies = [ - "async-trait", - "axum", + "anyhow", + "base64", "chrono", - "image", + "hex", + "hmac", "imphnen-libs", - "imphnen-utils", - "qrcode", - "serde", - "serde_json", - "sqlx", - "tokio", + "reqwest", + "sha2", "tracing", - "utoipa", + "urlencoding", "uuid", ] [[package]] name = "imphnen-utils" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index a28ff7a..44941c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,20 @@ [workspace] resolver = "2" members = [ - "imphnen-entities", # Most basic - core data structures - "imphnen-macros", # Macros - "imphnen-libs", # Depends on entities - "imphnen-utils", # Depends on libs and entities - "imphnen-middleware",# Utility for permissions - "imphnen-iam", # Core auth service, depends on libs, utils, entities - "imphnen-cms", # Content management, depends on core services - "imphnen-gacha", # Game mechanics, depends on core services - "imphnen-dimentorin",# Learning platform, depends on core services - "imphnen-hackathon", # Hackathon feature, standalone with Supabase auth - "imphnen-qr", # QR campaign overlay service - "imphnen-gateway", # API gateway, depends on all services - "imphnen-backend", # Main application, depends on all services + "imphnen-entities", + "imphnen-macros", + "imphnen-libs", + "imphnen-storage", + "imphnen-email", + "imphnen-utils", + "imphnen-middleware", + "imphnen-iam", + "imphnen-cms", + "imphnen-gacha", + "imphnen-dimentorin", + "imphnen-hackathon", + "imphnen-gateway", + "imphnen-backend", ] @@ -90,7 +91,8 @@ imphnen-dimentorin = { path = "./imphnen-dimentorin" } imphnen-middleware = { path = "./imphnen-middleware" } imphnen-macros = { path = "./imphnen-macros" } imphnen-hackathon = { path = "./imphnen-hackathon" } -imphnen-qr = { path = "./imphnen-qr" } +imphnen-storage = { path = "./imphnen-storage" } +imphnen-email = { path = "./imphnen-email" } bcrypt = "0.15" image = { version = "0.25", features = ["png", "jpeg"] } qrcode = { version = "0.14", default-features = false, features = ["image"] } diff --git a/Dockerfile b/Dockerfile index 38598c1..da357a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,55 @@ -FROM rust:1.86-alpine AS builder - -RUN apk add --no-cache \ - curl \ - musl-dev \ - openssl-dev \ - openssl-libs-static \ - pkgconfig - -WORKDIR /app - -COPY Cargo.toml Cargo.lock ./ - -RUN mkdir -p imphnen-backend/src imphnen-cms/src imphnen-dimentorin/src \ - imphnen-entities/src imphnen-gacha/src imphnen-gateway/src \ - imphnen-iam/src imphnen-libs/src imphnen-middleware/src \ - imphnen-utils/src tests/src && \ - echo "fn main() {}" > imphnen-backend/src/main.rs && \ - find . -name "src" -type d -exec sh -c 'echo "// dummy" > "$1/lib.rs"' _ {} \; - -RUN echo '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml - - -RUN echo -e '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml - - -COPY imphnen-backend ./imphnen-backend -COPY imphnen-cms ./imphnen-cms -COPY imphnen-dimentorin ./imphnen-dimentorin -COPY imphnen-entities ./imphnen-entities -COPY imphnen-gacha ./imphnen-gacha -COPY imphnen-gateway ./imphnen-gateway -COPY imphnen-iam ./imphnen-iam -COPY imphnen-libs ./imphnen-libs -COPY imphnen-middleware ./imphnen-middleware -COPY imphnen-utils ./imphnen-utils -COPY tests ./tests - -RUN RUSTFLAGS="-C target-cpu=generic -C opt-level=s -C panic=abort -C codegen-units=1 -C strip=symbols" \ - cargo build -p imphnen-backend --release && \ - strip target/release/api && \ - upx --best --lzma target/release/api 2>/dev/null || true - -FROM scratch AS runner -COPY --from=builder /app/target/release/api /api -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -ENTRYPOINT ["/api"] \ No newline at end of file +FROM rust:1.86-alpine AS builder + +RUN apk add --no-cache \ + curl \ + musl-dev \ + openssl-dev \ + openssl-libs-static \ + pkgconfig + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ + +RUN mkdir -p \ + imphnen-backend/src \ + imphnen-cms/src \ + imphnen-dimentorin/src \ + imphnen-email/src \ + imphnen-entities/src \ + imphnen-gacha/src \ + imphnen-gateway/src \ + imphnen-hackathon/src \ + imphnen-iam/src \ + imphnen-libs/src \ + imphnen-macros/src \ + imphnen-middleware/src \ + imphnen-storage/src \ + imphnen-utils/src && \ + echo "fn main() {}" > imphnen-backend/src/main.rs && \ + find . -name "src" -type d -exec sh -c 'touch "$1/lib.rs"' _ {} \; + +COPY imphnen-backend ./imphnen-backend +COPY imphnen-cms ./imphnen-cms +COPY imphnen-dimentorin ./imphnen-dimentorin +COPY imphnen-email ./imphnen-email +COPY imphnen-entities ./imphnen-entities +COPY imphnen-gacha ./imphnen-gacha +COPY imphnen-gateway ./imphnen-gateway +COPY imphnen-hackathon ./imphnen-hackathon +COPY imphnen-iam ./imphnen-iam +COPY imphnen-libs ./imphnen-libs +COPY imphnen-macros ./imphnen-macros +COPY imphnen-middleware ./imphnen-middleware +COPY imphnen-storage ./imphnen-storage +COPY imphnen-utils ./imphnen-utils + +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"] diff --git a/imphnen-backend/Cargo.toml b/imphnen-backend/Cargo.toml index 750966b..b126582 100644 --- a/imphnen-backend/Cargo.toml +++ b/imphnen-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-backend" -version = "0.2.0" +version = "0.3.0" edition = "2021" [[bin]] diff --git a/imphnen-backend/src/bin/api.rs b/imphnen-backend/src/bin/api.rs index fd4e36b..35bfb93 100644 --- a/imphnen-backend/src/bin/api.rs +++ b/imphnen-backend/src/bin/api.rs @@ -1,14 +1,10 @@ -// API entry point using PostgreSQL (SurrealDB migration complete) -// This file has been updated to use SeaORM with PostgreSQL instead of SurrealDB -use imphnen_gateway::gateway_service; -use imphnen_libs::axum_init; - -#[tokio::main] -async fn main() { - axum_init(|postgres_db| async { - // Gateway service now uses PostgreSQL exclusively (SeaORM) - // SurrealDB dependencies have been completely removed - gateway_service(postgres_db).await - }) - .await; -} +use imphnen_gateway::gateway_service; +use imphnen_libs::axum_init; + +#[tokio::main] +async fn main() { + axum_init(|postgres_db| async { + gateway_service(postgres_db).await + }) + .await; +} diff --git a/imphnen-backend/src/bin/clear_db.rs b/imphnen-backend/src/bin/clear_db.rs index 6ec6319..9e05a6e 100644 --- a/imphnen-backend/src/bin/clear_db.rs +++ b/imphnen-backend/src/bin/clear_db.rs @@ -1,93 +1,91 @@ -#![allow(clippy::all)] - -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use sea_orm::{Statement, ConnectionTrait}; -use std::error::Error; -use std::env; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let args: Vec = env::args().collect(); - // New default behavior: execute by default; use --dry-run to preview only. - let dry_run = args.iter().any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry"); - let force = args.iter().any(|s| s == "--force" || s == "-f"); - - println!("🔎 Clear DB script - WARNING: This will remove data from tables\n"); - println!("Note: script now runs by default (no --yes required). To preview without executing, use --dry-run.\n"); - - // List of tables to truncate (order doesn't matter with CASCADE) - let tables = vec![ - "gacha_claims", - "gacha_rolls", - "gacha_items", - "gacha_credits", - "audit_logs", - "rate_limits", - "testimonials", - "events", - "app_mentors", - "app_sessions", - "app_roles_permissions", - "app_permissions", - "app_roles", - "app_users", - ]; - - let postgres_config = PostgresConfig::from_env()?; - let pg_conn = PostgresConnection::new(postgres_config).await?; - let db = &pg_conn.conn; - - // Filter tables that actually exist in the database - let mut existing_tables: Vec<&str> = vec![]; - for t in tables.iter() { - let check_sql = format!( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;", - t - ); - let stmt = Statement::from_string(db.get_database_backend(), check_sql); - if let Ok(Some(row)) = pg_conn.query_one(stmt).await { - let exists_val: Option = row.try_get("", "exists").ok(); - if exists_val.unwrap_or(false) { - existing_tables.push(t); - } - } - } - - if existing_tables.is_empty() { - println!("No configured tables found to clear - nothing to do."); - return Ok(()); - } - - let truncate_sql = format!( - "TRUNCATE TABLE {} RESTART IDENTITY CASCADE;", - existing_tables.join(", ") - ); - - println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql); - - // Prevent accidental execution in production without explicit force flag - let env_name = std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()); - if env_name == "production" && !force { - println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override."); - return Ok(()); - } - - if dry_run { - println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production)."); - return Ok(()); - } - - println!("Executing truncate...\n"); - - let postgres_config = PostgresConfig::from_env()?; - let pg_conn = PostgresConnection::new(postgres_config).await?; - let db = &pg_conn.conn; - - let stmt = Statement::from_string(db.get_database_backend(), truncate_sql); - match pg_conn.execute(stmt).await { - Ok(_) => println!("✅ Successfully cleared DB tables"), - Err(e) => println!("❌ Failed to clear DB tables: {}", e), - } - - Ok(()) -} +#![allow(clippy::all)] + +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; +use sea_orm::{ConnectionTrait, Statement}; +use std::env; +use std::error::Error; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + let dry_run = args + .iter() + .any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry"); + let force = args.iter().any(|s| s == "--force" || s == "-f"); + + println!("🔎 Clear DB script - WARNING: This will remove data from tables\n"); + println!("Note: script now runs by default (no --yes required). To preview without executing, use --dry-run.\n"); + + let tables = vec![ + "gacha_claims", + "gacha_rolls", + "gacha_items", + "gacha_credits", + "audit_logs", + "rate_limits", + "testimonials", + "events", + "app_mentors", + "app_sessions", + "app_roles_permissions", + "app_permissions", + "app_roles", + "app_users", + ]; + + let postgres_config = PostgresConfig::from_env()?; + let pg_conn = PostgresConnection::new(postgres_config).await?; + let db = &pg_conn.conn; + + let mut existing_tables: Vec<&str> = vec![]; + for t in tables.iter() { + let check_sql = format!( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;", + t + ); + let stmt = Statement::from_string(db.get_database_backend(), check_sql); + if let Ok(Some(row)) = pg_conn.query_one(stmt).await { + let exists_val: Option = row.try_get("", "exists").ok(); + if exists_val.unwrap_or(false) { + existing_tables.push(t); + } + } + } + + if existing_tables.is_empty() { + println!("No configured tables found to clear - nothing to do."); + return Ok(()); + } + + let truncate_sql = format!( + "TRUNCATE TABLE {} RESTART IDENTITY CASCADE;", + existing_tables.join(", ") + ); + + println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql); + + let env_name = imphnen_libs::ENV.rust_env.clone(); + if env_name == "production" && !force { + println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override."); + return Ok(()); + } + + if dry_run { + println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production)."); + return Ok(()); + } + + println!("Executing truncate...\n"); + + let postgres_config = PostgresConfig::from_env()?; + let pg_conn = PostgresConnection::new(postgres_config).await?; + let db = &pg_conn.conn; + + let stmt = Statement::from_string(db.get_database_backend(), truncate_sql); + match pg_conn.execute(stmt).await { + Ok(_) => println!("✅ Successfully cleared DB tables"), + Err(e) => println!("❌ Failed to clear DB tables: {}", e), + } + + Ok(()) +} diff --git a/imphnen-backend/src/bin/create_schema.rs b/imphnen-backend/src/bin/create_schema.rs index e92b697..0df5e49 100644 --- a/imphnen-backend/src/bin/create_schema.rs +++ b/imphnen-backend/src/bin/create_schema.rs @@ -1,65 +1,76 @@ #![allow(clippy::all)] -use sea_orm::{ConnectionTrait, Database, Schema, DbBackend, EntityTrait}; -use imphnen_libs::postgres::PostgresConfig; use imphnen_entities::seaorm::{auth, common, gacha}; -use sea_orm::sea_query::Table; +use imphnen_libs::postgres::PostgresConfig; +use sea_orm::sea_query::Table; +use sea_orm::{ConnectionTrait, Database, DbBackend, EntityTrait, Schema}; #[tokio::main] async fn main() -> Result<(), Box> { - println!("🛠️ Creating database schema..."); - - let config = PostgresConfig::from_env()?; - let db = Database::connect(&config.database_url).await?; - let builder = db.get_database_backend(); + println!("🛠️ Creating database schema..."); - println!(" Database connected. Creating/updating tables..."); + let config = PostgresConfig::from_env()?; + let db = Database::connect(&config.database_url).await?; + let builder = db.get_database_backend(); - // Dropping and recreating tables to ensure schema is up-to-date - // This is safer for development/testing environments to prevent schema drift. - drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?; - drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity).await?; - drop_and_create_table(&db, builder, "app_users", auth::users::Entity).await?; - drop_and_create_table(&db, builder, "app_roles_permissions", auth::roles_permissions::Entity).await?; - drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?; - drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity).await?; - - drop_and_create_table(&db, builder, "events", common::events::Entity).await?; - drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity).await?; - drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity).await?; - drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity).await?; - - drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity).await?; - drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity).await?; - drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity).await?; - drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity).await?; + println!(" Database connected. Creating/updating tables..."); - println!("✅ Schema creation completed."); - Ok(()) + drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?; + drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity) + .await?; + drop_and_create_table(&db, builder, "app_users", auth::users::Entity).await?; + drop_and_create_table( + &db, + builder, + "app_roles_permissions", + auth::roles_permissions::Entity, + ) + .await?; + drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?; + drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity) + .await?; + + drop_and_create_table(&db, builder, "events", common::events::Entity).await?; + drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity) + .await?; + drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity) + .await?; + drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity) + .await?; + + drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity) + .await?; + drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity) + .await?; + drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity) + .await?; + drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity) + .await?; + + println!("✅ Schema creation completed."); + Ok(()) } async fn drop_and_create_table( - db: &sea_orm::DatabaseConnection, - builder: DbBackend, - name: &str, - entity: E, -) -> Result<(), Box> // Return Result + db: &sea_orm::DatabaseConnection, + builder: DbBackend, + name: &str, + entity: E, +) -> Result<(), Box> where - E: EntityTrait, + E: EntityTrait, { - let schema = Schema::new(builder); + let schema = Schema::new(builder); - // Drop table if it exists - let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned(); // Added .cascade() - db.execute(builder.build(&drop_stmt)).await?; // Propagate error - println!(" Dropped table if exists: {}", name); + let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned(); + db.execute(builder.build(&drop_stmt)).await?; + println!(" Dropped table if exists: {}", name); - // Create table - let mut create_stmt = schema.create_table_from_entity(entity); - create_stmt.if_not_exists(); + let mut create_stmt = schema.create_table_from_entity(entity); + create_stmt.if_not_exists(); - db.execute(builder.build(&create_stmt)).await?; // Propagate error - println!(" ✅ Created table: {}", name); + db.execute(builder.build(&create_stmt)).await?; + println!(" ✅ Created table: {}", name); - Ok(()) -} \ No newline at end of file + Ok(()) +} diff --git a/imphnen-backend/src/bin/mk_token.rs b/imphnen-backend/src/bin/mk_token.rs index 7e9eeaa..64deb9d 100644 --- a/imphnen-backend/src/bin/mk_token.rs +++ b/imphnen-backend/src/bin/mk_token.rs @@ -1,21 +1,20 @@ -#![allow(clippy::all)] - -use imphnen_libs::jsonwebtoken::encode_access_token; -use std::env; - -fn main() { - let args: Vec = env::args().collect(); - if args.len() < 2 { - eprintln!("Usage: mk_token "); - std::process::exit(1); - } - let sub = args[1].clone(); - // Use sub as both sub and user_id - match encode_access_token(sub.clone(), sub.clone()) { - Ok(token) => println!("{}", token), - Err(e) => { - eprintln!("Failed to generate token: {:?}", e); - std::process::exit(2); - } - } -} +#![allow(clippy::all)] + +use imphnen_libs::jsonwebtoken::encode_access_token; +use std::env; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: mk_token "); + std::process::exit(1); + } + let sub = args[1].clone(); + match encode_access_token(sub.clone(), sub.clone()) { + Ok(token) => println!("{}", token), + Err(e) => { + eprintln!("Failed to generate token: {:?}", e); + std::process::exit(2); + } + } +} diff --git a/imphnen-backend/src/bin/seed_events.rs b/imphnen-backend/src/bin/seed_events.rs index 581ba78..038b4eb 100644 --- a/imphnen-backend/src/bin/seed_events.rs +++ b/imphnen-backend/src/bin/seed_events.rs @@ -1,11 +1,15 @@ #![allow(clippy::all)] -use std::error::Error; +use chrono::Utc; +use imphnen_entities::seaorm::common::events::{ + ActiveModel as EventsActiveModel, Entity as EventEntity, +}; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::common::events::{ActiveModel as EventsActiveModel, Entity as EventEntity}; -use sea_orm::{ActiveValue::Set, ActiveModelTrait, EntityTrait, ColumnTrait, QueryFilter}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, +}; +use std::error::Error; use uuid::Uuid; -use chrono::Utc; // Removed NaiveDateTime as it was unused #[tokio::main] async fn main() -> Result<(), Box> { @@ -54,7 +58,6 @@ async fn main() -> Result<(), Box> { "2025-09-20T13:00:00Z", "2025-09-22T15:00:00Z", ), - // Additional Events ( "Rust Programming Bootcamp", "Intensive 3-day bootcamp to master Rust fundamentals and advanced concepts.", @@ -154,18 +157,20 @@ async fn main() -> Result<(), Box> { price, location, is_online, - start_date_str, // Renamed to avoid conflict - end_date_str, // Renamed to avoid conflict + start_date_str, + end_date_str, ) in events { - // Check if event already exists by name - let existing = EventEntity::find().filter(::Column::Name.eq(name)).one(db).await?; + let existing = EventEntity::find() + .filter(::Column::Name.eq(name)) + .one(db) + .await?; if existing.is_some() { println!("ℹ️ Skipping (already exists): {name}"); continue; } - let uuid = Uuid::new_v4(); // Generate a Uuid + let uuid = Uuid::new_v4(); let mut event_model: EventsActiveModel = Default::default(); event_model.id = Set(uuid); event_model.name = Set(name.to_string()); @@ -174,12 +179,17 @@ async fn main() -> Result<(), Box> { event_model.price = Set(price); event_model.is_online = Set(is_online); event_model.location = Set(location.clone()); - event_model.start_date = Set(chrono::DateTime::parse_from_rfc3339(start_date_str)?.with_timezone(&chrono::Utc)); - event_model.end_date = Set(chrono::DateTime::parse_from_rfc3339(end_date_str)?.with_timezone(&chrono::Utc)); - event_model.is_deleted = Set(false); // Explicitly set is_deleted - event_model.created_at = Set(Utc::now()); // Explicitly set created_at - event_model.updated_at = Set(Utc::now()); // Explicitly set updated_at - + event_model.start_date = Set( + chrono::DateTime::parse_from_rfc3339(start_date_str)? + .with_timezone(&chrono::Utc), + ); + event_model.end_date = Set( + chrono::DateTime::parse_from_rfc3339(end_date_str)? + .with_timezone(&chrono::Utc), + ); + event_model.is_deleted = Set(false); + event_model.created_at = Set(Utc::now()); + event_model.updated_at = Set(Utc::now()); event_model.insert(db).await?; @@ -192,4 +202,4 @@ async fn main() -> Result<(), Box> { println!("✅ All Events seeded"); Ok(()) -} \ No newline at end of file +} diff --git a/imphnen-backend/src/bin/seed_gacha_rolls.rs b/imphnen-backend/src/bin/seed_gacha_rolls.rs index 0d352ec..30417b3 100644 --- a/imphnen-backend/src/bin/seed_gacha_rolls.rs +++ b/imphnen-backend/src/bin/seed_gacha_rolls.rs @@ -1,13 +1,13 @@ #![allow(clippy::all)] -use std::error::Error; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use imphnen_entities::seaorm::gacha::gacha_items::ActiveModel as GachaItemActiveModel; use imphnen_entities::seaorm::gacha::gacha_rolls::ActiveModel as GachaRollActiveModel; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use sea_orm::ActiveModelTrait; use sea_orm::ActiveValue::Set; -use uuid::Uuid; use sea_orm::ConnectionTrait; +use std::error::Error; +use uuid::Uuid; #[tokio::main] async fn main() -> Result<(), Box> { @@ -15,18 +15,25 @@ async fn main() -> Result<(), Box> { let pg_conn = PostgresConnection::new(config).await?; let db = &pg_conn.conn; - // Check if gacha item already exists - let check_item_sql = "SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1"; - let item_result = pg_conn.query_one(sea_orm::Statement::from_string(db.get_database_backend(), check_item_sql)).await?; + let check_item_sql = + "SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1"; + let item_result = pg_conn + .query_one(sea_orm::Statement::from_string( + db.get_database_backend(), + check_item_sql, + )) + .await?; let gacha_item_uuid = if let Some(ref row) = item_result { - // Item exists, get its ID row.try_get("", "id")? } else { - // Item doesn't exist, create it - // Note: We can't easily delete by a fixed ID since it's a UUID, but the insert will fail if there's a conflict - let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string())).await.ok(); - - // Create gacha item via SeaORM + let _ = pg_conn + .execute(sea_orm::Statement::from_string( + db.get_database_backend(), + "DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string(), + )) + .await + .ok(); + let new_uuid = Uuid::new_v4(); let mut item_model: GachaItemActiveModel = Default::default(); item_model.id = Set(new_uuid); @@ -47,7 +54,6 @@ async fn main() -> Result<(), Box> { new_uuid }; - // Always try to insert the roll, relying on the database constraints to prevent duplicates if needed let gacha_roll_id = Uuid::new_v4(); let mut roll_model: GachaRollActiveModel = Default::default(); roll_model.id = Set(gacha_roll_id); @@ -61,18 +67,18 @@ async fn main() -> Result<(), Box> { roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc())); roll_model.insert(db).await?; println!("Gacha Roll seeded successfully!"); - let gacha_roll_id = Uuid::new_v4(); - let mut roll_model: GachaRollActiveModel = Default::default(); - roll_model.id = Set(gacha_roll_id); - roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); - roll_model.gacha_id = Set(Uuid::new_v4().to_string()); - roll_model.item_id = Set(gacha_item_uuid); - roll_model.weight = Set(1.0); - roll_model.quantity = Set(10); - roll_model.is_deleted = Set(false); - roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc())); - roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc())); - roll_model.insert(db).await?; + let gacha_roll_id = Uuid::new_v4(); + let mut roll_model: GachaRollActiveModel = Default::default(); + roll_model.id = Set(gacha_roll_id); + roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); + roll_model.gacha_id = Set(Uuid::new_v4().to_string()); + roll_model.item_id = Set(gacha_item_uuid); + roll_model.weight = Set(1.0); + roll_model.quantity = Set(10); + roll_model.is_deleted = Set(false); + roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc())); + roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc())); + roll_model.insert(db).await?; println!("✅ Gacha items and rolls seeded."); Ok(()) } diff --git a/imphnen-backend/src/bin/seed_mentor_user.rs b/imphnen-backend/src/bin/seed_mentor_user.rs index 23a5b5a..c25d0d7 100644 --- a/imphnen-backend/src/bin/seed_mentor_user.rs +++ b/imphnen-backend/src/bin/seed_mentor_user.rs @@ -1,13 +1,18 @@ #![allow(clippy::all)] +use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel; +use imphnen_entities::seaorm::auth::roles::{ + Column as RoleColumn, Entity as RoleEntity, +}; +use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel; use imphnen_libs::hash_password; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, + QueryFilter, +}; use serde_json::json; use std::error::Error; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel; -use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel; -use imphnen_entities::seaorm::auth::roles::{Entity as RoleEntity, Column as RoleColumn}; -use sea_orm::{ActiveModelTrait, ConnectionTrait, ActiveValue::Set, EntityTrait, QueryFilter, ColumnTrait}; use uuid::Uuid; #[tokio::main] @@ -16,17 +21,28 @@ async fn main() -> Result<(), Box> { let pg_conn = PostgresConnection::new(config).await?; let db = &pg_conn.conn; - let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_mentors WHERE id = 'e6f78d23-83bf-5c2b-bcd4-001345678901'".to_string())).await.ok(); - let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_users WHERE email = 'mentor@example.com'".to_string())).await.ok(); + let _ = pg_conn + .execute(sea_orm::Statement::from_string( + db.get_database_backend(), + "DELETE FROM app_mentors WHERE id = 'e6f78d23-83bf-5c2b-bcd4-001345678901'" + .to_string(), + )) + .await + .ok(); + let _ = pg_conn + .execute(sea_orm::Statement::from_string( + db.get_database_backend(), + "DELETE FROM app_users WHERE email = 'mentor@example.com'".to_string(), + )) + .await + .ok(); - // Find Mentor role let role = RoleEntity::find() .filter(RoleColumn::Name.eq("Mentor")) .one(db) .await? .ok_or("Role 'Mentor' not found")?; - // Insert user with Mentor role let user_id = Uuid::new_v4(); let mut user_model: UsersActiveModel = Default::default(); user_model.id = Set(user_id); @@ -43,21 +59,23 @@ async fn main() -> Result<(), Box> { user_model.updated_at = Set(chrono::Utc::now()); user_model.insert(db).await?; - // Insert mentor let mentor_id = Uuid::new_v4(); let mut mentor_model: MentorsActiveModel = Default::default(); mentor_model.id = Set(mentor_id); mentor_model.user_id = Set(user_id); - mentor_model.industries = Set(Some(json!( ["Software", "Education"] ))); - mentor_model.expertise = Set(Some(json!( ["Rust", "Microservices"] ))); - mentor_model.languages = Set(Some(json!( ["Indonesian", "English"] ))); + mentor_model.industries = Set(Some(json!(["Software", "Education"]))); + mentor_model.expertise = Set(Some(json!(["Rust", "Microservices"]))); + mentor_model.languages = Set(Some(json!(["Indonesian", "English"]))); mentor_model.current_company = Set(Some("PT Contoh".to_string())); mentor_model.current_role = Set(Some("Senior Backend Engineer".to_string())); mentor_model.years_of_experience = Set(Some(5)); - mentor_model.topics_of_interest = Set(Some(json!( ["Rust Programming", "Backend Development"] ))); + mentor_model.topics_of_interest = + Set(Some(json!(["Rust Programming", "Backend Development"]))); mentor_model.preferred_mentee_level = Set(Some("beginner".to_string())); - mentor_model.preferred_mentoring_formats = Set(Some(json!( ["online", "offline"] ))); - mentor_model.availability_commitment = Set(Some("2 jam per minggu untuk mentoring online dan offline".to_string())); + mentor_model.preferred_mentoring_formats = Set(Some(json!(["online", "offline"]))); + mentor_model.availability_commitment = Set(Some( + "2 jam per minggu untuk mentoring online dan offline".to_string(), + )); mentor_model.mentoring_rate = Set(Some(100000.0)); mentor_model.status = Set(Some("verified".to_string())); mentor_model.is_deleted = Set(false); diff --git a/imphnen-backend/src/bin/seed_permissions.rs b/imphnen-backend/src/bin/seed_permissions.rs index b99fc5f..7ad4ff5 100644 --- a/imphnen-backend/src/bin/seed_permissions.rs +++ b/imphnen-backend/src/bin/seed_permissions.rs @@ -1,81 +1,79 @@ -#![allow(clippy::all)] - -use imphnen_iam::PermissionsEnum; -use std::error::Error; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::auth::permissions::ActiveModel as PermissionActiveModel; -use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity; -use sea_orm::ActiveValue::Set; -use sea_orm::{ActiveModelTrait}; -use uuid::Uuid; -use chrono::Utc; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = PostgresConfig::from_env()?; - let pg_conn = PostgresConnection::new(config).await?; - let db = &pg_conn.conn; - - for permission in [ - PermissionsEnum::ReadListUsers, - PermissionsEnum::ReadDetailUsers, - PermissionsEnum::CreateUsers, - PermissionsEnum::DeleteUsers, - PermissionsEnum::UpdateUsers, - PermissionsEnum::ActivateUsers, - PermissionsEnum::ReadListRoles, - PermissionsEnum::ReadDetailRoles, - PermissionsEnum::CreateRoles, - PermissionsEnum::DeleteRoles, - PermissionsEnum::UpdateRoles, - PermissionsEnum::ReadListPermissions, - PermissionsEnum::ReadDetailPermissions, - PermissionsEnum::CreatePermissions, - PermissionsEnum::DeletePermissions, - PermissionsEnum::UpdatePermissions, - PermissionsEnum::CreateGachaClaims, - PermissionsEnum::ReadDetailGachaClaims, - PermissionsEnum::ReadListGachaItems, - PermissionsEnum::ReadDetailGachaItems, - PermissionsEnum::CreateGachaItems, - PermissionsEnum::DeleteGachaItems, - PermissionsEnum::UpdateGachaItems, - PermissionsEnum::ReadDetailGachaRolls, - PermissionsEnum::CreateGachaRolls, - PermissionsEnum::ExecuteGachaRolls, - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailMentors, - PermissionsEnum::RegisterMentors, - PermissionsEnum::ReadOwnMentorProfile, - PermissionsEnum::UpdateOwnMentorProfile, - PermissionsEnum::ReadOwnMentorStatus, - PermissionsEnum::UpdateMentors, - PermissionsEnum::VerifyMentors, - PermissionsEnum::DeleteMentors, - PermissionsEnum::Administrator, - ] { - // permission.id() returns a string, try parse to uuid - let parsed_id = Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4()); - - // Check if permission already exists - let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?; - if existing.is_some() { - println!("ℹ️ Skipping (already exists): {permission}"); - continue; - } - - // Insert permission using active model - let mut perm_model: PermissionActiveModel = Default::default(); - perm_model.id = Set(parsed_id); - perm_model.name = Set(permission.to_string()); - perm_model.is_deleted = Set(false); - perm_model.created_at = Set(Utc::now()); - perm_model.updated_at = Set(Utc::now()); - perm_model.insert(db).await?; - println!("✅ Inserted: {permission}"); - } - - println!("✅ All Permissions seeded"); - - Ok(()) -} +#![allow(clippy::all)] + +use chrono::Utc; +use imphnen_entities::seaorm::auth::permissions::ActiveModel as PermissionActiveModel; +use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity; +use imphnen_iam::PermissionsEnum; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; +use sea_orm::ActiveModelTrait; +use sea_orm::ActiveValue::Set; +use std::error::Error; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = PostgresConfig::from_env()?; + let pg_conn = PostgresConnection::new(config).await?; + let db = &pg_conn.conn; + + for permission in [ + PermissionsEnum::ReadListUsers, + PermissionsEnum::ReadDetailUsers, + PermissionsEnum::CreateUsers, + PermissionsEnum::DeleteUsers, + PermissionsEnum::UpdateUsers, + PermissionsEnum::ActivateUsers, + PermissionsEnum::ReadListRoles, + PermissionsEnum::ReadDetailRoles, + PermissionsEnum::CreateRoles, + PermissionsEnum::DeleteRoles, + PermissionsEnum::UpdateRoles, + PermissionsEnum::ReadListPermissions, + PermissionsEnum::ReadDetailPermissions, + PermissionsEnum::CreatePermissions, + PermissionsEnum::DeletePermissions, + PermissionsEnum::UpdatePermissions, + PermissionsEnum::CreateGachaClaims, + PermissionsEnum::ReadDetailGachaClaims, + PermissionsEnum::ReadListGachaItems, + PermissionsEnum::ReadDetailGachaItems, + PermissionsEnum::CreateGachaItems, + PermissionsEnum::DeleteGachaItems, + PermissionsEnum::UpdateGachaItems, + PermissionsEnum::ReadDetailGachaRolls, + PermissionsEnum::CreateGachaRolls, + PermissionsEnum::ExecuteGachaRolls, + PermissionsEnum::ReadListMentors, + PermissionsEnum::ReadDetailMentors, + PermissionsEnum::RegisterMentors, + PermissionsEnum::ReadOwnMentorProfile, + PermissionsEnum::UpdateOwnMentorProfile, + PermissionsEnum::ReadOwnMentorStatus, + PermissionsEnum::UpdateMentors, + PermissionsEnum::VerifyMentors, + PermissionsEnum::DeleteMentors, + PermissionsEnum::Administrator, + ] { + let parsed_id = + Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4()); + + let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?; + if existing.is_some() { + println!("ℹ️ Skipping (already exists): {permission}"); + continue; + } + + let mut perm_model: PermissionActiveModel = Default::default(); + perm_model.id = Set(parsed_id); + perm_model.name = Set(permission.to_string()); + perm_model.is_deleted = Set(false); + perm_model.created_at = Set(Utc::now()); + perm_model.updated_at = Set(Utc::now()); + perm_model.insert(db).await?; + println!("✅ Inserted: {permission}"); + } + + println!("✅ All Permissions seeded"); + + Ok(()) +} diff --git a/imphnen-backend/src/bin/seed_roles.rs b/imphnen-backend/src/bin/seed_roles.rs index f44cc8c..3b8e799 100644 --- a/imphnen-backend/src/bin/seed_roles.rs +++ b/imphnen-backend/src/bin/seed_roles.rs @@ -1,9 +1,9 @@ -use std::error::Error; +use chrono::Utc; +use imphnen_entities::seaorm::auth::roles::{Entity as RoleEntity, RoleBuilder}; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::auth::roles::{RoleBuilder, Entity as RoleEntity}; use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; +use std::error::Error; use uuid::Uuid; -use chrono::Utc; // Added chrono #[tokio::main] async fn main() -> Result<(), Box> { @@ -50,19 +50,15 @@ async fn main() -> Result<(), Box> { ), ]; - for (id, name, _created_at_str, _updated_at_str) in roles { // Renamed to avoid conflict + for (id, name, _created_at_str, _updated_at_str) in roles { let uuid = Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4()); - - // Check if role already exists + let existing = RoleEntity::find_by_id(uuid).one(db).await?; if existing.is_some() { println!("ℹ️ Skipping (already exists): {name}"); continue; } - // Delete existing by id to avoid duplicates (original logic, replaced by existence check) - // let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), format!("DELETE FROM app_roles WHERE id = '{}'", uuid))).await.ok(); - let role_model = RoleBuilder::new() .name(name.to_string()) .description("System generated role".to_string()) @@ -71,13 +67,13 @@ async fn main() -> Result<(), Box> { .build()?; let mut role_model = role_model; role_model.id = Set(uuid); - role_model.is_system_role = Set(true); // Set the missing field - role_model.created_at = Set(Utc::now()); // Set created_at - role_model.updated_at = Set(Utc::now()); // Set updated_at + role_model.is_system_role = Set(true); + role_model.created_at = Set(Utc::now()); + role_model.updated_at = Set(Utc::now()); role_model.insert(db).await?; println!("✅ Inserted role: {name}"); } println!("✅ All Roles seeded"); Ok(()) -} \ No newline at end of file +} diff --git a/imphnen-backend/src/bin/seed_roles_permissions.rs b/imphnen-backend/src/bin/seed_roles_permissions.rs index 4577e02..3acca4c 100644 --- a/imphnen-backend/src/bin/seed_roles_permissions.rs +++ b/imphnen-backend/src/bin/seed_roles_permissions.rs @@ -1,112 +1,109 @@ -use imphnen_iam::PermissionsEnum; -use std::error::Error; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity; -use imphnen_entities::seaorm::auth::roles::ActiveModel as RoleActiveModel; -use sea_orm::ActiveValue::Set; -use sea_orm::EntityTrait; -use sea_orm::ActiveModelTrait; -use uuid::Uuid; -use serde_json::Value as JsonValue; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = PostgresConfig::from_env()?; - let pg_conn = PostgresConnection::new(config).await?; - let db = &pg_conn.conn; - // Ensure indexes are present if needed (placeholders) - we don't modify schema here - - println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'."); - - let roles_permissions = vec![ - ( - "f6b03f25-e416-4893-ac88-caaa690afb07", - vec![ - // Only Administrator permission - grants access to everything - PermissionsEnum::Administrator, - ], - ), - ( - "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", - vec![ - PermissionsEnum::ReadListUsers, // Added ReadListUsers permission - PermissionsEnum::ReadOwnMentorProfile, - PermissionsEnum::UpdateOwnMentorProfile, - PermissionsEnum::ReadOwnMentorStatus, - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailMentors, - PermissionsEnum::ReadListGachaItems, - PermissionsEnum::ReadDetailGachaItems, - PermissionsEnum::ReadDetailGachaRolls, - PermissionsEnum::CreateGachaRolls, - PermissionsEnum::ExecuteGachaRolls, - ], - ), - ( - "5713cb37-dc02-4e87-8048-d7a41d352059", - vec![ - PermissionsEnum::ReadListGachaItems, - PermissionsEnum::ReadDetailGachaItems, - PermissionsEnum::ReadListUsers, - PermissionsEnum::ReadDetailUsers, - PermissionsEnum::CreateGachaClaims, - PermissionsEnum::ReadDetailGachaClaims, - PermissionsEnum::ReadDetailGachaRolls, - PermissionsEnum::CreateGachaRolls, - PermissionsEnum::ExecuteGachaRolls, - PermissionsEnum::RegisterMentors, - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailMentors, - PermissionsEnum::ReadOwnMentorProfile, - PermissionsEnum::ReadOwnMentorStatus, - ], - ), - ( - "50133429-f4b1-4249-9f97-7b86e6ee9d86", - vec![ - // Staff should be able to list roles and permissions in tests - PermissionsEnum::ReadListRoles, - PermissionsEnum::ReadListPermissions, - PermissionsEnum::ReadListUsers, - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailUsers, - PermissionsEnum::ActivateUsers, - PermissionsEnum::ReadDetailRoles, - PermissionsEnum::ReadDetailPermissions, - PermissionsEnum::ReadListGachaItems, - PermissionsEnum::ReadDetailGachaItems, - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailMentors, - PermissionsEnum::ReadDetailGachaRolls, - PermissionsEnum::CreateGachaRolls, - PermissionsEnum::ExecuteGachaRolls, - ], - ), - ( - "60f1aeb7-dad2-4e06-bcb5-be1ba510c906", - vec![PermissionsEnum::ActivateUsers], - ), - ("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]), - ]; - - for (role_id, permissions) in roles_permissions { - let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4()); - // Map permissions enum to JSON array of permission ids - let json_permissions = JsonValue::Array( - permissions.iter().map(|p| JsonValue::String(p.id())).collect() - ); - - // Find role and update permissions - if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? { - let mut am: RoleActiveModel = role_model.into(); - am.permissions = Set(Some(json_permissions)); - am.update(db).await?; - println!("✅ Permissions updated for role: {role_id}"); - } else { - println!("⚠️ Role with id {role_id} not found, skipping permissions update"); - } - } - - println!("✅ All roles permissions updated!"); - Ok(()) -} +use imphnen_entities::seaorm::auth::roles::ActiveModel as RoleActiveModel; +use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity; +use imphnen_iam::PermissionsEnum; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; +use sea_orm::ActiveModelTrait; +use sea_orm::ActiveValue::Set; +use sea_orm::EntityTrait; +use serde_json::Value as JsonValue; +use std::error::Error; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = PostgresConfig::from_env()?; + let pg_conn = PostgresConnection::new(config).await?; + let db = &pg_conn.conn; + println!( + "✅ Index 'user_email_index' defined on table 'users' for column 'email'." + ); + + let roles_permissions = vec![ + ( + "f6b03f25-e416-4893-ac88-caaa690afb07", + vec![PermissionsEnum::Administrator], + ), + ( + "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", + vec![ + PermissionsEnum::ReadListUsers, + 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::ReadListRoles, + PermissionsEnum::ReadListPermissions, + PermissionsEnum::ReadListUsers, + PermissionsEnum::ReadListMentors, + PermissionsEnum::ReadDetailUsers, + PermissionsEnum::ActivateUsers, + PermissionsEnum::ReadDetailRoles, + PermissionsEnum::ReadDetailPermissions, + PermissionsEnum::ReadListGachaItems, + PermissionsEnum::ReadDetailGachaItems, + PermissionsEnum::ReadListMentors, + PermissionsEnum::ReadDetailMentors, + PermissionsEnum::ReadDetailGachaRolls, + PermissionsEnum::CreateGachaRolls, + PermissionsEnum::ExecuteGachaRolls, + ], + ), + ( + "60f1aeb7-dad2-4e06-bcb5-be1ba510c906", + vec![PermissionsEnum::ActivateUsers], + ), + ("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]), + ]; + + for (role_id, permissions) in roles_permissions { + let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4()); + let json_permissions = JsonValue::Array( + permissions + .iter() + .map(|p| JsonValue::String(p.id())) + .collect(), + ); + + if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? { + let mut am: RoleActiveModel = role_model.into(); + am.permissions = Set(Some(json_permissions)); + am.update(db).await?; + println!("✅ Permissions updated for role: {role_id}"); + } else { + println!("⚠️ Role with id {role_id} not found, skipping permissions update"); + } + } + + println!("✅ All roles permissions updated!"); + Ok(()) +} diff --git a/imphnen-backend/src/bin/seed_test_data.rs b/imphnen-backend/src/bin/seed_test_data.rs index 0b74aff..0bd2eb6 100644 --- a/imphnen-backend/src/bin/seed_test_data.rs +++ b/imphnen-backend/src/bin/seed_test_data.rs @@ -1,77 +1,79 @@ #![allow(clippy::all)] -use std::error::Error; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; +use chrono::Utc; +use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel; use imphnen_entities::seaorm::common::events::ActiveModel as EventsActiveModel; use imphnen_entities::seaorm::common::testimonials::ActiveModel as TestimonialsActiveModel; -use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel; -use sea_orm::ActiveValue::Set; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use sea_orm::ActiveModelTrait; -use uuid::Uuid; +use sea_orm::ActiveValue::Set; use serde_json::json; -use chrono::Utc; +use std::error::Error; +use uuid::Uuid; #[tokio::main] async fn main() -> Result<(), Box> { - let config = PostgresConfig::from_env()?; - let pg_conn = PostgresConnection::new(config).await?; - let db = &pg_conn.conn; + let config = PostgresConfig::from_env()?; + let pg_conn = PostgresConnection::new(config).await?; + let db = &pg_conn.conn; - // Seed Events - handle existing data - let uuid = Uuid::new_v4().to_string(); - let mut event_model: EventsActiveModel = Default::default(); - event_model.id = Set(Uuid::parse_str(&uuid)?); - event_model.name = Set("Test Event".to_string()); - event_model.description = Set("Test event description".to_string()); - event_model.detail_link = Set("https://example.com/event".to_string()); - event_model.price = Set(50.0); - event_model.is_online = Set(true); - event_model.start_date = Set(Utc::now()); - event_model.end_date = Set(Utc::now() + chrono::Duration::days(1)); - event_model.location = Set(None); - event_model.is_deleted = Set(false); - match event_model.insert(db).await { - Ok(_) => println!("✅ Inserted test event"), - Err(_) => println!("⚠️ Test event already exists or could not be inserted, skipping"), - }; + let uuid = Uuid::new_v4().to_string(); + let mut event_model: EventsActiveModel = Default::default(); + event_model.id = Set(Uuid::parse_str(&uuid)?); + event_model.name = Set("Test Event".to_string()); + event_model.description = Set("Test event description".to_string()); + event_model.detail_link = Set("https://example.com/event".to_string()); + event_model.price = Set(50.0); + event_model.is_online = Set(true); + event_model.start_date = Set(Utc::now()); + event_model.end_date = Set(Utc::now() + chrono::Duration::days(1)); + event_model.location = Set(None); + event_model.is_deleted = Set(false); + match event_model.insert(db).await { + Ok(_) => println!("✅ Inserted test event"), + Err(_) => { + println!("⚠️ Test event already exists or could not be inserted, skipping") + } + }; - // Seed Testimonials - handle existing data - let mut testimonial_model: TestimonialsActiveModel = Default::default(); - testimonial_model.id = Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?); - testimonial_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); - testimonial_model.role = Set("Student".to_string()); - testimonial_model.content = Set("This is a great platform!".to_string()); - testimonial_model.is_deleted = Set(false); - match testimonial_model.insert(db).await { - Ok(_) => println!("✅ Inserted test testimonial"), - Err(_) => println!("⚠️ Test testimonial already exists or could not be inserted, skipping"), - }; + let mut testimonial_model: TestimonialsActiveModel = Default::default(); + testimonial_model.id = + Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?); + testimonial_model.user_id = + Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); + testimonial_model.role = Set("Student".to_string()); + testimonial_model.content = Set("This is a great platform!".to_string()); + testimonial_model.is_deleted = Set(false); + match testimonial_model.insert(db).await { + Ok(_) => println!("✅ Inserted test testimonial"), + Err(_) => println!( + "⚠️ Test testimonial already exists or could not be inserted, skipping" + ), + }; - // Seed Mentor - handle existing data - let mentor_id = Uuid::new_v4(); - let mut mentor_model: MentorsActiveModel = Default::default(); - mentor_model.id = Set(mentor_id); - // Use the admin user ID instead of a random one - mentor_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); - mentor_model.industries = Set(Some(json!( ["Technology", "Education"] ))); - mentor_model.expertise = Set(Some(json!( ["Software Development"] ))); - mentor_model.languages = Set(Some(json!( ["English", "Indonesian"] ))); - mentor_model.current_company = Set(Some("Tech Corp".to_string())); - mentor_model.current_role = Set(Some("Senior Engineer".to_string())); - mentor_model.years_of_experience = Set(Some(5)); - mentor_model.topics_of_interest = Set(Some(json!( ["Rust", "Web Development"] ))); - mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string())); - mentor_model.preferred_mentoring_formats = Set(Some(json!( ["1:1", "Group"] ))); - mentor_model.availability_commitment = Set(Some("Weekly".to_string())); - mentor_model.mentoring_rate = Set(Some(100.0)); - mentor_model.status = Set(Some("active".to_string())); - mentor_model.is_deleted = Set(false); - mentor_model.created_at = Set(chrono::Utc::now()); - mentor_model.updated_at = Set(chrono::Utc::now()); - // Create mentor record via SeaORM active model - mentor_model.insert(db).await?; - println!("✅ Inserted test mentor via SeaORM"); + let mentor_id = Uuid::new_v4(); + let mut mentor_model: MentorsActiveModel = Default::default(); + mentor_model.id = Set(mentor_id); + mentor_model.user_id = + Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); + mentor_model.industries = Set(Some(json!(["Technology", "Education"]))); + mentor_model.expertise = Set(Some(json!(["Software Development"]))); + mentor_model.languages = Set(Some(json!(["English", "Indonesian"]))); + mentor_model.current_company = Set(Some("Tech Corp".to_string())); + mentor_model.current_role = Set(Some("Senior Engineer".to_string())); + mentor_model.years_of_experience = Set(Some(5)); + mentor_model.topics_of_interest = Set(Some(json!(["Rust", "Web Development"]))); + mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string())); + mentor_model.preferred_mentoring_formats = Set(Some(json!(["1:1", "Group"]))); + mentor_model.availability_commitment = Set(Some("Weekly".to_string())); + mentor_model.mentoring_rate = Set(Some(100.0)); + mentor_model.status = Set(Some("active".to_string())); + mentor_model.is_deleted = Set(false); + mentor_model.created_at = Set(chrono::Utc::now()); + mentor_model.updated_at = Set(chrono::Utc::now()); + mentor_model.insert(db).await?; + println!("✅ Inserted test mentor via SeaORM"); - println!("✅ All test data seeded successfully"); - Ok(()) -} \ No newline at end of file + println!("✅ All test data seeded successfully"); + Ok(()) +} diff --git a/imphnen-backend/src/bin/seed_users.rs b/imphnen-backend/src/bin/seed_users.rs index a94739e..70aa5ad 100644 --- a/imphnen-backend/src/bin/seed_users.rs +++ b/imphnen-backend/src/bin/seed_users.rs @@ -1,14 +1,14 @@ #![allow(clippy::all)] +use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel; +use imphnen_entities::seaorm::auth::users::Entity as UserEntity; use imphnen_libs::hash_password; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; -use imphnen_entities::seaorm::auth::users::Entity as UserEntity; // Added for dynamic role lookup -use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel; - -use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel}; -use uuid::Uuid; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel}; use std::error::Error; -use chrono::Utc; +use uuid::Uuid; #[tokio::main] async fn main() -> Result<(), Box> { @@ -17,145 +17,148 @@ async fn main() -> Result<(), Box> { let db = &pg_conn.conn; let users = vec![ - ( - "c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2", - "admin@example.com", - "Admin", - "f6b03f25-e416-4893-ac88-caaa690afb07", - ), - ( - "a4d23fb5-9e31-423c-9842-fbd6e75a5298", - "staff@example.com", - "Staff", - "50133429-f4b1-4249-9f97-7b86e6ee9d86", - ), - ( - "d5e89c12-72af-4b1a-abc3-ff1234567890", - "user@example.com", - "User", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4", - "testuser1@example.com", - "Test User 1", - "5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID - ), - ( - "3972c139-a450-416c-93b0-c42539dc780f", - "testuser2@example.com", - "Test User 2", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "b426c0a9-0efb-4e26-b078-4f18767255f3", - "testuser3@example.com", - "Test User 3", - "5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID - ), - // Additional Users for Volume and Variety - ( - "11111111-1111-1111-1111-111111111111", - "user4@example.com", - "User Four", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "22222222-2222-2222-2222-222222222222", - "user5@example.com", - "User Five", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "33333333-3333-3333-3333-333333333333", - "mentor2@example.com", - "Mentor Two", - "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", // Mentor Role - ), - ( - "44444444-4444-4444-4444-444444444444", - "staff2@example.com", - "Staff Two", - "50133429-f4b1-4249-9f97-7b86e6ee9d86", // Staff Role - ), - ( - "55555555-5555-5555-5555-555555555555", - "user6@example.com", - "User Six", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "66666666-6666-6666-6666-666666666666", - "user7@example.com", - "User Seven", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "77777777-7777-7777-7777-777777777777", - "user8@example.com", - "User Eight", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "88888888-8888-8888-8888-888888888888", - "user9@example.com", - "User Nine", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), - ( - "99999999-9999-9999-9999-999999999999", - "user10@example.com", - "User Ten", - "5713cb37-dc02-4e87-8048-d7a41d352059", - ), + ( + "c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2", + "admin@example.com", + "Admin", + "f6b03f25-e416-4893-ac88-caaa690afb07", + ), + ( + "a4d23fb5-9e31-423c-9842-fbd6e75a5298", + "staff@example.com", + "Staff", + "50133429-f4b1-4249-9f97-7b86e6ee9d86", + ), + ( + "d5e89c12-72af-4b1a-abc3-ff1234567890", + "user@example.com", + "User", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4", + "testuser1@example.com", + "Test User 1", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "3972c139-a450-416c-93b0-c42539dc780f", + "testuser2@example.com", + "Test User 2", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "b426c0a9-0efb-4e26-b078-4f18767255f3", + "testuser3@example.com", + "Test User 3", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "11111111-1111-1111-1111-111111111111", + "user4@example.com", + "User Four", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "22222222-2222-2222-2222-222222222222", + "user5@example.com", + "User Five", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "33333333-3333-3333-3333-333333333333", + "mentor2@example.com", + "Mentor Two", + "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", + ), + ( + "44444444-4444-4444-4444-444444444444", + "staff2@example.com", + "Staff Two", + "50133429-f4b1-4249-9f97-7b86e6ee9d86", + ), + ( + "55555555-5555-5555-5555-555555555555", + "user6@example.com", + "User Six", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "66666666-6666-6666-6666-666666666666", + "user7@example.com", + "User Seven", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "77777777-7777-7777-7777-777777777777", + "user8@example.com", + "User Eight", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "88888888-8888-8888-8888-888888888888", + "user9@example.com", + "User Nine", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ( + "99999999-9999-9999-9999-999999999999", + "user10@example.com", + "User Ten", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), ]; - for (id, email, fullname, role_id_str) in users { // role_id_str directly contains UUID - let role_uuid = Some(Uuid::parse_str(role_id_str) - .map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?); - - // Build SeaORM ActiveModel for users - let uid = Uuid::parse_str(id)?; // Should always be valid UUID strings from test data - - let names: Vec<&str> = fullname.split_whitespace().collect(); - let first_name = names.first().map(|s| s.to_string()); - let last_name = if names.len() > 1 { Some(names[1..].join(" ")) } else { None }; - - let password = "password"; - let hashed = hash_password(password).unwrap(); + for (id, email, fullname, role_id_str) in users { + let role_uuid = Some( + Uuid::parse_str(role_id_str) + .map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?, + ); - // Explicit Upsert Logic - let existing_user = UserEntity::find_by_id(uid).one(db).await?; - let is_update = existing_user.is_some(); - - let mut user_model: UsersActiveModel = if let Some(existing) = existing_user { - println!("🔄 Updating user: {fullname} ({email})"); - existing.into_active_model() - } else { - println!("✅ Inserting user: {fullname} ({email})"); - let mut active: UsersActiveModel = Default::default(); - active.id = Set(uid); - active.created_at = Set(Utc::now()); - active - }; - - user_model.email = Set(email.to_string()); - user_model.password_hash = Set(hashed); - user_model.username = Set(email.to_string()); - user_model.first_name = Set(first_name); - user_model.last_name = Set(last_name); - user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string())); - user_model.is_verified = Set(true); - user_model.is_active = Set(true); - user_model.role_id = Set(role_uuid); - user_model.updated_at = Set(Utc::now()); - - if is_update { - user_model.update(db).await?; - } else { - user_model.insert(db).await?; - } - } + let uid = Uuid::parse_str(id)?; + + let names: Vec<&str> = fullname.split_whitespace().collect(); + let first_name = names.first().map(|s| s.to_string()); + let last_name = if names.len() > 1 { + Some(names[1..].join(" ")) + } else { + None + }; + + let password = "password"; + let hashed = hash_password(password).unwrap(); + + let existing_user = UserEntity::find_by_id(uid).one(db).await?; + let is_update = existing_user.is_some(); + + let mut user_model: UsersActiveModel = if let Some(existing) = existing_user { + println!("🔄 Updating user: {fullname} ({email})"); + existing.into_active_model() + } else { + println!("✅ Inserting user: {fullname} ({email})"); + let mut active: UsersActiveModel = Default::default(); + active.id = Set(uid); + active.created_at = Set(Utc::now()); + active + }; + + user_model.email = Set(email.to_string()); + user_model.password_hash = Set(hashed); + user_model.username = Set(email.to_string()); + user_model.first_name = Set(first_name); + user_model.last_name = Set(last_name); + user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string())); + user_model.is_verified = Set(true); + user_model.is_active = Set(true); + user_model.role_id = Set(role_uuid); + user_model.updated_at = Set(Utc::now()); + + if is_update { + user_model.update(db).await?; + } else { + user_model.insert(db).await?; + } + } println!("✅ All Users seeded"); Ok(()) -} \ No newline at end of file +} diff --git a/imphnen-backend/src/bin/test_postgres.rs b/imphnen-backend/src/bin/test_postgres.rs index 0e841c6..25e12f2 100644 --- a/imphnen-backend/src/bin/test_postgres.rs +++ b/imphnen-backend/src/bin/test_postgres.rs @@ -1,368 +1,395 @@ -//! PostgreSQL Connection Test Program -//! This program tests the PostgreSQL integration with SeaORM - -use std::sync::Arc; -use imphnen_libs::postgres::{PostgresConfig, PostgresConnection, PostgresError}; -use imphnen_entities::seaorm::auth::users::{Entity as UsersEntity, Model as UserModel}; -use imphnen_entities::seaorm::auth::roles::{Entity as RolesEntity, Model as RoleModel}; -use sea_orm::{EntityTrait, ActiveModelTrait, Set, TransactionTrait, DbErr, PaginatorTrait}; -use uuid::Uuid; -use chrono::Utc; - -#[tokio::main] -async fn main() -> Result<(), Box> { - println!("🚀 Starting PostgreSQL Connection Test"); - println!("====================================="); - - // Load configuration from environment - let config = PostgresConfig::from_env()?; - println!("✅ Configuration loaded successfully"); - println!(" Database URL: {}", config.database_url.replace("postgres://", "postgres://****:****@")); - println!(" Pool size: {}", config.pool_size); - println!(" Connect timeout: {}s", config.connect_timeout); - println!(" Retry attempts: {}", config.retry_attempts); - - // Test connection - println!("\n🔌 Testing PostgreSQL connection..."); - match test_connection(config).await { - Ok(()) => { - println!("✅ All PostgreSQL tests passed successfully!"); - Ok(()) - } - Err(e) => { - println!("❌ PostgreSQL test failed: {}", e); - Err(e.into()) - } - } -} - -async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> { - // Create connection - println!(" Creating PostgreSQL connection..."); - let postgres_conn = PostgresConnection::new(config).await?; - let connection = Arc::new(postgres_conn); - println!(" ✅ Connection established successfully"); - - // Test basic connectivity - println!(" Testing basic connectivity..."); - test_basic_connectivity(&connection).await?; - println!(" ✅ Basic connectivity test passed"); - - // Test table existence - println!(" Testing table existence..."); - test_table_existence(&connection).await?; - println!(" ✅ Table existence test passed"); - - // Test CRUD operations - println!(" Testing CRUD operations..."); - test_crud_operations(&connection).await?; - println!(" ✅ CRUD operations test passed"); - - // Test transaction support - println!(" Testing transaction support..."); - test_transactions(&connection).await?; - println!(" ✅ Transaction support test passed"); - - // Test error handling - println!(" Testing error handling..."); - test_error_handling(&connection).await?; - println!(" ✅ Error handling test passed"); - - Ok(()) -} - -async fn test_basic_connectivity(connection: &Arc) -> Result<(), PostgresError> { - // Execute a simple query - let statement = sea_orm::Statement::from_string( - connection.get_database_backend(), - "SELECT 1 as test_value, current_timestamp as current_time".to_string() - ); - - let result = connection.query_one(statement).await? - .ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("No results returned".to_string())))?; - - // Verify we got expected results - let test_value: Option = result.try_get("", "test_value").ok(); - let current_time: Option = result.try_get("", "current_time").ok(); - - if test_value != Some(1) { - return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( - format!("Expected test_value=1, got {:?}", test_value) - ))); - } - - if current_time.is_none() { - return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( - "Expected current_time to be set".to_string() - ))); - } - - println!(" 📝 Query result: test_value={:?}, current_time={:?}", test_value, current_time); - Ok(()) -} - -async fn test_table_existence(connection: &Arc) -> Result<(), PostgresError> { - // Test if our tables exist - use sea_orm::EntityTrait; - - println!(" 📋 Checking users table..."); - let user_count = UsersEntity::find() - .count(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)?; - println!(" 📊 Users table accessible, current count: {}", user_count); - - println!(" 📋 Checking roles table..."); - let role_count = RolesEntity::find() - .count(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)?; - println!(" 📊 Roles table accessible, current count: {}", role_count); - - Ok(()) -} - -async fn test_crud_operations(connection: &Arc) -> Result<(), PostgresError> { - use sea_orm::{ActiveModelTrait, Set}; - - // Create test user - println!(" ➕ Creating test user..."); - let test_user_id = Uuid::new_v4(); - let now = Utc::now(); - - let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { - id: Set(test_user_id), - email: Set(format!("test_user_{}@example.com", test_user_id)), - password_hash: Set("test_password_hash".to_string()), - username: Set(format!("testuser_{}", test_user_id)), - first_name: Set(Some("Test".to_string())), - last_name: Set(Some("User".to_string())), - avatar_url: Set(None), - is_verified: Set(false), - is_active: Set(true), - metadata: Set(None), - role_id: Set(None), - created_at: Set(now), - updated_at: Set(now), - deleted_at: Set(None), - }; - - let created_user = user_model.insert(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)?; - - println!(" ✅ Created user with ID: {}", created_user.id); - - // Read user - println!(" 🔍 Reading test user..."); - let found_user = UsersEntity::find_by_id(test_user_id) - .one(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)? - .ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("User not found after creation".to_string())))?; - - println!(" ✅ Found user: {} ({})", found_user.username, found_user.email); - - // Update user - println!(" ✏️ Updating test user..."); - let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel = found_user.into(); - update_model.first_name = Set(Some("Updated".to_string())); - update_model.updated_at = Set(Utc::now()); - - let updated_user = update_model.update(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)?; - - println!(" ✅ Updated user first name to: {:?}", updated_user.first_name); - - // Delete user - println!(" 🗑️ Deleting test user..."); - UsersEntity::delete_by_id(updated_user.id) - .exec(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)?; - - println!(" ✅ Test user deleted successfully"); - - Ok(()) -} - -async fn test_transactions(connection: &Arc) -> Result<(), PostgresError> { - println!(" 💰 Testing transaction support..."); - - // Test transaction with rollback - let transaction_result = connection.conn.transaction(|txn| { - Box::pin(async move { - // Create a test user within transaction - let test_user_id = Uuid::new_v4(); - let now = Utc::now(); - - let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { - id: Set(test_user_id), - email: Set(format!("transaction_test_{}@example.com", test_user_id)), - password_hash: Set("transaction_password_hash".to_string()), - username: Set(format!("transaction_user_{}", test_user_id)), - first_name: Set(Some("Transaction".to_string())), - last_name: Set(Some("Test".to_string())), - avatar_url: Set(None), - is_verified: Set(false), - is_active: Set(true), - metadata: Set(None), - role_id: Set(None), - created_at: Set(now), - updated_at: Set(now), - deleted_at: Set(None), - }; - - let _created_user = user_model.insert(txn) - .await?; - - // Simulate an error to trigger rollback (return a sea_orm::DbErr so the TransactionError matches) - Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string())) - }) - }).await; - - // Transaction should fail and rollback - match transaction_result { - Err(e) => { - let e_text = format!("{:?}", e); - if e_text.contains("Simulated transaction failure") { - println!(" ✅ Transaction failed as expected, rollback successful"); - } else { - return Err(PostgresError::OperationFailed(format!("Unexpected transaction result: {}", e_text))); - } - } - Ok(_) => { - return Err(PostgresError::OperationFailed("Unexpected transaction result: transaction unexpectedly succeeded".to_string())); - } - } - - // Verify user was not created (due to rollback) - let user_exists = UsersEntity::find_by_id(Uuid::nil()) // Use nil UUID as we don't know the actual ID - .one(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)? - .is_some(); - - if user_exists { - println!(" ⚠️ User found despite rollback - this might indicate an issue"); - } else { - println!(" ✅ Transaction rollback verified - user not found"); - } - - Ok(()) -} - -async fn test_error_handling(connection: &Arc) -> Result<(), PostgresError> { - println!(" ⚠️ Testing error handling..."); - - // Test invalid UUID - println!(" 🔍 Testing invalid UUID handling..."); - let invalid_uuid = Uuid::nil(); // This should exist or be handled gracefully - - match UsersEntity::find_by_id(invalid_uuid) - .one(&connection.conn) - .await - .map_err(PostgresError::ConnectionError)? - { - Some(_) => println!(" ✅ Found user with nil UUID (expected in some cases)"), - None => println!(" ✅ No user found with nil UUID (expected)"), - } - - // Test invalid query - println!(" 🔍 Testing invalid query handling..."); - let invalid_statement = sea_orm::Statement::from_string( - connection.get_database_backend(), - "SELECT * FROM non_existent_table".to_string() - ); - - match connection.execute(invalid_statement).await { - Err(_) => println!(" ✅ Invalid query properly handled with error"), - Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"), - } - - Ok(()) -} - -/// Additional utility functions for comprehensive testing -pub mod test_utils { - use super::*; - - /// Create a test PostgreSQL configuration - pub fn create_test_config() -> PostgresConfig { - PostgresConfig { - database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test".to_string(), - pool_size: 5, - connect_timeout: 10, - idle_timeout: 30, - max_lifetime: Some(600), - retry_attempts: 2, - retry_delay: 1, - } - } - - /// Create a test user model - pub fn create_test_user_model() -> UserModel { - UserModel { - id: Uuid::new_v4(), - email: format!("test_{}@example.com", Uuid::new_v4()), - password_hash: "test_password_hash".to_string(), - username: format!("testuser_{}", Uuid::new_v4()), - first_name: Some("Test".to_string()), - last_name: Some("User".to_string()), - avatar_url: None, - is_verified: false, - is_active: true, - metadata: None, - role_id: None, - created_at: Utc::now(), - updated_at: Utc::now(), - deleted_at: None, - } - } - - /// Create a test role model - pub fn create_test_role_model() -> RoleModel { - RoleModel { - id: Uuid::new_v4(), - name: format!("test_role_{}", Uuid::new_v4()), - description: "Test role description".to_string(), - permissions: Some(serde_json::json!(["test.permission"])), - is_system_role: false, - is_default: false, - created_at: Utc::now(), - updated_at: Utc::now(), - deleted_at: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_test_config() { - let config = test_utils::create_test_config(); - assert_eq!(config.pool_size, 5); - assert_eq!(config.connect_timeout, 10); - assert!(config.database_url.contains("imphnen_test")); - } - - #[test] - fn test_create_test_user_model() { - let user = test_utils::create_test_user_model(); - assert!(!user.email.is_empty()); - assert!(!user.username.is_empty()); - assert!(user.is_active); - // is_admin field removed; instead, check role-based permission or is_active - } - - #[test] - fn test_create_test_role_model() { - let role = test_utils::create_test_role_model(); - assert!(!role.name.is_empty()); - assert!(role.permissions.is_some()); - assert!(!role.is_system_role); - } -} \ No newline at end of file +use chrono::Utc; +use imphnen_entities::seaorm::auth::roles::{ + Entity as RolesEntity, Model as RoleModel, +}; +use imphnen_entities::seaorm::auth::users::{ + Entity as UsersEntity, Model as UserModel, +}; +use imphnen_libs::postgres::{PostgresConfig, PostgresConnection, PostgresError}; +use sea_orm::{ + ActiveModelTrait, DbErr, EntityTrait, PaginatorTrait, Set, TransactionTrait, +}; +use std::sync::Arc; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("🚀 Starting PostgreSQL Connection Test"); + println!("====================================="); + + let config = PostgresConfig::from_env()?; + println!("✅ Configuration loaded successfully"); + println!( + " Database URL: {}", + config + .database_url + .replace("postgres://", "postgres://****:****@") + ); + println!(" Pool size: {}", config.pool_size); + println!(" Connect timeout: {}s", config.connect_timeout); + println!(" Retry attempts: {}", config.retry_attempts); + + println!("\n🔌 Testing PostgreSQL connection..."); + match test_connection(config).await { + Ok(()) => { + println!("✅ All PostgreSQL tests passed successfully!"); + Ok(()) + } + Err(e) => { + println!("❌ PostgreSQL test failed: {}", e); + Err(e.into()) + } + } +} + +async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> { + println!(" Creating PostgreSQL connection..."); + let postgres_conn = PostgresConnection::new(config).await?; + let connection = Arc::new(postgres_conn); + println!(" ✅ Connection established successfully"); + + println!(" Testing basic connectivity..."); + test_basic_connectivity(&connection).await?; + println!(" ✅ Basic connectivity test passed"); + + println!(" Testing table existence..."); + test_table_existence(&connection).await?; + println!(" ✅ Table existence test passed"); + + println!(" Testing CRUD operations..."); + test_crud_operations(&connection).await?; + println!(" ✅ CRUD operations test passed"); + + println!(" Testing transaction support..."); + test_transactions(&connection).await?; + println!(" ✅ Transaction support test passed"); + + println!(" Testing error handling..."); + test_error_handling(&connection).await?; + println!(" ✅ Error handling test passed"); + + Ok(()) +} + +async fn test_basic_connectivity( + connection: &Arc, +) -> Result<(), PostgresError> { + let statement = sea_orm::Statement::from_string( + connection.get_database_backend(), + "SELECT 1 as test_value, current_timestamp as current_time".to_string(), + ); + + let result = connection.query_one(statement).await?.ok_or_else(|| { + PostgresError::ConnectionError(sea_orm::DbErr::Custom( + "No results returned".to_string(), + )) + })?; + + let test_value: Option = result.try_get("", "test_value").ok(); + let current_time: Option = result.try_get("", "current_time").ok(); + + if test_value != Some(1) { + return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( + format!("Expected test_value=1, got {:?}", test_value), + ))); + } + + if current_time.is_none() { + return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( + "Expected current_time to be set".to_string(), + ))); + } + + println!( + " 📝 Query result: test_value={:?}, current_time={:?}", + test_value, current_time + ); + Ok(()) +} + +async fn test_table_existence( + connection: &Arc, +) -> Result<(), PostgresError> { + use sea_orm::EntityTrait; + + println!(" 📋 Checking users table..."); + let user_count = UsersEntity::find() + .count(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)?; + println!( + " 📊 Users table accessible, current count: {}", + user_count + ); + + println!(" 📋 Checking roles table..."); + let role_count = RolesEntity::find() + .count(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)?; + println!( + " 📊 Roles table accessible, current count: {}", + role_count + ); + + Ok(()) +} + +async fn test_crud_operations( + connection: &Arc, +) -> Result<(), PostgresError> { + use sea_orm::{ActiveModelTrait, Set}; + + println!(" ➕ Creating test user..."); + let test_user_id = Uuid::new_v4(); + let now = Utc::now(); + + let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { + id: Set(test_user_id), + email: Set(format!("test_user_{}@example.com", test_user_id)), + password_hash: Set("test_password_hash".to_string()), + username: Set(format!("testuser_{}", test_user_id)), + first_name: Set(Some("Test".to_string())), + last_name: Set(Some("User".to_string())), + avatar_url: Set(None), + is_verified: Set(false), + is_active: Set(true), + metadata: Set(None), + role_id: Set(None), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + }; + + let created_user = user_model + .insert(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)?; + + println!(" ✅ Created user with ID: {}", created_user.id); + + println!(" 🔍 Reading test user..."); + let found_user = UsersEntity::find_by_id(test_user_id) + .one(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)? + .ok_or_else(|| { + PostgresError::ConnectionError(sea_orm::DbErr::Custom( + "User not found after creation".to_string(), + )) + })?; + + println!( + " ✅ Found user: {} ({})", + found_user.username, found_user.email + ); + + println!(" ✏️ Updating test user..."); + let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel = + found_user.into(); + update_model.first_name = Set(Some("Updated".to_string())); + update_model.updated_at = Set(Utc::now()); + + let updated_user = update_model + .update(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)?; + + println!( + " ✅ Updated user first name to: {:?}", + updated_user.first_name + ); + + println!(" 🗑️ Deleting test user..."); + UsersEntity::delete_by_id(updated_user.id) + .exec(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)?; + + println!(" ✅ Test user deleted successfully"); + + Ok(()) +} + +async fn test_transactions( + connection: &Arc, +) -> Result<(), PostgresError> { + println!(" 💰 Testing transaction support..."); + + let transaction_result = connection + .conn + .transaction(|txn| { + Box::pin(async move { + let test_user_id = Uuid::new_v4(); + let now = Utc::now(); + + let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { + id: Set(test_user_id), + email: Set(format!("transaction_test_{}@example.com", test_user_id)), + password_hash: Set("transaction_password_hash".to_string()), + username: Set(format!("transaction_user_{}", test_user_id)), + first_name: Set(Some("Transaction".to_string())), + last_name: Set(Some("Test".to_string())), + avatar_url: Set(None), + is_verified: Set(false), + is_active: Set(true), + metadata: Set(None), + role_id: Set(None), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + }; + + let _created_user = user_model.insert(txn).await?; + + Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string())) + }) + }) + .await; + + match transaction_result { + Err(e) => { + let e_text = format!("{:?}", e); + if e_text.contains("Simulated transaction failure") { + println!(" ✅ Transaction failed as expected, rollback successful"); + } else { + return Err(PostgresError::OperationFailed(format!( + "Unexpected transaction result: {}", + e_text + ))); + } + } + Ok(_) => { + return Err(PostgresError::OperationFailed( + "Unexpected transaction result: transaction unexpectedly succeeded" + .to_string(), + )); + } + } + + let user_exists = UsersEntity::find_by_id(Uuid::nil()) + .one(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)? + .is_some(); + + if user_exists { + println!(" ⚠️ User found despite rollback - this might indicate an issue"); + } else { + println!(" ✅ Transaction rollback verified - user not found"); + } + + Ok(()) +} + +async fn test_error_handling( + connection: &Arc, +) -> Result<(), PostgresError> { + println!(" ⚠️ Testing error handling..."); + + println!(" 🔍 Testing invalid UUID handling..."); + let invalid_uuid = Uuid::nil(); + + match UsersEntity::find_by_id(invalid_uuid) + .one(&connection.conn) + .await + .map_err(PostgresError::ConnectionError)? + { + Some(_) => { + println!(" ✅ Found user with nil UUID (expected in some cases)") + } + None => println!(" ✅ No user found with nil UUID (expected)"), + } + + println!(" 🔍 Testing invalid query handling..."); + let invalid_statement = sea_orm::Statement::from_string( + connection.get_database_backend(), + "SELECT * FROM non_existent_table".to_string(), + ); + + match connection.execute(invalid_statement).await { + Err(_) => println!(" ✅ Invalid query properly handled with error"), + Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"), + } + + Ok(()) +} + +pub mod test_utils { + use super::*; + + pub fn create_test_config() -> PostgresConfig { + PostgresConfig { + database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test" + .to_string(), + pool_size: 5, + connect_timeout: 10, + idle_timeout: 30, + max_lifetime: Some(600), + retry_attempts: 2, + retry_delay: 1, + } + } + + pub fn create_test_user_model() -> UserModel { + UserModel { + id: Uuid::new_v4(), + email: format!("test_{}@example.com", Uuid::new_v4()), + password_hash: "test_password_hash".to_string(), + username: format!("testuser_{}", Uuid::new_v4()), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + avatar_url: None, + is_verified: false, + is_active: true, + metadata: None, + role_id: None, + created_at: Utc::now(), + updated_at: Utc::now(), + deleted_at: None, + } + } + + pub fn create_test_role_model() -> RoleModel { + RoleModel { + id: Uuid::new_v4(), + name: format!("test_role_{}", Uuid::new_v4()), + description: "Test role description".to_string(), + permissions: Some(serde_json::json!(["test.permission"])), + is_system_role: false, + is_default: false, + created_at: Utc::now(), + updated_at: Utc::now(), + deleted_at: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_test_config() { + let config = test_utils::create_test_config(); + assert_eq!(config.pool_size, 5); + assert_eq!(config.connect_timeout, 10); + assert!(config.database_url.contains("imphnen_test")); + } + + #[test] + fn test_create_test_user_model() { + let user = test_utils::create_test_user_model(); + assert!(!user.email.is_empty()); + assert!(!user.username.is_empty()); + assert!(user.is_active); + } + + #[test] + fn test_create_test_role_model() { + let role = test_utils::create_test_role_model(); + assert!(!role.name.is_empty()); + assert!(role.permissions.is_some()); + assert!(!role.is_system_role); + } +} diff --git a/imphnen-backend/src/main.rs b/imphnen-backend/src/main.rs index cad3cc6..877d166 100644 --- a/imphnen-backend/src/main.rs +++ b/imphnen-backend/src/main.rs @@ -1,13 +1,10 @@ -use imphnen_gateway::gateway_service; -use imphnen_libs::axum_init; - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt::init(); - - let _ = axum_init(|postgres_conn| async { - // PostgreSQL is now the primary database - SurrealDB has been completely removed - gateway_service(postgres_conn).await - }) - .await; -} +use imphnen_gateway::gateway_service; +use imphnen_libs::axum_init; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let _ = + axum_init(|postgres_conn| async { gateway_service(postgres_conn).await }).await; +} diff --git a/imphnen-cms/Cargo.toml b/imphnen-cms/Cargo.toml index 6ccdc7b..b3ab501 100644 --- a/imphnen-cms/Cargo.toml +++ b/imphnen-cms/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-cms" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] @@ -33,6 +33,9 @@ paginator-rs.workspace = true paginator-utils.workspace = true paginator-sea-orm.workspace = true paginator-axum.workspace = true +sqlx.workspace = true +image.workspace = true +qrcode.workspace = true [package.metadata.validator.regex] VALID_URL_REGEX = "^https?://" diff --git a/imphnen-cms/src/events/application/event_service.rs b/imphnen-cms/src/events/application/event_service.rs index 6c64195..da8dccb 100644 --- a/imphnen-cms/src/events/application/event_service.rs +++ b/imphnen-cms/src/events/application/event_service.rs @@ -1,40 +1,43 @@ -use std::sync::Arc; +use crate::events::domain::{EventEntity, EventRepository, EventService}; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use crate::events::domain::{EventEntity, EventRepository, EventService}; pub struct EventServiceImpl { - repo: Arc, + repo: Arc, } impl EventServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl EventService for EventServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: Uuid) -> Result { - self.repo.find_by_id(id).await - } + async fn get(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } - async fn create(&self, entity: EventEntity) -> Result<(), AppError> { - self.repo.create(entity).await - } + async fn create(&self, entity: EventEntity) -> Result<(), AppError> { + self.repo.create(entity).await + } - async fn update(&self, entity: EventEntity) -> Result<(), AppError> { - self.repo.update(entity).await - } + async fn update(&self, entity: EventEntity) -> Result<(), AppError> { + self.repo.update(entity).await + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.delete(id).await - } + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } } diff --git a/imphnen-cms/src/events/domain/event.rs b/imphnen-cms/src/events/domain/event.rs index 88e3668..73bd868 100644 --- a/imphnen-cms/src/events/domain/event.rs +++ b/imphnen-cms/src/events/domain/event.rs @@ -3,16 +3,16 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct EventEntity { - pub id: Uuid, - pub name: String, - pub description: String, - pub detail_link: String, - pub price: f64, - pub is_online: bool, - pub is_deleted: bool, - pub location: Option, - pub start_date: DateTime, - pub end_date: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub name: String, + pub description: String, + pub detail_link: String, + pub price: f64, + pub is_online: bool, + pub is_deleted: bool, + pub location: Option, + pub start_date: DateTime, + pub end_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, } diff --git a/imphnen-cms/src/events/domain/repository.rs b/imphnen-cms/src/events/domain/repository.rs index 21d15f3..98007ba 100644 --- a/imphnen-cms/src/events/domain/repository.rs +++ b/imphnen-cms/src/events/domain/repository.rs @@ -1,15 +1,18 @@ +use super::event::EventEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::event::EventEntity; #[async_trait] pub trait EventRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: Uuid) -> Result; - async fn create(&self, entity: EventEntity) -> Result<(), AppError>; - async fn update(&self, entity: EventEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn create(&self, entity: EventEntity) -> Result<(), AppError>; + async fn update(&self, entity: EventEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-cms/src/events/domain/service.rs b/imphnen-cms/src/events/domain/service.rs index 69317a1..3599f41 100644 --- a/imphnen-cms/src/events/domain/service.rs +++ b/imphnen-cms/src/events/domain/service.rs @@ -1,15 +1,18 @@ +use super::event::EventEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::event::EventEntity; #[async_trait] pub trait EventService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: Uuid) -> Result; - async fn create(&self, entity: EventEntity) -> Result<(), AppError>; - async fn update(&self, entity: EventEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: Uuid) -> Result; + async fn create(&self, entity: EventEntity) -> Result<(), AppError>; + async fn update(&self, entity: EventEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-cms/src/events/infrastructure/http/dto.rs b/imphnen-cms/src/events/infrastructure/http/dto.rs index 1ab90cd..9e73f21 100644 --- a/imphnen-cms/src/events/infrastructure/http/dto.rs +++ b/imphnen-cms/src/events/infrastructure/http/dto.rs @@ -1,131 +1,131 @@ +use crate::events::domain::event::EventEntity; use chrono::{DateTime, Utc}; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::events::domain::event::EventEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct EventsCreateRequestDto { - pub name: String, - pub description: String, - pub detail_link: String, - pub price: f64, - #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] - pub end_date: DateTime, - #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] - pub start_date: DateTime, - pub location: Option, - pub is_online: bool, + pub name: String, + pub description: String, + pub detail_link: String, + pub price: f64, + #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] + pub end_date: DateTime, + #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] + pub start_date: DateTime, + pub location: Option, + pub is_online: bool, } impl ZodValidate for EventsCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } impl From for EventEntity { - fn from(dto: EventsCreateRequestDto) -> Self { - EventEntity { - id: Uuid::new_v4(), - name: dto.name, - description: dto.description, - detail_link: dto.detail_link, - price: dto.price, - is_online: dto.is_online, - is_deleted: false, - location: dto.location, - start_date: dto.start_date, - end_date: dto.end_date, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - } - } + fn from(dto: EventsCreateRequestDto) -> Self { + EventEntity { + id: Uuid::new_v4(), + name: dto.name, + description: dto.description, + detail_link: dto.detail_link, + price: dto.price, + is_online: dto.is_online, + is_deleted: false, + location: dto.location, + start_date: dto.start_date, + end_date: dto.end_date, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct EventsUpdateRequestDto { - pub name: String, - #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] - pub end_date: DateTime, - #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] - pub start_date: DateTime, - pub price: f64, - pub is_online: bool, - pub description: String, - pub detail_link: String, - pub location: Option, + pub name: String, + #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] + pub end_date: DateTime, + #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] + pub start_date: DateTime, + pub price: f64, + pub is_online: bool, + pub description: String, + pub detail_link: String, + pub location: Option, } impl ZodValidate for EventsUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct EventsListItemDto { - pub id: String, - pub name: String, - pub description: String, - pub detail_link: String, - pub price: f64, - pub is_online: bool, - pub start_date: String, - pub end_date: String, - pub created_at: String, - pub location: Option, - pub is_deleted: bool, + 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, + pub is_deleted: bool, } impl From for EventsListItemDto { - fn from(e: EventEntity) -> Self { - EventsListItemDto { - id: e.id.to_string(), - name: e.name, - description: e.description, - detail_link: e.detail_link, - price: e.price, - is_online: e.is_online, - start_date: e.start_date.to_rfc3339(), - end_date: e.end_date.to_rfc3339(), - created_at: e.created_at.to_rfc3339(), - location: e.location, - is_deleted: e.is_deleted, - } - } + fn from(e: EventEntity) -> Self { + EventsListItemDto { + id: e.id.to_string(), + name: e.name, + description: e.description, + detail_link: e.detail_link, + price: e.price, + is_online: e.is_online, + start_date: e.start_date.to_rfc3339(), + end_date: e.end_date.to_rfc3339(), + created_at: e.created_at.to_rfc3339(), + location: e.location, + is_deleted: e.is_deleted, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct EventsDetailItemDto { - pub id: String, - pub name: String, - pub description: String, - pub detail_link: String, - pub price: f64, - pub is_online: bool, - pub start_date: String, - pub end_date: String, - pub created_at: String, - pub updated_at: String, - pub location: Option, + 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, } impl From for EventsDetailItemDto { - fn from(e: EventEntity) -> Self { - EventsDetailItemDto { - id: e.id.to_string(), - name: e.name, - description: e.description, - detail_link: e.detail_link, - price: e.price, - is_online: e.is_online, - start_date: e.start_date.to_rfc3339(), - end_date: e.end_date.to_rfc3339(), - created_at: e.created_at.to_rfc3339(), - updated_at: e.updated_at.to_rfc3339(), - location: e.location, - } - } + fn from(e: EventEntity) -> Self { + EventsDetailItemDto { + id: e.id.to_string(), + name: e.name, + description: e.description, + detail_link: e.detail_link, + price: e.price, + is_online: e.is_online, + start_date: e.start_date.to_rfc3339(), + end_date: e.end_date.to_rfc3339(), + created_at: e.created_at.to_rfc3339(), + updated_at: e.updated_at.to_rfc3339(), + location: e.location, + } + } } diff --git a/imphnen-cms/src/events/infrastructure/http/handlers.rs b/imphnen-cms/src/events/infrastructure/http/handlers.rs index 8201acf..b294313 100644 --- a/imphnen-cms/src/events/infrastructure/http/handlers.rs +++ b/imphnen-cms/src/events/infrastructure/http/handlers.rs @@ -1,15 +1,23 @@ -use std::sync::Arc; -use axum::{Extension, extract::Path, http::HeaderMap, response::{IntoResponse, Response}}; -use paginator_axum::PaginationQuery; -use paginator_utils::PaginatorResponse; -use uuid::Uuid; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage}; +use super::dto::{ + EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, + EventsUpdateRequestDto, +}; +use crate::events::domain::EventService; +use axum::{ + Extension, + extract::Path, + http::HeaderMap, + response::{IntoResponse, Response}, +}; use imphnen_entities::ResponseSuccessDto; use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; use imphnen_utils::AppError; -use super::dto::{EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsUpdateRequestDto}; -use crate::events::domain::EventService; +use imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -27,19 +35,24 @@ use crate::events::domain::EventService; tag = "Events" )] pub async fn get_event_list( - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, ) -> Response { - match service.list(params).await { - Ok(result) => { - let mapped = PaginatorResponse { - data: result.data.into_iter().map(EventsListItemDto::from).collect::>(), - meta: result.meta, - }; - ApiPaginated(mapped).into_response() - } - Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } + match service.list(params).await { + Ok(result) => { + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(EventsListItemDto::from) + .collect::>(), + meta: result.meta, + }; + ApiPaginated(mapped).into_response() + } + Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string()) + .into_response(), + } } #[utoipa::path( @@ -54,17 +67,24 @@ pub async fn get_event_list( tag = "Events" )] pub async fn get_event_by_id( - Extension(service): Extension>, - Path(id): Path, + Extension(service): Extension>, + Path(id): Path, ) -> Response { - let uuid = match Uuid::parse_str(&id) { - Ok(u) => u, - Err(e) => return ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}")).into_response(), - }; - match service.get(uuid).await { - Ok(event) => ApiSuccess(EventsDetailItemDto::from(event)).into_response(), - Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string()).into_response(), - } + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(e) => { + return ApiMessage::new( + axum::http::StatusCode::BAD_REQUEST, + format!("Invalid UUID: {e}"), + ) + .into_response(); + } + }; + match service.get(uuid).await { + Ok(event) => ApiSuccess(EventsDetailItemDto::from(event)).into_response(), + Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string()) + .into_response(), + } } #[utoipa::path( @@ -78,16 +98,16 @@ pub async fn get_event_by_id( tag = "Events" )] pub async fn post_create_event( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::Administrator], { - let entity = payload.into(); - service.create(entity).await?; - Ok(ApiMessage::created("Event created")) - }) + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let entity = payload.into(); + service.create(entity).await?; + Ok(ApiMessage::created("Event created")) + }) } #[utoipa::path( @@ -104,33 +124,33 @@ pub async fn post_create_event( tag = "Events" )] pub async fn patch_update_event( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::Administrator], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let existing = service.get(uuid).await?; - let entity = crate::events::domain::EventEntity { - id: existing.id, - name: payload.name, - description: payload.description, - detail_link: payload.detail_link, - price: payload.price, - is_online: payload.is_online, - location: payload.location, - start_date: payload.start_date, - end_date: payload.end_date, - is_deleted: existing.is_deleted, - created_at: existing.created_at, - updated_at: chrono::Utc::now(), - }; - service.update(entity).await?; - Ok(ApiMessage::ok("Event updated")) - }) + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let existing = service.get(uuid).await?; + let entity = crate::events::domain::EventEntity { + id: existing.id, + name: payload.name, + description: payload.description, + detail_link: payload.detail_link, + price: payload.price, + is_online: payload.is_online, + location: payload.location, + start_date: payload.start_date, + end_date: payload.end_date, + is_deleted: existing.is_deleted, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + service.update(entity).await?; + Ok(ApiMessage::ok("Event updated")) + }) } #[utoipa::path( @@ -146,15 +166,15 @@ pub async fn patch_update_event( tag = "Events" )] pub async fn delete_event( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::Administrator], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - service.delete(uuid).await?; - Ok(ApiMessage::ok("Event deleted")) - }) + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + service.delete(uuid).await?; + Ok(ApiMessage::ok("Event deleted")) + }) } diff --git a/imphnen-cms/src/events/infrastructure/http/mod.rs b/imphnen-cms/src/events/infrastructure/http/mod.rs index 75c3e19..4d8edee 100644 --- a/imphnen-cms/src/events/infrastructure/http/mod.rs +++ b/imphnen-cms/src/events/infrastructure/http/mod.rs @@ -2,4 +2,4 @@ pub mod dto; pub mod handlers; pub mod routes; -pub use routes::{events_public_routes, events_protected_routes}; +pub use routes::{events_protected_routes, events_public_routes}; diff --git a/imphnen-cms/src/events/infrastructure/http/routes.rs b/imphnen-cms/src/events/infrastructure/http/routes.rs index e2c52e0..00c6e7b 100644 --- a/imphnen-cms/src/events/infrastructure/http/routes.rs +++ b/imphnen-cms/src/events/infrastructure/http/routes.rs @@ -1,31 +1,35 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, patch, post}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{ + delete_event, get_event_by_id, get_event_list, patch_update_event, + post_create_event, +}; use crate::events::application::EventServiceImpl; use crate::events::domain::EventService; use crate::events::infrastructure::persistence::PostgresEventRepository; -use super::handlers::{ - delete_event, get_event_by_id, get_event_list, patch_update_event, post_create_event, +use axum::{ + Extension, Router, + routing::{delete, get, patch, post}, }; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresEventRepository::new(db)); - Arc::new(EventServiceImpl::new(repo)) + let repo = Arc::new(PostgresEventRepository::new(db)); + Arc::new(EventServiceImpl::new(repo)) } pub fn events_public_routes(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/cms/landing/events", get(get_event_list)) - .route("/cms/landing/events/detail/{id}", get(get_event_by_id)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/cms/landing/events", get(get_event_list)) + .route("/cms/landing/events/detail/{id}", get(get_event_by_id)) + .layer(Extension(service)) } pub fn events_protected_routes(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/cms/landing/events/create", post(post_create_event)) - .route("/cms/landing/events/update/{id}", patch(patch_update_event)) - .route("/cms/landing/events/delete/{id}", delete(delete_event)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/cms/landing/events/create", post(post_create_event)) + .route("/cms/landing/events/update/{id}", patch(patch_update_event)) + .route("/cms/landing/events/delete/{id}", delete(delete_event)) + .layer(Extension(service)) } diff --git a/imphnen-cms/src/events/infrastructure/persistence/postgres_event_repository.rs b/imphnen-cms/src/events/infrastructure/persistence/postgres_event_repository.rs index c7f3e3d..951c0e8 100644 --- a/imphnen-cms/src/events/infrastructure/persistence/postgres_event_repository.rs +++ b/imphnen-cms/src/events/infrastructure/persistence/postgres_event_repository.rs @@ -1,149 +1,161 @@ -use std::sync::Arc; +use crate::events::domain::{event::EventEntity, repository::EventRepository}; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait}; +use imphnen_entities::seaorm::common::events::{ + ActiveModel as EventsActiveModel, Column as EventsColumn, Entity as EventsEntity, + Model as EventsModel, +}; +use imphnen_utils::AppError; use paginator_rs::{PaginationParams, SortDirection}; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::common::events::{ - Entity as EventsEntity, Column as EventsColumn, - ActiveModel as EventsActiveModel, Model as EventsModel, -}; -use crate::events::domain::{event::EventEntity, repository::EventRepository}; fn to_entity(model: EventsModel) -> EventEntity { - EventEntity { - id: model.id, - name: model.name, - description: model.description, - detail_link: model.detail_link, - price: model.price, - is_online: model.is_online, - is_deleted: model.is_deleted, - location: model.location, - start_date: model.start_date, - end_date: model.end_date, - created_at: model.created_at, - updated_at: model.updated_at, - } + EventEntity { + id: model.id, + name: model.name, + description: model.description, + detail_link: model.detail_link, + price: model.price, + is_online: model.is_online, + is_deleted: model.is_deleted, + location: model.location, + start_date: model.start_date, + end_date: model.end_date, + created_at: model.created_at, + updated_at: model.updated_at, + } } pub struct PostgresEventRepository { - db: Arc, + db: Arc, } impl PostgresEventRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl EventRepository for PostgresEventRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); - let mut query = EventsEntity::find() - .filter(EventsColumn::IsDeleted.eq(false)); + let mut query = EventsEntity::find().filter(EventsColumn::IsDeleted.eq(false)); - if let Some(ref search) = params.search { - query = query.filter(EventsColumn::Name.contains(&search.query)); - } + if let Some(ref search) = params.search { + query = query.filter(EventsColumn::Name.contains(&search.query)); + } - query = match params.sort_by.as_deref() { - Some("name") => match params.sort_direction { - Some(SortDirection::Desc) => query.order_by(EventsColumn::Name, Order::Desc), - _ => query.order_by(EventsColumn::Name, Order::Asc), - }, - _ => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by(EventsColumn::CreatedAt, Order::Asc), - _ => query.order_by(EventsColumn::CreatedAt, Order::Desc), - }, - }; + query = match params.sort_by.as_deref() { + Some("name") => match params.sort_direction { + Some(SortDirection::Desc) => query.order_by(EventsColumn::Name, Order::Desc), + _ => query.order_by(EventsColumn::Name, Order::Asc), + }, + _ => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by(EventsColumn::CreatedAt, Order::Asc) + } + _ => query.order_by(EventsColumn::CreatedAt, Order::Desc), + }, + }; - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let total = paginator.num_items().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let events = paginator.fetch_page((page - 1) as u64).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let paginator = query.paginate(self.db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let events = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let data = events.into_iter().map(to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } + let data = events.into_iter().map(to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) + } - async fn find_by_id(&self, id: Uuid) -> Result { - let event = EventsEntity::find_by_id(id) - .filter(EventsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result { + let event = EventsEntity::find_by_id(id) + .filter(EventsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?; - Ok(to_entity(event)) - } + Ok(to_entity(event)) + } - async fn create(&self, entity: EventEntity) -> Result<(), AppError> { - let active_model = EventsActiveModel { - id: ActiveValue::Set(entity.id), - name: ActiveValue::Set(entity.name), - description: ActiveValue::Set(entity.description), - detail_link: ActiveValue::Set(entity.detail_link), - price: ActiveValue::Set(entity.price), - is_online: ActiveValue::Set(entity.is_online), - is_deleted: ActiveValue::Set(false), - location: ActiveValue::Set(entity.location), - start_date: ActiveValue::Set(entity.start_date), - end_date: ActiveValue::Set(entity.end_date), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - }; + async fn create(&self, entity: EventEntity) -> Result<(), AppError> { + let active_model = EventsActiveModel { + id: ActiveValue::Set(entity.id), + name: ActiveValue::Set(entity.name), + description: ActiveValue::Set(entity.description), + detail_link: ActiveValue::Set(entity.detail_link), + price: ActiveValue::Set(entity.price), + is_online: ActiveValue::Set(entity.is_online), + is_deleted: ActiveValue::Set(false), + location: ActiveValue::Set(entity.location), + start_date: ActiveValue::Set(entity.start_date), + end_date: ActiveValue::Set(entity.end_date), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + }; - EventsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + EventsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn update(&self, entity: EventEntity) -> Result<(), AppError> { - let mut active_model: EventsActiveModel = EventsEntity::find_by_id(entity.id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))? - .into(); + async fn update(&self, entity: EventEntity) -> Result<(), AppError> { + let mut active_model: EventsActiveModel = EventsEntity::find_by_id(entity.id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))? + .into(); - active_model.name = ActiveValue::Set(entity.name); - active_model.description = ActiveValue::Set(entity.description); - active_model.detail_link = ActiveValue::Set(entity.detail_link); - active_model.price = ActiveValue::Set(entity.price); - active_model.is_online = ActiveValue::Set(entity.is_online); - active_model.location = ActiveValue::Set(entity.location); - active_model.start_date = ActiveValue::Set(entity.start_date); - active_model.end_date = ActiveValue::Set(entity.end_date); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model.name = ActiveValue::Set(entity.name); + active_model.description = ActiveValue::Set(entity.description); + active_model.detail_link = ActiveValue::Set(entity.detail_link); + active_model.price = ActiveValue::Set(entity.price); + active_model.is_online = ActiveValue::Set(entity.is_online); + active_model.location = ActiveValue::Set(entity.location); + active_model.start_date = ActiveValue::Set(entity.start_date); + active_model.end_date = ActiveValue::Set(entity.end_date); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - let mut active_model: EventsActiveModel = EventsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))? - .into(); + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let mut active_model: EventsActiveModel = EventsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))? + .into(); - active_model.is_deleted = ActiveValue::Set(true); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + active_model.is_deleted = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } } diff --git a/imphnen-cms/src/events/mod.rs b/imphnen-cms/src/events/mod.rs index 0513e60..be12149 100644 --- a/imphnen-cms/src/events/mod.rs +++ b/imphnen-cms/src/events/mod.rs @@ -2,4 +2,4 @@ pub mod application; pub mod domain; pub mod infrastructure; -pub use infrastructure::http::{events_public_routes, events_protected_routes}; +pub use infrastructure::http::{events_protected_routes, events_public_routes}; diff --git a/imphnen-cms/src/lib.rs b/imphnen-cms/src/lib.rs index 3171ad6..5bd8c58 100644 --- a/imphnen-cms/src/lib.rs +++ b/imphnen-cms/src/lib.rs @@ -1,5 +1,7 @@ pub mod events; pub mod testimonials; +pub mod qr; -pub use events::{events_public_routes, events_protected_routes}; -pub use testimonials::{testimonials_public_routes, testimonials_protected_routes}; +pub use events::{events_protected_routes, events_public_routes}; +pub use testimonials::{testimonials_protected_routes, testimonials_public_routes}; +pub use qr::qr_router; diff --git a/imphnen-cms/src/qr/campaigns/application/campaign_service.rs b/imphnen-cms/src/qr/campaigns/application/campaign_service.rs new file mode 100644 index 0000000..b051195 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/application/campaign_service.rs @@ -0,0 +1,101 @@ +use async_trait::async_trait; +use image::{DynamicImage, GenericImageView, ImageFormat, imageops}; +use imphnen_utils::errors::AppError; +use qrcode::QrCode; +use std::io::Cursor; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::campaigns::domain::{ + entity::{CampaignEntity, CreateCampaignInput}, + repository::CampaignRepository, + service::QrCampaignService, +}; + +pub struct QrCampaignServiceImpl { + repo: Arc, +} + +impl QrCampaignServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl QrCampaignService for QrCampaignServiceImpl { + async fn create( + &self, + name: String, + url: String, + created_by: Uuid, + ) -> Result { + let qr = QrCode::new(url.as_bytes()) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let qr_img = qr + .render::>() + .min_dimensions(256, 256) + .build(); + let mut qr_bytes = Vec::new(); + DynamicImage::ImageLuma8(qr_img) + .write_to(&mut Cursor::new(&mut qr_bytes), ImageFormat::Png) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let input = CreateCampaignInput { + name, + url, + created_by, + qr_code_data: qr_bytes, + }; + self.repo.create(input).await + } + + async fn list_all(&self) -> Result, AppError> { + self.repo.find_all().await + } + + async fn get_active_qr_data(&self) -> Result>, AppError> { + self.repo.find_active_qr_data().await + } + + async fn set_active(&self, id: Uuid) -> Result { + self.repo.set_active(id).await + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } + + async fn process_image(&self, image_bytes: Vec) -> Result, AppError> { + let qr_data = self + .repo + .find_active_qr_data() + .await? + .ok_or_else(|| AppError::NotFoundError("No active campaign".to_string()))?; + + let img = image::load_from_memory(&image_bytes) + .map_err(|_| AppError::BadRequestError("Invalid image format".to_string()))?; + + let qr_img = image::load_from_memory(&qr_data).map_err(|_| { + AppError::InternalServerError("Failed to load QR data".to_string()) + })?; + + let (w, h) = img.dimensions(); + let qr_size = (std::cmp::min(w, h) / 5).max(100); + + let qr_resized = + qr_img.resize_exact(qr_size, qr_size, imageops::FilterType::Nearest); + + let mut output = img.to_rgba8(); + let x = (w - qr_size - 10) as i64; + let y = (h - qr_size - 10) as i64; + imageops::overlay(&mut output, &qr_resized.to_rgba8(), x, y); + + let mut out_bytes = Vec::new(); + DynamicImage::ImageRgba8(output) + .write_to(&mut Cursor::new(&mut out_bytes), ImageFormat::Png) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(out_bytes) + } +} diff --git a/imphnen-qr/src/campaigns/application/mod.rs b/imphnen-cms/src/qr/campaigns/application/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/application/mod.rs rename to imphnen-cms/src/qr/campaigns/application/mod.rs diff --git a/imphnen-cms/src/qr/campaigns/domain/entity.rs b/imphnen-cms/src/qr/campaigns/domain/entity.rs new file mode 100644 index 0000000..50169e3 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/domain/entity.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CampaignEntity { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub created_by: Uuid, + pub expires_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, +} + +pub struct CreateCampaignInput { + pub name: String, + pub url: String, + pub created_by: Uuid, + pub qr_code_data: Vec, +} diff --git a/imphnen-qr/src/campaigns/domain/mod.rs b/imphnen-cms/src/qr/campaigns/domain/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/domain/mod.rs rename to imphnen-cms/src/qr/campaigns/domain/mod.rs diff --git a/imphnen-cms/src/qr/campaigns/domain/repository.rs b/imphnen-cms/src/qr/campaigns/domain/repository.rs new file mode 100644 index 0000000..20c4e33 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/domain/repository.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{CampaignEntity, CreateCampaignInput}; + +#[async_trait] +pub trait CampaignRepository: Send + Sync { + async fn create( + &self, + input: CreateCampaignInput, + ) -> Result; + async fn find_all(&self) -> Result, AppError>; + async fn find_active_qr_data(&self) -> Result>, AppError>; + async fn set_active(&self, id: Uuid) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-cms/src/qr/campaigns/domain/service.rs b/imphnen-cms/src/qr/campaigns/domain/service.rs new file mode 100644 index 0000000..67cc162 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/domain/service.rs @@ -0,0 +1,20 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::CampaignEntity; + +#[async_trait] +pub trait QrCampaignService: Send + Sync { + async fn create( + &self, + name: String, + url: String, + created_by: Uuid, + ) -> Result; + async fn list_all(&self) -> Result, AppError>; + async fn get_active_qr_data(&self) -> Result>, AppError>; + async fn set_active(&self, id: Uuid) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn process_image(&self, image_bytes: Vec) -> Result, AppError>; +} diff --git a/imphnen-cms/src/qr/campaigns/infrastructure/http/dto.rs b/imphnen-cms/src/qr/campaigns/infrastructure/http/dto.rs new file mode 100644 index 0000000..45411e4 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/infrastructure/http/dto.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCampaignRequest { + pub name: String, + pub url: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct CampaignResponse { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub created_by: Uuid, + pub expires_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, +} diff --git a/imphnen-cms/src/qr/campaigns/infrastructure/http/handlers.rs b/imphnen-cms/src/qr/campaigns/infrastructure/http/handlers.rs new file mode 100644 index 0000000..d11b397 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/infrastructure/http/handlers.rs @@ -0,0 +1,103 @@ +use axum::{ + Extension, Json, + extract::{Multipart, Path}, + response::{IntoResponse, Response}, +}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::{ + campaigns::{ + domain::service::QrCampaignService, + infrastructure::http::dto::CreateCampaignRequest, + }, + middleware::qr_auth::QrAuthUser, +}; + +pub async fn create_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Json(body): Json, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + let campaign = service + .create(body.name, body.url, auth_user.user_id) + .await?; + Ok(imphnen_utils::response_format::ApiCreated(campaign).into_response()) +} + +pub async fn list_campaigns_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + let campaigns = service.list_all().await?; + Ok(ApiSuccess(campaigns).into_response()) +} + +pub async fn activate_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + let campaign = service.set_active(id).await?; + Ok(ApiSuccess(campaign).into_response()) +} + +pub async fn delete_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + service.delete(id).await?; + Ok( + imphnen_utils::response_format::ApiMessage::ok("Campaign deleted successfully") + .into_response(), + ) +} + +pub async fn process_image_handler( + Extension(service): Extension>, + Extension(_auth_user): Extension, + mut multipart: Multipart, +) -> Result { + let mut image_bytes = Vec::new(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequestError(e.to_string()))? + { + if field.name() == Some("file") { + image_bytes = field + .bytes() + .await + .map_err(|e| AppError::BadRequestError(e.to_string()))? + .to_vec(); + break; + } + } + if image_bytes.is_empty() { + return Err(AppError::BadRequestError("No file provided".to_string())); + } + let png_bytes = service.process_image(image_bytes).await?; + Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png_bytes).into_response()) +} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/mod.rs b/imphnen-cms/src/qr/campaigns/infrastructure/http/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/infrastructure/http/mod.rs rename to imphnen-cms/src/qr/campaigns/infrastructure/http/mod.rs diff --git a/imphnen-cms/src/qr/campaigns/infrastructure/http/routes.rs b/imphnen-cms/src/qr/campaigns/infrastructure/http/routes.rs new file mode 100644 index 0000000..ccdee32 --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/infrastructure/http/routes.rs @@ -0,0 +1,41 @@ +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{delete, post, put}, +}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::qr::{ + campaigns::{ + application::campaign_service::QrCampaignServiceImpl, + domain::{repository::CampaignRepository, service::QrCampaignService}, + infrastructure::{ + http::handlers::{ + activate_campaign_handler, create_campaign_handler, delete_campaign_handler, + list_campaigns_handler, process_image_handler, + }, + persistence::postgres_campaign_repository::PostgresCampaignRepository, + }, + }, + middleware::qr_auth::qr_auth_middleware, +}; + +pub fn qr_campaigns_routes(pool: Arc) -> Router { + let repo: Arc = + Arc::new(PostgresCampaignRepository::new(pool.clone())); + let service: Arc = + Arc::new(QrCampaignServiceImpl::new(repo)); + + Router::new() + .route( + "/campaigns", + post(create_campaign_handler).get(list_campaigns_handler), + ) + .route("/campaigns/:id/activate", put(activate_campaign_handler)) + .route("/campaigns/:id", delete(delete_campaign_handler)) + .route("/campaigns/process-image", post(process_image_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(qr_auth_middleware)) +} diff --git a/imphnen-qr/src/campaigns/infrastructure/mod.rs b/imphnen-cms/src/qr/campaigns/infrastructure/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/infrastructure/mod.rs rename to imphnen-cms/src/qr/campaigns/infrastructure/mod.rs diff --git a/imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs b/imphnen-cms/src/qr/campaigns/infrastructure/persistence/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs rename to imphnen-cms/src/qr/campaigns/infrastructure/persistence/mod.rs diff --git a/imphnen-cms/src/qr/campaigns/infrastructure/persistence/postgres_campaign_repository.rs b/imphnen-cms/src/qr/campaigns/infrastructure/persistence/postgres_campaign_repository.rs new file mode 100644 index 0000000..d6b589f --- /dev/null +++ b/imphnen-cms/src/qr/campaigns/infrastructure/persistence/postgres_campaign_repository.rs @@ -0,0 +1,147 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::FromRow; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::campaigns::domain::{ + entity::{CampaignEntity, CreateCampaignInput}, + repository::CampaignRepository, +}; + +#[derive(FromRow)] +struct CampaignRow { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub created_by: Uuid, + pub expires_at: chrono::DateTime, + pub created_at: Option>, + pub updated_at: Option>, +} + +impl From for CampaignEntity { + fn from(row: CampaignRow) -> Self { + CampaignEntity { + id: row.id, + name: row.name, + url: row.url, + is_active: row.is_active, + created_by: row.created_by, + expires_at: row.expires_at, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +pub struct PostgresCampaignRepository { + pool: Arc, +} + +impl PostgresCampaignRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl CampaignRepository for PostgresCampaignRepository { + async fn create( + &self, + input: CreateCampaignInput, + ) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") + .execute(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let id = Uuid::new_v4(); + let campaign = sqlx::query_as::<_, CampaignRow>( + "INSERT INTO qr_campaigns (id, name, url, qr_code_data, is_active, created_by, expires_at) \ + VALUES ($1, $2, $3, $4, true, $5, NOW() + INTERVAL '30 days') \ + RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", + ) + .bind(id) + .bind(&input.name) + .bind(&input.url) + .bind(&input.qr_code_data) + .bind(input.created_by) + .fetch_one(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tx.commit() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(campaign.into()) + } + + async fn find_all(&self) -> Result, AppError> { + sqlx::query_as::<_, CampaignRow>( + "SELECT id, name, url, is_active, created_by, expires_at, created_at, updated_at \ + FROM qr_campaigns ORDER BY created_at DESC", + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + async fn find_active_qr_data(&self) -> Result>, AppError> { + let row = sqlx::query_as::<_, (Vec,)>( + "SELECT qr_code_data FROM qr_campaigns WHERE is_active = true LIMIT 1", + ) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(row.map(|r| r.0)) + } + + async fn set_active(&self, id: Uuid) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") + .execute(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let campaign = sqlx::query_as::<_, CampaignRow>( + "UPDATE qr_campaigns SET is_active = true, updated_at = NOW() WHERE id = $1 \ + RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", + ) + .bind(id) + .fetch_one(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tx.commit() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(campaign.into()) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM qr_campaigns WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/imphnen-qr/src/campaigns/mod.rs b/imphnen-cms/src/qr/campaigns/mod.rs similarity index 100% rename from imphnen-qr/src/campaigns/mod.rs rename to imphnen-cms/src/qr/campaigns/mod.rs index 3d2b916..f11269f 100644 --- a/imphnen-qr/src/campaigns/mod.rs +++ b/imphnen-cms/src/qr/campaigns/mod.rs @@ -1,4 +1,4 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::qr_campaigns_routes; diff --git a/imphnen-qr/src/middleware/mod.rs b/imphnen-cms/src/qr/middleware/mod.rs similarity index 100% rename from imphnen-qr/src/middleware/mod.rs rename to imphnen-cms/src/qr/middleware/mod.rs diff --git a/imphnen-cms/src/qr/middleware/qr_auth.rs b/imphnen-cms/src/qr/middleware/qr_auth.rs new file mode 100644 index 0000000..669c192 --- /dev/null +++ b/imphnen-cms/src/qr/middleware/qr_auth.rs @@ -0,0 +1,69 @@ +use axum::http::StatusCode; +use axum::{ + body::Body, + extract::Request, + middleware::Next, + response::{IntoResponse, Response}, +}; +use imphnen_libs::decode_access_token; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QrAuthUser { + pub user_id: Uuid, + pub role: String, +} + +pub async fn qr_auth_middleware( + axum::Extension(pool): axum::Extension>, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| { + (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response() + })?; + + let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "Invalid Authorization header format", + ) + .into_response() + })?; + + let token_data = decode_access_token(token).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response() + })?; + + let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response() + })?; + + let _ = sqlx::query( + "INSERT INTO qr_users (id, email, name, role, provider) VALUES ($1, $2, $2, 'user', 'external') ON CONFLICT (id) DO NOTHING" + ) + .bind(user_id) + .bind(&token_data.claims.sub) + .execute(pool.as_ref()) + .await; + + let role: String = sqlx::query_scalar("SELECT role FROM qr_users WHERE id = $1") + .bind(user_id) + .fetch_optional(pool.as_ref()) + .await + .ok() + .flatten() + .unwrap_or_else(|| "user".to_string()); + + request + .extensions_mut() + .insert(QrAuthUser { user_id, role }); + Ok(next.run(request).await) +} diff --git a/imphnen-qr/src/lib.rs b/imphnen-cms/src/qr/mod.rs similarity index 64% rename from imphnen-qr/src/lib.rs rename to imphnen-cms/src/qr/mod.rs index c11e9ed..13daa21 100644 --- a/imphnen-qr/src/lib.rs +++ b/imphnen-cms/src/qr/mod.rs @@ -1,13 +1,14 @@ -pub mod common; +pub mod campaigns; pub mod middleware; pub mod users; -pub mod campaigns; use axum::Router; +use sea_orm::DatabaseConnection; use sqlx::PgPool; use std::sync::Arc; -pub fn qr_router(pool: Arc) -> Router { +pub fn qr_router(db: DatabaseConnection) -> Router { + let pool: Arc = Arc::new(db.get_postgres_connection_pool().clone()); Router::new() .merge(users::infrastructure::http::routes::qr_users_routes(pool.clone())) .merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool)) diff --git a/imphnen-qr/src/users/application/mod.rs b/imphnen-cms/src/qr/users/application/mod.rs similarity index 100% rename from imphnen-qr/src/users/application/mod.rs rename to imphnen-cms/src/qr/users/application/mod.rs diff --git a/imphnen-cms/src/qr/users/application/user_service.rs b/imphnen-cms/src/qr/users/application/user_service.rs new file mode 100644 index 0000000..4830f05 --- /dev/null +++ b/imphnen-cms/src/qr/users/application/user_service.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::users::domain::{ + entity::{UpdateUserInput, UserEntity}, + repository::UserRepository, + service::QrUserService, +}; + +pub struct QrUserServiceImpl { + repo: Arc, +} + +impl QrUserServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl QrUserService for QrUserServiceImpl { + async fn get_profile(&self, user_id: Uuid) -> Result { + self + .repo + .find_by_id(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) + } + + async fn update_profile( + &self, + user_id: Uuid, + input: UpdateUserInput, + ) -> Result { + if let Some(ref email) = input.email + && email.trim().is_empty() + { + return Err(AppError::ValidationError( + "Email cannot be empty".to_string(), + )); + } + self.repo.update(user_id, input).await + } + + async fn list_all(&self) -> Result, AppError> { + self.repo.find_all().await + } + + async fn update_role( + &self, + id: Uuid, + role: String, + ) -> Result { + self.repo.update_role(id, role).await + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } +} diff --git a/imphnen-cms/src/qr/users/domain/entity.rs b/imphnen-cms/src/qr/users/domain/entity.rs new file mode 100644 index 0000000..bef47d5 --- /dev/null +++ b/imphnen-cms/src/qr/users/domain/entity.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserEntity { + pub id: Uuid, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, + pub created_at: Option>, + pub updated_at: Option>, +} + +pub struct UpdateUserInput { + pub name: Option, + pub email: Option, +} diff --git a/imphnen-qr/src/users/domain/mod.rs b/imphnen-cms/src/qr/users/domain/mod.rs similarity index 100% rename from imphnen-qr/src/users/domain/mod.rs rename to imphnen-cms/src/qr/users/domain/mod.rs diff --git a/imphnen-cms/src/qr/users/domain/repository.rs b/imphnen-cms/src/qr/users/domain/repository.rs new file mode 100644 index 0000000..77fe9c2 --- /dev/null +++ b/imphnen-cms/src/qr/users/domain/repository.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{UpdateUserInput, UserEntity}; + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_all(&self) -> Result, AppError>; + async fn update( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result; + async fn update_role( + &self, + id: Uuid, + role: String, + ) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-cms/src/qr/users/domain/service.rs b/imphnen-cms/src/qr/users/domain/service.rs new file mode 100644 index 0000000..3e1660e --- /dev/null +++ b/imphnen-cms/src/qr/users/domain/service.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{UpdateUserInput, UserEntity}; + +#[async_trait] +pub trait QrUserService: Send + Sync { + async fn get_profile(&self, user_id: Uuid) -> Result; + async fn update_profile( + &self, + user_id: Uuid, + input: UpdateUserInput, + ) -> Result; + async fn list_all(&self) -> Result, AppError>; + async fn update_role( + &self, + id: Uuid, + role: String, + ) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-qr/src/users/infrastructure/http/dto.rs b/imphnen-cms/src/qr/users/infrastructure/http/dto.rs similarity index 58% rename from imphnen-qr/src/users/infrastructure/http/dto.rs rename to imphnen-cms/src/qr/users/infrastructure/http/dto.rs index 04b2fe1..1c2754a 100644 --- a/imphnen-qr/src/users/infrastructure/http/dto.rs +++ b/imphnen-cms/src/qr/users/infrastructure/http/dto.rs @@ -3,20 +3,20 @@ use utoipa::ToSchema; #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateProfileRequest { - pub name: Option, - pub email: Option, + pub name: Option, + pub email: Option, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateRoleRequest { - pub role: String, + pub role: String, } #[derive(Debug, Serialize, ToSchema)] pub struct UserResponse { - pub id: String, - pub email: String, - pub name: String, - pub role: String, - pub provider: String, + pub id: String, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, } diff --git a/imphnen-cms/src/qr/users/infrastructure/http/handlers.rs b/imphnen-cms/src/qr/users/infrastructure/http/handlers.rs new file mode 100644 index 0000000..7d2a794 --- /dev/null +++ b/imphnen-cms/src/qr/users/infrastructure/http/handlers.rs @@ -0,0 +1,82 @@ +use axum::{ + Extension, Json, + extract::Path, + response::{IntoResponse, Response}, +}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::{ + middleware::qr_auth::QrAuthUser, + users::{ + domain::{entity::UpdateUserInput, service::QrUserService}, + infrastructure::http::dto::{UpdateProfileRequest, UpdateRoleRequest}, + }, +}; + +pub async fn get_me_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + let user = service.get_profile(auth_user.user_id).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn update_me_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Json(body): Json, +) -> Result { + let input = UpdateUserInput { + name: body.name, + email: body.email, + }; + let user = service.update_profile(auth_user.user_id, input).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn list_users_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + let users = service.list_all().await?; + Ok(ApiSuccess(users).into_response()) +} + +pub async fn update_role_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, + Json(body): Json, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + let user = service.update_role(id, body.role).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn delete_user_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError( + "Admin access required".to_string(), + )); + } + service.delete(id).await?; + Ok( + imphnen_utils::response_format::ApiMessage::ok("User deleted successfully") + .into_response(), + ) +} diff --git a/imphnen-qr/src/users/infrastructure/http/mod.rs b/imphnen-cms/src/qr/users/infrastructure/http/mod.rs similarity index 100% rename from imphnen-qr/src/users/infrastructure/http/mod.rs rename to imphnen-cms/src/qr/users/infrastructure/http/mod.rs diff --git a/imphnen-cms/src/qr/users/infrastructure/http/routes.rs b/imphnen-cms/src/qr/users/infrastructure/http/routes.rs new file mode 100644 index 0000000..9dba025 --- /dev/null +++ b/imphnen-cms/src/qr/users/infrastructure/http/routes.rs @@ -0,0 +1,37 @@ +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{delete, get, put}, +}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::qr::{ + middleware::qr_auth::qr_auth_middleware, + users::{ + application::user_service::QrUserServiceImpl, + domain::{repository::UserRepository, service::QrUserService}, + infrastructure::{ + http::handlers::{ + delete_user_handler, get_me_handler, list_users_handler, update_me_handler, + update_role_handler, + }, + persistence::postgres_user_repository::PostgresUserRepository, + }, + }, +}; + +pub fn qr_users_routes(pool: Arc) -> Router { + let repo: Arc = + Arc::new(PostgresUserRepository::new(pool.clone())); + let service: Arc = Arc::new(QrUserServiceImpl::new(repo)); + + Router::new() + .route("/users/me", get(get_me_handler).put(update_me_handler)) + .route("/users", get(list_users_handler)) + .route("/users/:id/role", put(update_role_handler)) + .route("/users/:id", delete(delete_user_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(qr_auth_middleware)) +} diff --git a/imphnen-qr/src/users/infrastructure/mod.rs b/imphnen-cms/src/qr/users/infrastructure/mod.rs similarity index 100% rename from imphnen-qr/src/users/infrastructure/mod.rs rename to imphnen-cms/src/qr/users/infrastructure/mod.rs diff --git a/imphnen-qr/src/users/infrastructure/persistence/mod.rs b/imphnen-cms/src/qr/users/infrastructure/persistence/mod.rs similarity index 100% rename from imphnen-qr/src/users/infrastructure/persistence/mod.rs rename to imphnen-cms/src/qr/users/infrastructure/persistence/mod.rs diff --git a/imphnen-cms/src/qr/users/infrastructure/persistence/postgres_user_repository.rs b/imphnen-cms/src/qr/users/infrastructure/persistence/postgres_user_repository.rs new file mode 100644 index 0000000..7cb8e8b --- /dev/null +++ b/imphnen-cms/src/qr/users/infrastructure/persistence/postgres_user_repository.rs @@ -0,0 +1,112 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::FromRow; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::qr::users::domain::{ + entity::{UpdateUserInput, UserEntity}, + repository::UserRepository, +}; + +#[derive(FromRow)] +struct UserRow { + pub id: Uuid, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, + pub created_at: Option>, + pub updated_at: Option>, +} + +impl From for UserEntity { + fn from(row: UserRow) -> Self { + UserEntity { + id: row.id, + email: row.email, + name: row.name, + role: row.role, + provider: row.provider, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +pub struct PostgresUserRepository { + pool: Arc, +} + +impl PostgresUserRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl UserRepository for PostgresUserRepository { + async fn find_by_id(&self, id: Uuid) -> Result, AppError> { + sqlx::query_as::<_, UserRow>( + "SELECT id, email, name, role, provider, created_at, updated_at FROM qr_users WHERE id = $1", + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + .map(|opt| opt.map(Into::into)) + } + + async fn find_all(&self) -> Result, AppError> { + sqlx::query_as::<_, UserRow>( + "SELECT id, email, name, role, provider, created_at, updated_at FROM qr_users ORDER BY created_at DESC", + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + async fn update( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result { + sqlx::query_as::<_, UserRow>( + "UPDATE qr_users SET name = COALESCE($1, name), email = COALESCE($2, email), updated_at = NOW() WHERE id = $3 RETURNING id, email, name, role, provider, created_at, updated_at", + ) + .bind(input.name) + .bind(input.email) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + .map(Into::into) + } + + async fn update_role( + &self, + id: Uuid, + role: String, + ) -> Result { + sqlx::query_as::<_, UserRow>( + "UPDATE qr_users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role, provider, created_at, updated_at", + ) + .bind(role) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + .map(Into::into) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM qr_users WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/imphnen-qr/src/users/mod.rs b/imphnen-cms/src/qr/users/mod.rs similarity index 100% rename from imphnen-qr/src/users/mod.rs rename to imphnen-cms/src/qr/users/mod.rs index 40ac3c7..e6ac5db 100644 --- a/imphnen-qr/src/users/mod.rs +++ b/imphnen-cms/src/qr/users/mod.rs @@ -1,4 +1,4 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::qr_users_routes; diff --git a/imphnen-cms/src/testimonials/application/testimonial_service.rs b/imphnen-cms/src/testimonials/application/testimonial_service.rs index 4417d78..c9dc5cd 100644 --- a/imphnen-cms/src/testimonials/application/testimonial_service.rs +++ b/imphnen-cms/src/testimonials/application/testimonial_service.rs @@ -1,40 +1,48 @@ -use std::sync::Arc; +use crate::testimonials::domain::{ + TestimonialEntity, TestimonialRepository, TestimonialService, +}; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use crate::testimonials::domain::{TestimonialEntity, TestimonialRepository, TestimonialService}; pub struct TestimonialServiceImpl { - repo: Arc, + repo: Arc, } impl TestimonialServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl TestimonialService for TestimonialServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: Uuid) -> Result { - self.repo.find_by_id(id).await - } + async fn get(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } - async fn create(&self, entity: TestimonialEntity) -> Result { - self.repo.create(entity).await - } + async fn create( + &self, + entity: TestimonialEntity, + ) -> Result { + self.repo.create(entity).await + } - async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> { - self.repo.update(entity).await - } + async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> { + self.repo.update(entity).await + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.delete(id).await - } + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } } diff --git a/imphnen-cms/src/testimonials/domain/mod.rs b/imphnen-cms/src/testimonials/domain/mod.rs index a45da74..b80a14d 100644 --- a/imphnen-cms/src/testimonials/domain/mod.rs +++ b/imphnen-cms/src/testimonials/domain/mod.rs @@ -1,7 +1,7 @@ -pub mod testimonial; pub mod repository; pub mod service; +pub mod testimonial; -pub use testimonial::TestimonialEntity; pub use repository::TestimonialRepository; pub use service::TestimonialService; +pub use testimonial::TestimonialEntity; diff --git a/imphnen-cms/src/testimonials/domain/repository.rs b/imphnen-cms/src/testimonials/domain/repository.rs index 54dba71..099455a 100644 --- a/imphnen-cms/src/testimonials/domain/repository.rs +++ b/imphnen-cms/src/testimonials/domain/repository.rs @@ -1,15 +1,21 @@ +use super::testimonial::TestimonialEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::testimonial::TestimonialEntity; #[async_trait] pub trait TestimonialRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: Uuid) -> Result; - async fn create(&self, entity: TestimonialEntity) -> Result; - async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn create( + &self, + entity: TestimonialEntity, + ) -> Result; + async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-cms/src/testimonials/domain/service.rs b/imphnen-cms/src/testimonials/domain/service.rs index 2a085f2..9289102 100644 --- a/imphnen-cms/src/testimonials/domain/service.rs +++ b/imphnen-cms/src/testimonials/domain/service.rs @@ -1,15 +1,21 @@ +use super::testimonial::TestimonialEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::testimonial::TestimonialEntity; #[async_trait] pub trait TestimonialService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: Uuid) -> Result; - async fn create(&self, entity: TestimonialEntity) -> Result; - async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: Uuid) -> Result; + async fn create( + &self, + entity: TestimonialEntity, + ) -> Result; + async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-cms/src/testimonials/domain/testimonial.rs b/imphnen-cms/src/testimonials/domain/testimonial.rs index 39ec485..1b3c234 100644 --- a/imphnen-cms/src/testimonials/domain/testimonial.rs +++ b/imphnen-cms/src/testimonials/domain/testimonial.rs @@ -2,12 +2,12 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct TestimonialEntity { - pub id: Uuid, - pub user_id: Uuid, - pub user_fullname: String, - pub role: String, - pub content: String, - pub is_deleted: bool, - pub created_at: String, - pub updated_at: String, + pub id: Uuid, + pub user_id: Uuid, + pub user_fullname: String, + pub role: String, + pub content: String, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, } diff --git a/imphnen-cms/src/testimonials/infrastructure/http/dto.rs b/imphnen-cms/src/testimonials/infrastructure/http/dto.rs index 667d579..14bd0da 100644 --- a/imphnen-cms/src/testimonials/infrastructure/http/dto.rs +++ b/imphnen-cms/src/testimonials/infrastructure/http/dto.rs @@ -1,83 +1,83 @@ +use crate::testimonials::domain::testimonial::TestimonialEntity; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use zod_rs::prelude::*; -use crate::testimonials::domain::testimonial::TestimonialEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct TestimonialsCreateRequestDto { - #[zod(min_length(1), max_length(100))] - pub role: String, - #[zod(min_length(1), max_length(1000))] - pub content: String, + #[zod(min_length(1), max_length(100))] + pub role: String, + #[zod(min_length(1), max_length(1000))] + pub content: String, } impl ZodValidate for TestimonialsCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct TestimonialsUpdateRequestDto { - #[zod(min_length(1), max_length(100))] - pub role: String, - #[zod(min_length(1), max_length(1000))] - pub content: String, + #[zod(min_length(1), max_length(100))] + pub role: String, + #[zod(min_length(1), max_length(1000))] + pub content: String, } impl ZodValidate for TestimonialsUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct TestimonialsListItemDto { - pub id: String, - pub user_id: String, - pub user_fullname: String, - pub role: String, - pub content: String, - pub created_at: String, - pub is_deleted: bool, + pub id: String, + pub user_id: String, + pub user_fullname: String, + pub role: String, + pub content: String, + pub created_at: String, + pub is_deleted: bool, } impl From for TestimonialsListItemDto { - fn from(e: TestimonialEntity) -> Self { - TestimonialsListItemDto { - id: e.id.to_string(), - user_id: e.user_id.to_string(), - user_fullname: e.user_fullname, - role: e.role, - content: e.content, - created_at: e.created_at, - is_deleted: e.is_deleted, - } - } + fn from(e: TestimonialEntity) -> Self { + TestimonialsListItemDto { + id: e.id.to_string(), + user_id: e.user_id.to_string(), + user_fullname: e.user_fullname, + role: e.role, + content: e.content, + created_at: e.created_at, + is_deleted: e.is_deleted, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct TestimonialsDetailItemDto { - pub id: String, - pub user_id: String, - pub user_fullname: String, - pub role: String, - pub content: String, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub user_id: String, + pub user_fullname: String, + pub role: String, + pub content: String, + pub created_at: String, + pub updated_at: String, } impl From for TestimonialsDetailItemDto { - fn from(e: TestimonialEntity) -> Self { - TestimonialsDetailItemDto { - id: e.id.to_string(), - user_id: e.user_id.to_string(), - user_fullname: e.user_fullname, - role: e.role, - content: e.content, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + fn from(e: TestimonialEntity) -> Self { + TestimonialsDetailItemDto { + id: e.id.to_string(), + user_id: e.user_id.to_string(), + user_fullname: e.user_fullname, + role: e.role, + content: e.content, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } diff --git a/imphnen-cms/src/testimonials/infrastructure/http/handlers.rs b/imphnen-cms/src/testimonials/infrastructure/http/handlers.rs index 42dbf3a..d55e112 100644 --- a/imphnen-cms/src/testimonials/infrastructure/http/handlers.rs +++ b/imphnen-cms/src/testimonials/infrastructure/http/handlers.rs @@ -1,18 +1,26 @@ -use std::sync::Arc; -use axum::{Extension, extract::Path, http::HeaderMap, http::StatusCode, response::{IntoResponse, Response}}; -use paginator_axum::PaginationQuery; -use paginator_utils::PaginatorResponse; -use uuid::Uuid; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage, extract_email}; -use imphnen_entities::ResponseSuccessDto; -use imphnen_iam::require_auth; -use imphnen_utils::AppError; use super::dto::{ - TestimonialsCreateRequestDto, TestimonialsDetailItemDto, - TestimonialsListItemDto, TestimonialsUpdateRequestDto, + TestimonialsCreateRequestDto, TestimonialsDetailItemDto, TestimonialsListItemDto, + TestimonialsUpdateRequestDto, }; use crate::testimonials::domain::{TestimonialEntity, TestimonialService}; +use axum::{ + Extension, + extract::Path, + http::HeaderMap, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_iam::require_auth; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ + ApiCreated, ApiMessage, ApiPaginated, ApiSuccess, extract_email, +}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -30,22 +38,26 @@ use crate::testimonials::domain::{TestimonialEntity, TestimonialService}; tag = "Testimonials" )] pub async fn get_testimonial_list( - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, ) -> Response { - match service.list(params).await { - Ok(result) => { - let mapped = PaginatorResponse { - data: result.data.into_iter() - .filter(|e| !e.is_deleted) - .map(TestimonialsListItemDto::from) - .collect::>(), - meta: result.meta, - }; - ApiPaginated(mapped).into_response() - } - Err(e) => ApiMessage::new(StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } + match service.list(params).await { + Ok(result) => { + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .filter(|e| !e.is_deleted) + .map(TestimonialsListItemDto::from) + .collect::>(), + meta: result.meta, + }; + ApiPaginated(mapped).into_response() + } + Err(e) => { + ApiMessage::new(StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } } #[utoipa::path( @@ -60,20 +72,25 @@ pub async fn get_testimonial_list( tag = "Testimonials" )] pub async fn get_testimonial_by_id( - Extension(service): Extension>, - Path(id): Path, + Extension(service): Extension>, + Path(id): Path, ) -> Response { - let uuid = match Uuid::parse_str(&id) { - Ok(u) => u, - Err(e) => return ApiMessage::new(StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}")).into_response(), - }; - match service.get(uuid).await { - Ok(t) if !t.is_deleted => { - ApiSuccess(TestimonialsDetailItemDto::from(t)).into_response() - } - Ok(_) => ApiMessage::new(StatusCode::NOT_FOUND, "Testimonial not found").into_response(), - Err(e) => ApiMessage::new(StatusCode::NOT_FOUND, e.to_string()).into_response(), - } + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(e) => { + return ApiMessage::new(StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}")) + .into_response(); + } + }; + match service.get(uuid).await { + Ok(t) if !t.is_deleted => { + ApiSuccess(TestimonialsDetailItemDto::from(t)).into_response() + } + Ok(_) => { + ApiMessage::new(StatusCode::NOT_FOUND, "Testimonial not found").into_response() + } + Err(e) => ApiMessage::new(StatusCode::NOT_FOUND, e.to_string()).into_response(), + } } #[utoipa::path( @@ -87,32 +104,36 @@ pub async fn get_testimonial_by_id( tag = "Testimonials" )] pub async fn post_create_testimonial( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_auth!(headers.clone(), state, { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user = user_info.basic_info; - let user_id = Uuid::parse_str(&user.id) - .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {e}")))?; - let entity = TestimonialEntity { - id: Uuid::new_v4(), - user_id, - user_fullname: user.fullname.clone(), - role: payload.role, - content: payload.content, - is_deleted: false, - created_at: chrono::Utc::now().to_rfc3339(), - updated_at: chrono::Utc::now().to_rfc3339(), - }; - let created = service.create(entity).await?; - Ok(ApiCreated(TestimonialsDetailItemDto::from(created))) - }) + require_auth!(headers.clone(), state, { + let email = extract_email(&headers).ok_or_else(|| { + AppError::AuthenticationError("Token tidak valid".to_string()) + })?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user = user_info.basic_info; + let user_id = Uuid::parse_str(&user.id) + .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {e}")))?; + let entity = TestimonialEntity { + id: Uuid::new_v4(), + user_id, + user_fullname: user.fullname.clone(), + role: payload.role, + content: payload.content, + is_deleted: false, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }; + let created = service.create(entity).await?; + Ok(ApiCreated(TestimonialsDetailItemDto::from(created))) + }) } #[utoipa::path( @@ -129,29 +150,29 @@ pub async fn post_create_testimonial( tag = "Testimonials" )] pub async fn patch_update_testimonial( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_auth!(headers, state, { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let existing = service.get(uuid).await?; - let entity = TestimonialEntity { - id: existing.id, - user_id: existing.user_id, - user_fullname: existing.user_fullname, - role: payload.role, - content: payload.content, - is_deleted: existing.is_deleted, - created_at: existing.created_at, - updated_at: chrono::Utc::now().to_rfc3339(), - }; - service.update(entity).await?; - Ok(ApiMessage::ok("Testimonial updated")) - }) + require_auth!(headers, state, { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let existing = service.get(uuid).await?; + let entity = TestimonialEntity { + id: existing.id, + user_id: existing.user_id, + user_fullname: existing.user_fullname, + role: payload.role, + content: payload.content, + is_deleted: existing.is_deleted, + created_at: existing.created_at, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + service.update(entity).await?; + Ok(ApiMessage::ok("Testimonial updated")) + }) } #[utoipa::path( @@ -167,15 +188,15 @@ pub async fn patch_update_testimonial( tag = "Testimonials" )] pub async fn delete_testimonial( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_auth!(headers, state, { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - service.delete(uuid).await?; - Ok(ApiMessage::ok("Testimonial deleted")) - }) + require_auth!(headers, state, { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + service.delete(uuid).await?; + Ok(ApiMessage::ok("Testimonial deleted")) + }) } diff --git a/imphnen-cms/src/testimonials/infrastructure/http/mod.rs b/imphnen-cms/src/testimonials/infrastructure/http/mod.rs index 4a1c9b9..0a4848a 100644 --- a/imphnen-cms/src/testimonials/infrastructure/http/mod.rs +++ b/imphnen-cms/src/testimonials/infrastructure/http/mod.rs @@ -2,4 +2,4 @@ pub mod dto; pub mod handlers; pub mod routes; -pub use routes::{testimonials_public_routes, testimonials_protected_routes}; +pub use routes::{testimonials_protected_routes, testimonials_public_routes}; diff --git a/imphnen-cms/src/testimonials/infrastructure/http/routes.rs b/imphnen-cms/src/testimonials/infrastructure/http/routes.rs index 403e721..edf76f2 100644 --- a/imphnen-cms/src/testimonials/infrastructure/http/routes.rs +++ b/imphnen-cms/src/testimonials/infrastructure/http/routes.rs @@ -1,32 +1,47 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, patch, post}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{ + delete_testimonial, get_testimonial_by_id, get_testimonial_list, + patch_update_testimonial, post_create_testimonial, +}; use crate::testimonials::application::TestimonialServiceImpl; use crate::testimonials::domain::TestimonialService; use crate::testimonials::infrastructure::persistence::PostgresTestimonialRepository; -use super::handlers::{ - delete_testimonial, get_testimonial_by_id, get_testimonial_list, - patch_update_testimonial, post_create_testimonial, +use axum::{ + Extension, Router, + routing::{delete, get, patch, post}, }; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresTestimonialRepository::new(db)); - Arc::new(TestimonialServiceImpl::new(repo)) + let repo = Arc::new(PostgresTestimonialRepository::new(db)); + Arc::new(TestimonialServiceImpl::new(repo)) } pub fn testimonials_public_routes(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/cms/landing/testimonials", get(get_testimonial_list)) - .route("/cms/landing/testimonials/detail/{id}", get(get_testimonial_by_id)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/cms/landing/testimonials", get(get_testimonial_list)) + .route( + "/cms/landing/testimonials/detail/{id}", + get(get_testimonial_by_id), + ) + .layer(Extension(service)) } pub fn testimonials_protected_routes(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/cms/landing/testimonials/create", post(post_create_testimonial)) - .route("/cms/landing/testimonials/update/{id}", patch(patch_update_testimonial)) - .route("/cms/landing/testimonials/delete/{id}", delete(delete_testimonial)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route( + "/cms/landing/testimonials/create", + post(post_create_testimonial), + ) + .route( + "/cms/landing/testimonials/update/{id}", + patch(patch_update_testimonial), + ) + .route( + "/cms/landing/testimonials/delete/{id}", + delete(delete_testimonial), + ) + .layer(Extension(service)) } diff --git a/imphnen-cms/src/testimonials/infrastructure/persistence/postgres_testimonial_repository.rs b/imphnen-cms/src/testimonials/infrastructure/persistence/postgres_testimonial_repository.rs index ecea53a..635dc16 100644 --- a/imphnen-cms/src/testimonials/infrastructure/persistence/postgres_testimonial_repository.rs +++ b/imphnen-cms/src/testimonials/infrastructure/persistence/postgres_testimonial_repository.rs @@ -1,159 +1,191 @@ -use std::sync::Arc; +use crate::testimonials::domain::{ + repository::TestimonialRepository, testimonial::TestimonialEntity, +}; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, QueryOrder, PaginatorTrait}; +use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; +use imphnen_entities::seaorm::common::testimonials::{ + ActiveModel as TestimonialsActiveModel, Column as TestimonialsColumn, + Entity as TestimonialsEntity, +}; +use imphnen_utils::AppError; use paginator_rs::{PaginationParams, SortDirection}; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, PaginatorTrait, QueryOrder}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::common::testimonials::{ - Entity as TestimonialsEntity, Column as TestimonialsColumn, ActiveModel as TestimonialsActiveModel, -}; -use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; -use crate::testimonials::domain::{testimonial::TestimonialEntity, repository::TestimonialRepository}; pub struct PostgresTestimonialRepository { - db: Arc, + db: Arc, } impl PostgresTestimonialRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl TestimonialRepository for PostgresTestimonialRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); - let mut query = TestimonialsEntity::find() - .filter(TestimonialsColumn::IsDeleted.eq(false)) - .find_also_related(UsersEntity); + let mut query = TestimonialsEntity::find() + .filter(TestimonialsColumn::IsDeleted.eq(false)) + .find_also_related(UsersEntity); - query = match params.sort_by.as_deref() { - Some("updated_at") => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by_asc(TestimonialsColumn::UpdatedAt), - _ => query.order_by_desc(TestimonialsColumn::UpdatedAt), - }, - _ => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by_asc(TestimonialsColumn::CreatedAt), - _ => query.order_by_desc(TestimonialsColumn::CreatedAt), - }, - }; + query = match params.sort_by.as_deref() { + Some("updated_at") => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by_asc(TestimonialsColumn::UpdatedAt) + } + _ => query.order_by_desc(TestimonialsColumn::UpdatedAt), + }, + _ => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by_asc(TestimonialsColumn::CreatedAt) + } + _ => query.order_by_desc(TestimonialsColumn::CreatedAt), + }, + }; - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let total = paginator.num_items().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let rows = paginator.fetch_page((page - 1) as u64).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let paginator = query.paginate(self.db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let rows = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let data: Vec = rows.into_iter() - .filter_map(|(t, u)| { - u.map(|user| TestimonialEntity { - id: t.id, - user_id: t.user_id, - user_fullname: format!( - "{} {}", - user.first_name.as_deref().unwrap_or(""), - user.last_name.as_deref().unwrap_or("") - ).trim().to_string(), - role: t.role, - content: t.content, - is_deleted: t.is_deleted, - created_at: t.created_at.to_rfc3339(), - updated_at: t.updated_at.to_rfc3339(), - }) - }) - .collect(); + let data: Vec = rows + .into_iter() + .filter_map(|(t, u)| { + u.map(|user| TestimonialEntity { + id: t.id, + user_id: t.user_id, + user_fullname: format!( + "{} {}", + user.first_name.as_deref().unwrap_or(""), + user.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(), + role: t.role, + content: t.content, + is_deleted: t.is_deleted, + created_at: t.created_at.to_rfc3339(), + updated_at: t.updated_at.to_rfc3339(), + }) + }) + .collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) + } - async fn find_by_id(&self, id: Uuid) -> Result { - let (testimonial, user) = TestimonialsEntity::find_by_id(id) - .filter(TestimonialsColumn::IsDeleted.eq(false)) - .find_also_related(UsersEntity) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result { + let (testimonial, user) = TestimonialsEntity::find_by_id(id) + .filter(TestimonialsColumn::IsDeleted.eq(false)) + .find_also_related(UsersEntity) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?; - let user = user.ok_or_else(|| AppError::NotFoundError("User not found for testimonial".to_string()))?; + let user = user.ok_or_else(|| { + AppError::NotFoundError("User not found for testimonial".to_string()) + })?; - Ok(TestimonialEntity { - id: testimonial.id, - user_id: testimonial.user_id, - user_fullname: format!( - "{} {}", - user.first_name.as_deref().unwrap_or(""), - user.last_name.as_deref().unwrap_or("") - ).trim().to_string(), - role: testimonial.role, - content: testimonial.content, - is_deleted: testimonial.is_deleted, - created_at: testimonial.created_at.to_rfc3339(), - updated_at: testimonial.updated_at.to_rfc3339(), - }) - } + Ok(TestimonialEntity { + id: testimonial.id, + user_id: testimonial.user_id, + user_fullname: format!( + "{} {}", + user.first_name.as_deref().unwrap_or(""), + user.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(), + role: testimonial.role, + content: testimonial.content, + is_deleted: testimonial.is_deleted, + created_at: testimonial.created_at.to_rfc3339(), + updated_at: testimonial.updated_at.to_rfc3339(), + }) + } - async fn create(&self, entity: TestimonialEntity) -> Result { - let active_model = TestimonialsActiveModel { - id: ActiveValue::Set(entity.id), - user_id: ActiveValue::Set(entity.user_id), - role: ActiveValue::Set(entity.role.clone()), - content: ActiveValue::Set(entity.content.clone()), - is_deleted: ActiveValue::Set(false), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - }; + async fn create( + &self, + entity: TestimonialEntity, + ) -> Result { + let active_model = TestimonialsActiveModel { + id: ActiveValue::Set(entity.id), + user_id: ActiveValue::Set(entity.user_id), + role: ActiveValue::Set(entity.role.clone()), + content: ActiveValue::Set(entity.content.clone()), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + }; - let inserted = active_model.insert(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let inserted = active_model + .insert(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(TestimonialEntity { - id: inserted.id, - user_id: inserted.user_id, - user_fullname: entity.user_fullname, - role: inserted.role, - content: inserted.content, - is_deleted: inserted.is_deleted, - created_at: inserted.created_at.to_rfc3339(), - updated_at: inserted.updated_at.to_rfc3339(), - }) - } + Ok(TestimonialEntity { + id: inserted.id, + user_id: inserted.user_id, + user_fullname: entity.user_fullname, + role: inserted.role, + content: inserted.content, + is_deleted: inserted.is_deleted, + created_at: inserted.created_at.to_rfc3339(), + updated_at: inserted.updated_at.to_rfc3339(), + }) + } - async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> { - let mut active_model: TestimonialsActiveModel = TestimonialsEntity::find_by_id(entity.id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))? - .into(); + async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> { + let mut active_model: TestimonialsActiveModel = + TestimonialsEntity::find_by_id(entity.id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))? + .into(); - active_model.role = ActiveValue::Set(entity.role); - active_model.content = ActiveValue::Set(entity.content); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model.role = ActiveValue::Set(entity.role); + active_model.content = ActiveValue::Set(entity.content); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - let mut active_model: TestimonialsActiveModel = TestimonialsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))? - .into(); + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let mut active_model: TestimonialsActiveModel = + TestimonialsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))? + .into(); - active_model.is_deleted = ActiveValue::Set(true); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + active_model.is_deleted = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } } diff --git a/imphnen-cms/src/testimonials/mod.rs b/imphnen-cms/src/testimonials/mod.rs index 609531f..9fe63e9 100644 --- a/imphnen-cms/src/testimonials/mod.rs +++ b/imphnen-cms/src/testimonials/mod.rs @@ -2,4 +2,6 @@ pub mod application; pub mod domain; pub mod infrastructure; -pub use infrastructure::http::{testimonials_public_routes, testimonials_protected_routes}; +pub use infrastructure::http::{ + testimonials_protected_routes, testimonials_public_routes, +}; diff --git a/imphnen-dimentorin/Cargo.toml b/imphnen-dimentorin/Cargo.toml index 119040a..0136fff 100644 --- a/imphnen-dimentorin/Cargo.toml +++ b/imphnen-dimentorin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-dimentorin" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index c12761e..115975f 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -1,5 +1,5 @@ -pub mod mentors; -pub mod sessions; - -pub use mentors::{mentors_public_routes, mentors_protected_routes}; -pub use sessions::{sessions_public_routes, sessions_protected_routes}; +pub mod mentors; +pub mod sessions; + +pub use mentors::{mentors_protected_routes, mentors_public_routes}; +pub use sessions::{sessions_protected_routes, sessions_public_routes}; diff --git a/imphnen-dimentorin/src/mentors/application/mentor_query_service.rs b/imphnen-dimentorin/src/mentors/application/mentor_query_service.rs new file mode 100644 index 0000000..6245713 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/application/mentor_query_service.rs @@ -0,0 +1,170 @@ +use crate::mentors::domain::{ + MentorDetail, MentorEntity, MentorListItem, MentorListPage, MentorRepository, +}; +use imphnen_entities::UsersDetailQueryDto; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use std::sync::Arc; +use uuid::Uuid; + +pub fn build_detail( + entity: &MentorEntity, + user: Option<&UsersDetailQueryDto>, +) -> MentorDetail { + MentorDetail { + id: entity.id.to_string(), + user_id: entity.user_id.to_string(), + fullname: user.map(|u| u.fullname.clone()), + email: user.map(|u| u.email.clone()), + legal_name: user.and_then(|u| u.legal_name.clone()), + gender: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.gender.clone()), + domicile: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.domicile.clone()), + phone_for_verification: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.phone_for_verification.clone()), + bio: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.bio.clone()), + last_education: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.last_education.clone()), + linkedin_url: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.linkedin_url.clone()), + github_url: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.github_url.clone()), + cv_url: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.cv_url.clone()), + portfolio_url: user + .and_then(|u| u.profile_extension.as_ref()) + .and_then(|ext| ext.portfolio_url.clone()), + industries: entity.industries.clone(), + expertise: entity.expertise.clone(), + languages: entity.languages.clone(), + current_company: entity.current_company.clone(), + current_role: entity.current_role.clone(), + years_of_experience: entity.years_of_experience, + topics_of_interest: entity.topics_of_interest.clone(), + preferred_mentee_level: entity.preferred_mentee_level.clone(), + preferred_mentoring_formats: entity.preferred_mentoring_formats.clone(), + availability_commitment: entity.availability_commitment.clone(), + mentoring_rate: entity.mentoring_rate, + status: entity.status.clone(), + created_at: entity.created_at.to_rfc3339(), + updated_at: entity.updated_at.to_rfc3339(), + } +} + +pub struct MentorQueryService { + pub repo: Arc, + pub state: Arc, +} + +impl MentorQueryService { + pub async fn list( + &self, + params: PaginationParams, + ) -> Result { + let result = self.repo.find_all(params).await?; + + let mut items: Vec = Vec::with_capacity(result.data.len()); + for entity in &result.data { + let mut item = MentorListItem { + id: entity.id.to_string(), + user_id: entity.user_id.to_string(), + fullname: None, + email: None, + status: entity.status.clone(), + created_at: entity.created_at.to_rfc3339(), + updated_at: entity.updated_at.to_rfc3339(), + }; + if let Ok(info) = self + .state + .user_lookup_service + .get_user_by_id(entity.user_id, self.state.as_ref()) + .await + { + item.fullname = Some(info.basic_info.fullname); + item.email = Some(info.basic_info.email); + } + items.push(item); + } + + Ok(paginator_utils::PaginatorResponse { + data: items, + meta: result.meta, + }) + } + + pub async fn get_by_id(&self, id: Uuid) -> Result { + let entity = self.repo.find_by_id(id, false).await?; + let user = self + .state + .user_lookup_service + .get_user_by_id(entity.user_id, self.state.as_ref()) + .await + .ok() + .map(|i| i.basic_info); + Ok(build_detail(&entity, user.as_ref())) + } + + pub async fn get_by_email(&self, email: &str) -> Result { + let user_dto = self + .state + .user_lookup_service + .get_user_by_email(email, self.state.as_ref()) + .await + .map(|i| i.basic_info) + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + + let user_id = Uuid::parse_str(&user_dto.id) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let entity = self.repo.find_by_user_id(user_id, false).await?; + Ok(build_detail(&entity, Some(&user_dto))) + } + + pub async fn get_status(&self, email: &str) -> Result { + let user_dto = self + .state + .user_lookup_service + .get_user_by_email(email, self.state.as_ref()) + .await + .map(|i| i.basic_info) + .map_err(|_| { + AppError::NotFoundError( + "No mentor application found for current user".to_string(), + ) + })?; + + let user_id = Uuid::parse_str(&user_dto.id) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let entity = self + .repo + .find_by_user_id(user_id, false) + .await + .map_err(|_| { + AppError::NotFoundError( + "No mentor application found for current user".to_string(), + ) + })?; + + Ok(entity.status) + } + + pub async fn get_entity_by_id( + &self, + id: Uuid, + include_deleted: bool, + ) -> Result { + self.repo.find_by_id(id, include_deleted).await + } +} diff --git a/imphnen-dimentorin/src/mentors/application/mentor_registration_service.rs b/imphnen-dimentorin/src/mentors/application/mentor_registration_service.rs new file mode 100644 index 0000000..73c15ed --- /dev/null +++ b/imphnen-dimentorin/src/mentors/application/mentor_registration_service.rs @@ -0,0 +1,203 @@ +use super::mentor_query_service::build_detail; +use crate::mentors::domain::{ + MentorDetail, MentorEntity, MentorRegisterCommand, MentorRegistered, + MentorRepository, MentorVerifyCommand, +}; +use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; +use imphnen_iam::roles::domain::RoleRepository; +use imphnen_iam::users::domain::{UserEntity, UserRepository}; +use imphnen_libs::{AppState, hash_password}; +use imphnen_utils::AppError; +use std::sync::Arc; +use tracing::error; +use uuid::Uuid; + +pub struct MentorRegistrationService { + pub repo: Arc, + pub state: Arc, + pub user_repo: Arc, + pub role_repo: Arc, +} + +impl MentorRegistrationService { + pub async fn register( + &self, + cmd: MentorRegisterCommand, + ) -> Result { + let user_email = cmd.email.clone(); + + let user_id: Uuid = match self.user_repo.find_by_email(user_email.clone()).await + { + Ok(mut entity) => { + let existing_user_id = Uuid::parse_str(&entity.id) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + if self + .repo + .find_by_user_id(existing_user_id, false) + .await + .is_ok() + { + return Err(AppError::ConflictError( + "Mentor profile already exists for this user".to_string(), + )); + } + + let mentor_role = self + .role_repo + .find_by_name("Mentor".to_string()) + .await + .map_err(|_| { + AppError::BadRequestError("Mentor Role Not Found".to_string()) + })?; + + entity.fullname = cmd.fullname.clone(); + entity.is_active = false; + entity.role = RolesDetailQueryDto { + id: mentor_role.id.to_string(), + name: mentor_role.name.clone(), + ..Default::default() + }; + entity.password = hash_password(&cmd.password).map_err(|e| { + error!("Failed to hash password for {}: {}", user_email, e); + AppError::InternalServerError("Failed to hash password".to_string()) + })?; + + let mut profile_ext = entity.profile_extension.clone().unwrap_or_default(); + profile_ext.phone_number = cmd.phone_number.clone(); + profile_ext.phone_for_verification = cmd.phone_for_verification.clone(); + profile_ext.gender = cmd.gender.clone(); + profile_ext.domicile = cmd.domicile.clone(); + profile_ext.bio = Some(cmd.bio.clone()); + profile_ext.last_education = cmd.last_education.clone(); + profile_ext.linkedin_url = cmd.linkedin_url.clone(); + profile_ext.github_url = cmd.github_url.clone(); + profile_ext.cv_url = cmd.cv_url.clone(); + profile_ext.portfolio_url = cmd.portfolio_url.clone(); + entity.profile_extension = Some(profile_ext); + + let uid_str = entity.id.clone(); + self.user_repo.update(entity).await.map_err(|e| { + error!("Failed to update user {} to mentor role: {}", user_email, e); + AppError::InternalServerError(e.to_string()) + })?; + + Uuid::parse_str(&uid_str) + .map_err(|e| AppError::InternalServerError(e.to_string()))? + } + Err(_) => { + let mentor_role = self + .role_repo + .find_by_name("Mentor".to_string()) + .await + .map_err(|_| { + AppError::BadRequestError("Mentor Role Not Found".to_string()) + })?; + + let hashed_password = hash_password(&cmd.password).map_err(|e| { + error!("Failed to hash password for new user {}: {}", user_email, e); + AppError::InternalServerError("Failed to hash password".to_string()) + })?; + + let new_user_id = Uuid::new_v4(); + let profile_ext = UserProfileExtensionDto { + phone_number: cmd.phone_number.clone(), + phone_for_verification: cmd.phone_for_verification.clone(), + gender: cmd.gender.clone(), + domicile: cmd.domicile.clone(), + bio: Some(cmd.bio.clone()), + last_education: cmd.last_education.clone(), + linkedin_url: cmd.linkedin_url.clone(), + github_url: cmd.github_url.clone(), + cv_url: cmd.cv_url.clone(), + portfolio_url: cmd.portfolio_url.clone(), + ..Default::default() + }; + let new_entity = UserEntity { + id: new_user_id.to_string(), + email: cmd.email.clone(), + fullname: cmd.fullname.clone(), + legal_name: Some(cmd.legal_name.clone()), + password: hashed_password, + is_active: false, + role: RolesDetailQueryDto { + id: mentor_role.id.to_string(), + name: mentor_role.name.clone(), + ..Default::default() + }, + profile_extension: Some(profile_ext), + created_at: imphnen_utils::get_iso_date(), + updated_at: imphnen_utils::get_iso_date(), + ..Default::default() + }; + + self.user_repo.create(new_entity).await.map_err(|e| { + error!("Failed to create new user {}: {}", user_email, e); + AppError::InternalServerError(e.to_string()) + })?; + + new_user_id + } + }; + + let new_entity = MentorEntity { + id: Uuid::new_v4(), + user_id, + industries: cmd.industries.clone(), + expertise: cmd.expertise.clone(), + languages: cmd.languages.clone(), + current_company: cmd.current_company.clone(), + current_role: cmd.current_role.clone(), + years_of_experience: cmd.years_of_experience, + topics_of_interest: cmd.topics_of_interest.clone(), + preferred_mentee_level: cmd.preferred_mentee_level.clone(), + preferred_mentoring_formats: cmd.preferred_mentoring_formats.clone(), + availability_commitment: cmd.availability_commitment.clone(), + mentoring_rate: cmd.mentoring_rate_amount as f64, + status: "pending".to_string(), + is_deleted: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let mentor_id = self.repo.create(new_entity.clone()).await.map_err(|e| { + error!("Failed to create mentor profile for {}: {}", user_email, e); + e + })?; + + Ok(MentorRegistered { + id: mentor_id.to_string(), + user_id: user_id.to_string(), + email: Some(user_email), + status: "pending".to_string(), + created_at: new_entity.created_at.to_rfc3339(), + updated_at: new_entity.updated_at.to_rfc3339(), + }) + } + + pub async fn verify( + &self, + id: Uuid, + cmd: MentorVerifyCommand, + ) -> Result { + let mut entity = self.repo.find_by_id(id, false).await?; + entity.status = cmd.status; + entity.updated_at = chrono::Utc::now(); + + self.repo.update(entity).await?; + + let updated = self.repo.find_by_id(id, false).await?; + let user = self + .state + .user_lookup_service + .get_user_by_id(updated.user_id, self.state.as_ref()) + .await + .ok() + .map(|i| i.basic_info); + Ok(build_detail(&updated, user.as_ref())) + } + + pub async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.soft_delete(id).await + } +} diff --git a/imphnen-dimentorin/src/mentors/application/mentor_service.rs b/imphnen-dimentorin/src/mentors/application/mentor_service.rs index d4f5f95..32a7d1b 100644 --- a/imphnen-dimentorin/src/mentors/application/mentor_service.rs +++ b/imphnen-dimentorin/src/mentors/application/mentor_service.rs @@ -1,411 +1,113 @@ -use std::sync::Arc; -use async_trait::async_trait; -use paginator_rs::PaginationParams; -use paginator_utils::PaginatorResponse; -use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_libs::{AppState, hash_password}; -use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; -use imphnen_iam::users::domain::{UserRepository, UserEntity}; -use imphnen_iam::roles::domain::RoleRepository; -use tracing::error; -use crate::mentors::domain::{MentorEntity, MentorRepository, MentorService}; -use crate::mentors::infrastructure::http::dto::{ - MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto, - MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto, +use super::mentor_query_service::MentorQueryService; +use super::mentor_registration_service::MentorRegistrationService; +use super::mentor_update_service::MentorUpdateService; +use crate::mentors::domain::{ + MentorDetail, MentorEntity, MentorListPage, MentorRegisterCommand, + MentorRegistered, MentorRepository, MentorService, MentorUpdateCommand, + MentorVerifyCommand, }; +use async_trait::async_trait; +use imphnen_iam::roles::domain::RoleRepository; +use imphnen_iam::users::domain::UserRepository; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use std::sync::Arc; +use uuid::Uuid; pub struct MentorServiceImpl { - repo: Arc, - state: Arc, - user_repo: Arc, - role_repo: Arc, + query: MentorQueryService, + registration: MentorRegistrationService, + update: MentorUpdateService, } impl MentorServiceImpl { - pub fn new( - repo: Arc, - state: Arc, - user_repo: Arc, - role_repo: Arc, - ) -> Self { - Self { repo, state, user_repo, role_repo } - } - - fn build_detail_response( - entity: &MentorEntity, - user: Option<&imphnen_entities::UsersDetailQueryDto>, - ) -> MentorDetailResponseDto { - MentorDetailResponseDto { - id: entity.id.to_string(), - user_id: entity.user_id.to_string(), - fullname: user.map(|u| u.fullname.clone()), - email: user.map(|u| u.email.clone()), - legal_name: user.and_then(|u| u.legal_name.clone()), - gender: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.gender.clone()), - domicile: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.domicile.clone()), - phone_for_verification: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.phone_for_verification.clone()), - bio: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.bio.clone()), - last_education: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.last_education.clone()), - linkedin_url: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.linkedin_url.clone()), - github_url: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.github_url.clone()), - cv_url: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.cv_url.clone()), - portfolio_url: user - .and_then(|u| u.profile_extension.as_ref()) - .and_then(|ext| ext.portfolio_url.clone()), - industries: entity.industries.clone(), - expertise: entity.expertise.clone(), - languages: entity.languages.clone(), - current_company: entity.current_company.clone(), - current_role: entity.current_role.clone(), - years_of_experience: entity.years_of_experience, - topics_of_interest: entity.topics_of_interest.clone(), - preferred_mentee_level: entity.preferred_mentee_level.clone(), - preferred_mentoring_formats: entity.preferred_mentoring_formats.clone(), - availability_commitment: entity.availability_commitment.clone(), - mentoring_rate: entity.mentoring_rate, - status: entity.status.clone(), - created_at: entity.created_at.to_rfc3339(), - updated_at: entity.updated_at.to_rfc3339(), - } - } + pub fn new( + repo: Arc, + state: Arc, + user_repo: Arc, + role_repo: Arc, + ) -> Self { + Self { + query: MentorQueryService { + repo: Arc::clone(&repo), + state: Arc::clone(&state), + }, + registration: MentorRegistrationService { + repo: Arc::clone(&repo), + state: Arc::clone(&state), + user_repo, + role_repo, + }, + update: MentorUpdateService { + repo: Arc::clone(&repo), + state: Arc::clone(&state), + }, + } + } } #[async_trait] impl MentorService for MentorServiceImpl { - async fn list( - &self, - params: PaginationParams, - ) -> Result, AppError> { - let result = self.repo.find_all(params).await?; + async fn list( + &self, + params: PaginationParams, + ) -> Result { + self.query.list(params).await + } - let mut items: Vec = Vec::with_capacity(result.data.len()); - for entity in &result.data { - let mut item = MentorListResponseDto { - id: entity.id.to_string(), - user_id: entity.user_id.to_string(), - fullname: None, - email: None, - status: entity.status.clone(), - created_at: entity.created_at.to_rfc3339(), - updated_at: entity.updated_at.to_rfc3339(), - }; - if let Ok(info) = self.state.user_lookup_service - .get_user_by_id(entity.user_id, self.state.as_ref()) - .await - { - item.fullname = Some(info.basic_info.fullname); - item.email = Some(info.basic_info.email); - } - items.push(item); - } + async fn get_by_id(&self, id: Uuid) -> Result { + self.query.get_by_id(id).await + } - Ok(PaginatorResponse { data: items, meta: result.meta }) - } + async fn get_by_email(&self, email: &str) -> Result { + self.query.get_by_email(email).await + } - async fn get_by_id(&self, id: Uuid) -> Result { - let entity = self.repo.find_by_id(id, false).await?; - let user = self.state.user_lookup_service - .get_user_by_id(entity.user_id, self.state.as_ref()) - .await - .ok() - .map(|i| i.basic_info); - Ok(Self::build_detail_response(&entity, user.as_ref())) - } + async fn register( + &self, + cmd: MentorRegisterCommand, + ) -> Result { + self.registration.register(cmd).await + } - async fn get_by_email(&self, email: &str) -> Result { - let user_dto = self.state.user_lookup_service - .get_user_by_email(email, self.state.as_ref()) - .await - .map(|i| i.basic_info) - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + async fn update( + &self, + id: Uuid, + cmd: MentorUpdateCommand, + ) -> Result { + self.update.update(id, cmd).await + } - let user_id = Uuid::parse_str(&user_dto.id) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn update_me( + &self, + email: &str, + cmd: MentorUpdateCommand, + ) -> Result { + self.update.update_me(email, cmd).await + } - let entity = self.repo.find_by_user_id(user_id, false).await?; - Ok(Self::build_detail_response(&entity, Some(&user_dto))) - } + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.registration.delete(id).await + } - async fn register( - &self, - dto: MentorUserRegisterRequestDto, - ) -> Result { - let user_email = dto.email.clone(); + async fn verify( + &self, + id: Uuid, + cmd: MentorVerifyCommand, + ) -> Result { + self.registration.verify(id, cmd).await + } - let user_id: Uuid = match self.user_repo.find_by_email(user_email.clone()).await { - Ok(mut entity) => { - let existing_user_id = Uuid::parse_str(&entity.id) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn get_status(&self, email: &str) -> Result { + self.query.get_status(email).await + } - if self.repo.find_by_user_id(existing_user_id, false).await.is_ok() { - return Err(AppError::ConflictError( - "Mentor profile already exists for this user".to_string(), - )); - } - - let mentor_role = self.role_repo - .find_by_name("Mentor".to_string()) - .await - .map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?; - - entity.fullname = dto.fullname.clone(); - entity.is_active = false; - entity.role = RolesDetailQueryDto { - id: mentor_role.id.to_string(), - name: mentor_role.name.clone(), - ..Default::default() - }; - entity.password = hash_password(&dto.password).map_err(|e| { - error!("Failed to hash password for {}: {}", user_email, e); - AppError::InternalServerError("Failed to hash password".to_string()) - })?; - - let mut profile_ext = entity.profile_extension.clone().unwrap_or_default(); - profile_ext.phone_number = dto.phone_number.clone(); - profile_ext.phone_for_verification = dto.identity_and_verification.phone_for_verification.clone(); - profile_ext.gender = dto.identity_and_verification.gender.clone(); - profile_ext.domicile = dto.identity_and_verification.domicile.clone(); - profile_ext.bio = Some(dto.professional_profile.bio.clone()); - profile_ext.last_education = dto.professional_profile.last_education.clone(); - profile_ext.linkedin_url = dto.professional_profile.linkedin_url.clone(); - profile_ext.github_url = dto.professional_profile.github_url.clone(); - profile_ext.cv_url = dto.professional_profile.cv_url.clone(); - profile_ext.portfolio_url = dto.professional_profile.portfolio_url.clone(); - entity.profile_extension = Some(profile_ext); - - let uid_str = entity.id.clone(); - self.user_repo.update(entity).await.map_err(|e| { - error!("Failed to update user {} to mentor role: {}", user_email, e); - AppError::InternalServerError(e.to_string()) - })?; - - Uuid::parse_str(&uid_str) - .map_err(|e| AppError::InternalServerError(e.to_string()))? - } - Err(_) => { - let mentor_role = self.role_repo - .find_by_name("Mentor".to_string()) - .await - .map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?; - - let hashed_password = hash_password(&dto.password).map_err(|e| { - error!("Failed to hash password for new user {}: {}", user_email, e); - AppError::InternalServerError("Failed to hash password".to_string()) - })?; - - let new_user_id = Uuid::new_v4(); - let profile_ext = UserProfileExtensionDto { - phone_number: dto.phone_number.clone(), - phone_for_verification: dto.identity_and_verification.phone_for_verification.clone(), - gender: dto.identity_and_verification.gender.clone(), - domicile: dto.identity_and_verification.domicile.clone(), - bio: Some(dto.professional_profile.bio.clone()), - last_education: dto.professional_profile.last_education.clone(), - linkedin_url: dto.professional_profile.linkedin_url.clone(), - github_url: dto.professional_profile.github_url.clone(), - cv_url: dto.professional_profile.cv_url.clone(), - portfolio_url: dto.professional_profile.portfolio_url.clone(), - ..Default::default() - }; - let new_entity = UserEntity { - id: new_user_id.to_string(), - email: dto.email.clone(), - fullname: dto.fullname.clone(), - legal_name: Some(dto.identity_and_verification.legal_name.clone()), - password: hashed_password, - is_active: false, - role: RolesDetailQueryDto { - id: mentor_role.id.to_string(), - name: mentor_role.name.clone(), - ..Default::default() - }, - profile_extension: Some(profile_ext), - created_at: imphnen_utils::get_iso_date(), - updated_at: imphnen_utils::get_iso_date(), - ..Default::default() - }; - - self.user_repo.create(new_entity).await.map_err(|e| { - error!("Failed to create new user {}: {}", user_email, e); - AppError::InternalServerError(e.to_string()) - })?; - - new_user_id - } - }; - - let new_entity = MentorEntity { - id: Uuid::new_v4(), - user_id, - industries: dto.professional_profile.industries.clone(), - expertise: dto.professional_profile.expertise.clone(), - languages: dto.professional_profile.languages.clone(), - current_company: dto.professional_profile.current_company.clone(), - current_role: dto.professional_profile.current_role.clone(), - years_of_experience: dto.professional_profile.years_of_experience, - topics_of_interest: dto.mentoring_logistics.topics_of_interest.clone(), - preferred_mentee_level: dto.mentoring_logistics.preferred_mentee_level.clone(), - preferred_mentoring_formats: dto.mentoring_logistics.preferred_mentoring_formats.clone(), - availability_commitment: dto.mentoring_logistics.availability_commitment.clone(), - mentoring_rate: dto.mentoring_logistics.mentoring_rate_amount as f64, - status: "pending".to_string(), - is_deleted: false, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; - - let mentor_id = self.repo.create(new_entity.clone()).await.map_err(|e| { - error!("Failed to create mentor profile for {}: {}", user_email, e); - e - })?; - - Ok(MentorRegisterResponseDto { - id: mentor_id.to_string(), - user_id: user_id.to_string(), - email: Some(user_email), - status: "pending".to_string(), - created_at: new_entity.created_at.to_rfc3339(), - updated_at: new_entity.updated_at.to_rfc3339(), - }) - } - - async fn update( - &self, - id: Uuid, - dto: MentorUpdateRequestDto, - ) -> Result { - let mut entity = self.repo.find_by_id(id, false).await?; - - if let Some(val) = dto.industries { entity.industries = val; } - if let Some(val) = dto.expertise { entity.expertise = val; } - if let Some(val) = dto.languages { entity.languages = val; } - if let Some(val) = dto.current_company { entity.current_company = val; } - if let Some(val) = dto.current_role { entity.current_role = val; } - if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; } - if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; } - if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; } - if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; } - if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; } - if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; } - entity.updated_at = chrono::Utc::now(); - - self.repo.update(entity).await?; - - let updated = self.repo.find_by_id(id, false).await?; - let user = self.state.user_lookup_service - .get_user_by_id(updated.user_id, self.state.as_ref()) - .await - .ok() - .map(|i| i.basic_info); - Ok(Self::build_detail_response(&updated, user.as_ref())) - } - - async fn update_me( - &self, - email: &str, - dto: MentorUpdateRequestDto, - ) -> Result { - let user_dto = self.state.user_lookup_service - .get_user_by_email(email, self.state.as_ref()) - .await - .map(|i| i.basic_info) - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - - let user_id = Uuid::parse_str(&user_dto.id) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let mut entity = self.repo.find_by_user_id(user_id, false).await?; - - if let Some(val) = dto.industries { entity.industries = val; } - if let Some(val) = dto.expertise { entity.expertise = val; } - if let Some(val) = dto.languages { entity.languages = val; } - if let Some(val) = dto.current_company { entity.current_company = val; } - if let Some(val) = dto.current_role { entity.current_role = val; } - if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; } - if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; } - if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; } - if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; } - if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; } - if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; } - entity.updated_at = chrono::Utc::now(); - - let entity_id = entity.id; - self.repo.update(entity).await?; - - let updated = self.repo.find_by_id(entity_id, false).await?; - let refreshed_user = self.state.user_lookup_service - .get_user_by_id(updated.user_id, self.state.as_ref()) - .await - .ok() - .map(|i| i.basic_info); - Ok(Self::build_detail_response(&updated, refreshed_user.as_ref())) - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.soft_delete(id).await - } - - async fn verify( - &self, - id: Uuid, - dto: MentorVerifyRequestDto, - ) -> Result { - let mut entity = self.repo.find_by_id(id, false).await?; - entity.status = dto.status; - entity.updated_at = chrono::Utc::now(); - - self.repo.update(entity).await?; - - let updated = self.repo.find_by_id(id, false).await?; - let user = self.state.user_lookup_service - .get_user_by_id(updated.user_id, self.state.as_ref()) - .await - .ok() - .map(|i| i.basic_info); - Ok(Self::build_detail_response(&updated, user.as_ref())) - } - - async fn get_status(&self, email: &str) -> Result { - let user_dto = self.state.user_lookup_service - .get_user_by_email(email, self.state.as_ref()) - .await - .map(|i| i.basic_info) - .map_err(|_| { - AppError::NotFoundError("No mentor application found for current user".to_string()) - })?; - - let user_id = Uuid::parse_str(&user_dto.id) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let entity = self.repo.find_by_user_id(user_id, false).await.map_err(|_| { - AppError::NotFoundError("No mentor application found for current user".to_string()) - })?; - - Ok(entity.status) - } - - async fn get_entity_by_id( - &self, - id: Uuid, - include_deleted: bool, - ) -> Result { - self.repo.find_by_id(id, include_deleted).await - } + async fn get_entity_by_id( + &self, + id: Uuid, + include_deleted: bool, + ) -> Result { + self.query.get_entity_by_id(id, include_deleted).await + } } diff --git a/imphnen-dimentorin/src/mentors/application/mentor_update_service.rs b/imphnen-dimentorin/src/mentors/application/mentor_update_service.rs new file mode 100644 index 0000000..a28c727 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/application/mentor_update_service.rs @@ -0,0 +1,135 @@ +use super::mentor_query_service::build_detail; +use crate::mentors::domain::{MentorDetail, MentorRepository, MentorUpdateCommand}; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct MentorUpdateService { + pub repo: Arc, + pub state: Arc, +} + +impl MentorUpdateService { + pub async fn update( + &self, + id: Uuid, + cmd: MentorUpdateCommand, + ) -> Result { + let mut entity = self.repo.find_by_id(id, false).await?; + + if let Some(val) = cmd.industries { + entity.industries = val; + } + if let Some(val) = cmd.expertise { + entity.expertise = val; + } + if let Some(val) = cmd.languages { + entity.languages = val; + } + if let Some(val) = cmd.current_company { + entity.current_company = val; + } + if let Some(val) = cmd.current_role { + entity.current_role = val; + } + if let Some(val) = cmd.years_of_experience { + entity.years_of_experience = val; + } + if let Some(val) = cmd.topics_of_interest { + entity.topics_of_interest = val; + } + if let Some(val) = cmd.preferred_mentee_level { + entity.preferred_mentee_level = val; + } + if let Some(val) = cmd.preferred_mentoring_formats { + entity.preferred_mentoring_formats = val; + } + if let Some(val) = cmd.availability_commitment { + entity.availability_commitment = val; + } + if let Some(val) = cmd.mentoring_rate_amount { + entity.mentoring_rate = val as f64; + } + entity.updated_at = chrono::Utc::now(); + + self.repo.update(entity).await?; + + let updated = self.repo.find_by_id(id, false).await?; + let user = self + .state + .user_lookup_service + .get_user_by_id(updated.user_id, self.state.as_ref()) + .await + .ok() + .map(|i| i.basic_info); + Ok(build_detail(&updated, user.as_ref())) + } + + pub async fn update_me( + &self, + email: &str, + cmd: MentorUpdateCommand, + ) -> Result { + let user_dto = self + .state + .user_lookup_service + .get_user_by_email(email, self.state.as_ref()) + .await + .map(|i| i.basic_info) + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + + let user_id = Uuid::parse_str(&user_dto.id) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let mut entity = self.repo.find_by_user_id(user_id, false).await?; + + if let Some(val) = cmd.industries { + entity.industries = val; + } + if let Some(val) = cmd.expertise { + entity.expertise = val; + } + if let Some(val) = cmd.languages { + entity.languages = val; + } + if let Some(val) = cmd.current_company { + entity.current_company = val; + } + if let Some(val) = cmd.current_role { + entity.current_role = val; + } + if let Some(val) = cmd.years_of_experience { + entity.years_of_experience = val; + } + if let Some(val) = cmd.topics_of_interest { + entity.topics_of_interest = val; + } + if let Some(val) = cmd.preferred_mentee_level { + entity.preferred_mentee_level = val; + } + if let Some(val) = cmd.preferred_mentoring_formats { + entity.preferred_mentoring_formats = val; + } + if let Some(val) = cmd.availability_commitment { + entity.availability_commitment = val; + } + if let Some(val) = cmd.mentoring_rate_amount { + entity.mentoring_rate = val as f64; + } + entity.updated_at = chrono::Utc::now(); + + let entity_id = entity.id; + self.repo.update(entity).await?; + + let updated = self.repo.find_by_id(entity_id, false).await?; + let refreshed_user = self + .state + .user_lookup_service + .get_user_by_id(updated.user_id, self.state.as_ref()) + .await + .ok() + .map(|i| i.basic_info); + Ok(build_detail(&updated, refreshed_user.as_ref())) + } +} diff --git a/imphnen-dimentorin/src/mentors/application/mod.rs b/imphnen-dimentorin/src/mentors/application/mod.rs index 2b38c7f..b07a0e3 100644 --- a/imphnen-dimentorin/src/mentors/application/mod.rs +++ b/imphnen-dimentorin/src/mentors/application/mod.rs @@ -1,3 +1,6 @@ +pub mod mentor_query_service; +pub mod mentor_registration_service; pub mod mentor_service; +pub mod mentor_update_service; pub use mentor_service::MentorServiceImpl; diff --git a/imphnen-dimentorin/src/mentors/domain/mentor.rs b/imphnen-dimentorin/src/mentors/domain/mentor.rs index f8ad1c2..9d081ae 100644 --- a/imphnen-dimentorin/src/mentors/domain/mentor.rs +++ b/imphnen-dimentorin/src/mentors/domain/mentor.rs @@ -2,21 +2,21 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct MentorEntity { - pub id: Uuid, - pub user_id: Uuid, - pub industries: Vec, - pub expertise: Vec, - pub languages: Vec, - pub current_company: String, - pub current_role: String, - pub years_of_experience: i32, - pub topics_of_interest: Vec, - pub preferred_mentee_level: Vec, - pub preferred_mentoring_formats: Vec, - pub availability_commitment: String, - pub mentoring_rate: f64, - pub status: String, - pub is_deleted: bool, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, + pub id: Uuid, + pub user_id: Uuid, + pub industries: Vec, + pub expertise: Vec, + pub languages: Vec, + pub current_company: String, + pub current_role: String, + pub years_of_experience: i32, + pub topics_of_interest: Vec, + pub preferred_mentee_level: Vec, + pub preferred_mentoring_formats: Vec, + pub availability_commitment: String, + pub mentoring_rate: f64, + pub status: String, + pub is_deleted: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, } diff --git a/imphnen-dimentorin/src/mentors/domain/mentor_types.rs b/imphnen-dimentorin/src/mentors/domain/mentor_types.rs new file mode 100644 index 0000000..09894ac --- /dev/null +++ b/imphnen-dimentorin/src/mentors/domain/mentor_types.rs @@ -0,0 +1,110 @@ +use paginator_utils::PaginatorResponse; + +pub struct MentorListItem { + pub id: String, + pub user_id: String, + pub fullname: Option, + pub email: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +pub struct MentorDetail { + pub id: String, + pub user_id: String, + pub fullname: Option, + pub email: Option, + pub legal_name: Option, + pub gender: Option, + pub domicile: Option, + pub phone_for_verification: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub industries: Vec, + pub expertise: Vec, + pub languages: Vec, + pub current_company: String, + pub current_role: String, + pub years_of_experience: i32, + pub topics_of_interest: Vec, + pub preferred_mentee_level: Vec, + pub preferred_mentoring_formats: Vec, + pub availability_commitment: String, + pub mentoring_rate: f64, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +pub struct MentorRegistered { + pub id: String, + pub user_id: String, + pub email: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +pub struct MentorRegisterCommand { + pub email: String, + pub password: String, + pub fullname: String, + pub phone_number: Option, + pub legal_name: String, + pub gender: Option, + pub domicile: Option, + pub identity_document_url: String, + pub phone_for_verification: Option, + pub bio: String, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub industries: Vec, + pub expertise: Vec, + pub languages: Vec, + pub current_company: String, + pub current_role: String, + pub years_of_experience: i32, + pub topics_of_interest: Vec, + pub preferred_mentee_level: Vec, + pub preferred_mentoring_formats: Vec, + pub availability_commitment: String, + pub mentoring_rate_amount: u64, +} + +pub struct MentorUpdateCommand { + pub legal_name: Option, + pub gender: Option, + pub domicile: Option, + pub phone_for_verification: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub industries: Option>, + pub expertise: Option>, + pub languages: Option>, + pub current_company: Option, + pub current_role: Option, + pub years_of_experience: Option, + pub topics_of_interest: Option>, + pub preferred_mentee_level: Option>, + pub preferred_mentoring_formats: Option>, + pub availability_commitment: Option, + pub mentoring_rate_amount: Option, +} + +pub struct MentorVerifyCommand { + pub status: String, +} + +pub type MentorListPage = PaginatorResponse; diff --git a/imphnen-dimentorin/src/mentors/domain/mod.rs b/imphnen-dimentorin/src/mentors/domain/mod.rs index 9d01a77..3549807 100644 --- a/imphnen-dimentorin/src/mentors/domain/mod.rs +++ b/imphnen-dimentorin/src/mentors/domain/mod.rs @@ -1,7 +1,12 @@ pub mod mentor; +pub mod mentor_types; pub mod repository; pub mod service; pub use mentor::MentorEntity; +pub use mentor_types::{ + MentorDetail, MentorListItem, MentorListPage, MentorRegisterCommand, + MentorRegistered, MentorUpdateCommand, MentorVerifyCommand, +}; pub use repository::MentorRepository; pub use service::MentorService; diff --git a/imphnen-dimentorin/src/mentors/domain/repository.rs b/imphnen-dimentorin/src/mentors/domain/repository.rs index 7e221e5..2a34764 100644 --- a/imphnen-dimentorin/src/mentors/domain/repository.rs +++ b/imphnen-dimentorin/src/mentors/domain/repository.rs @@ -1,33 +1,32 @@ +use super::mentor::MentorEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::mentor::MentorEntity; - #[async_trait] pub trait MentorRepository: Send + Sync { - async fn find_all( - &self, - params: PaginationParams, - ) -> Result, AppError>; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; - async fn find_by_id( - &self, - id: Uuid, - include_deleted: bool, - ) -> Result; + async fn find_by_id( + &self, + id: Uuid, + include_deleted: bool, + ) -> Result; - async fn find_by_user_id( - &self, - user_id: Uuid, - include_deleted: bool, - ) -> Result; + async fn find_by_user_id( + &self, + user_id: Uuid, + include_deleted: bool, + ) -> Result; - async fn create(&self, entity: MentorEntity) -> Result; + async fn create(&self, entity: MentorEntity) -> Result; - async fn update(&self, entity: MentorEntity) -> Result<(), AppError>; + async fn update(&self, entity: MentorEntity) -> Result<(), AppError>; - async fn soft_delete(&self, id: Uuid) -> Result<(), AppError>; + async fn soft_delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-dimentorin/src/mentors/domain/service.rs b/imphnen-dimentorin/src/mentors/domain/service.rs index a180445..018d736 100644 --- a/imphnen-dimentorin/src/mentors/domain/service.rs +++ b/imphnen-dimentorin/src/mentors/domain/service.rs @@ -1,55 +1,52 @@ -use async_trait::async_trait; -use paginator_rs::PaginationParams; -use paginator_utils::PaginatorResponse; -use uuid::Uuid; -use imphnen_utils::AppError; use super::mentor::MentorEntity; -use crate::mentors::infrastructure::http::dto::{ - MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto, - MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto, +use super::mentor_types::{ + MentorDetail, MentorListPage, MentorRegisterCommand, MentorRegistered, + MentorUpdateCommand, MentorVerifyCommand, }; +use async_trait::async_trait; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use uuid::Uuid; #[async_trait] pub trait MentorService: Send + Sync { - async fn list( - &self, - params: PaginationParams, - ) -> Result, AppError>; + async fn list(&self, params: PaginationParams) + -> Result; - async fn get_by_id(&self, id: Uuid) -> Result; + async fn get_by_id(&self, id: Uuid) -> Result; - async fn get_by_email(&self, email: &str) -> Result; + async fn get_by_email(&self, email: &str) -> Result; - async fn register( - &self, - dto: MentorUserRegisterRequestDto, - ) -> Result; + async fn register( + &self, + cmd: MentorRegisterCommand, + ) -> Result; - async fn update( - &self, - id: Uuid, - dto: MentorUpdateRequestDto, - ) -> Result; + async fn update( + &self, + id: Uuid, + cmd: MentorUpdateCommand, + ) -> Result; - async fn update_me( - &self, - email: &str, - dto: MentorUpdateRequestDto, - ) -> Result; + async fn update_me( + &self, + email: &str, + cmd: MentorUpdateCommand, + ) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; - async fn verify( - &self, - id: Uuid, - dto: MentorVerifyRequestDto, - ) -> Result; + async fn verify( + &self, + id: Uuid, + cmd: MentorVerifyCommand, + ) -> Result; - async fn get_status(&self, email: &str) -> Result; + async fn get_status(&self, email: &str) -> Result; - async fn get_entity_by_id( - &self, - id: Uuid, - include_deleted: bool, - ) -> Result; + async fn get_entity_by_id( + &self, + id: Uuid, + include_deleted: bool, + ) -> Result; } diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/dto.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/dto.rs deleted file mode 100644 index 03c5f2e..0000000 --- a/imphnen-dimentorin/src/mentors/infrastructure/http/dto.rs +++ /dev/null @@ -1,261 +0,0 @@ -use imphnen_libs::ZodValidate; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use zod_rs::prelude::*; - -// ============================================================ -// Response DTOs -// ============================================================ - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct MentorListResponseDto { - pub id: String, - pub user_id: String, - pub fullname: Option, - pub email: Option, - pub status: String, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct MentorDetailResponseDto { - pub id: String, - pub user_id: String, - pub fullname: Option, - pub email: Option, - pub legal_name: Option, - pub gender: Option, - pub domicile: Option, - pub phone_for_verification: Option, - pub bio: Option, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, - pub industries: Vec, - pub expertise: Vec, - pub languages: Vec, - pub current_company: String, - pub current_role: String, - pub years_of_experience: i32, - pub topics_of_interest: Vec, - pub preferred_mentee_level: Vec, - pub preferred_mentoring_formats: Vec, - pub availability_commitment: String, - pub mentoring_rate: f64, - pub status: String, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct MentorRegisterResponseDto { - pub id: String, - pub user_id: String, - pub email: Option, - pub status: String, - pub created_at: String, - pub updated_at: String, -} - -// ============================================================ -// Request DTOs -// ============================================================ - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct MentorUserRegisterRequestDto { - #[zod(email, min_length(1))] - pub email: String, - #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] - pub password: String, - #[zod(min_length(2))] - pub fullname: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_number: Option, - pub identity_and_verification: IdentityAndVerification, - pub professional_profile: ProfessionalProfile, - pub mentoring_logistics: MentoringLogistics, -} - -impl ZodValidate for MentorUserRegisterRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct IdentityAndVerification { - #[zod(min_length(3))] - pub legal_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub gender: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub domicile: Option, - #[zod(url)] - pub identity_document_url: String, - #[zod(min_length(10), max_length(15))] - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_for_verification: Option, -} - -impl ZodValidate for IdentityAndVerification { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct ProfessionalProfile { - #[zod(min_length(50))] - pub bio: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_education: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub linkedin_url: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub github_url: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub cv_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub portfolio_url: Option, - pub industries: Vec, - pub expertise: Vec, - pub languages: Vec, - #[zod(min_length(1))] - pub current_company: String, - #[zod(min_length(1))] - pub current_role: String, - #[zod(min(2.0), int)] - pub years_of_experience: i32, -} - -impl ZodValidate for ProfessionalProfile { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct MentoringLogistics { - pub topics_of_interest: Vec, - pub preferred_mentee_level: Vec, - pub preferred_mentoring_formats: Vec, - #[zod(min_length(5))] - pub availability_commitment: String, - #[zod(min(1.0))] - pub mentoring_rate_amount: u64, -} - -impl ZodValidate for MentoringLogistics { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct MentorUpdateRequestDto { - #[zod(min_length(3))] - #[serde(skip_serializing_if = "Option::is_none")] - pub legal_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub gender: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub domicile: Option, - #[zod(min_length(10), max_length(15))] - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_for_verification: Option, - #[zod(min_length(50))] - #[serde(skip_serializing_if = "Option::is_none")] - pub bio: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_education: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub linkedin_url: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub github_url: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub cv_url: Option, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub portfolio_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub industries: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub expertise: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub languages: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub current_company: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub current_role: Option, - #[zod(min(2.0), int)] - #[serde(skip_serializing_if = "Option::is_none")] - pub years_of_experience: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub topics_of_interest: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub preferred_mentee_level: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub preferred_mentoring_formats: Option>, - #[zod(min_length(5))] - #[serde(skip_serializing_if = "Option::is_none")] - pub availability_commitment: Option, - #[zod(min(1.0))] - #[serde(skip_serializing_if = "Option::is_none")] - pub mentoring_rate_amount: Option, -} - -impl ZodValidate for MentorUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct MentorVerifyRequestDto { - #[zod(min_length(1))] - pub status: String, -} - -impl ZodValidate for MentorVerifyRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema, Default)] -pub struct MentoringRate { - #[zod(min(1.0))] - pub amount: u64, - #[zod(min_length(1))] - pub currency: String, - #[zod(min_length(1))] - pub per_duration: String, -} - -impl ZodValidate for MentoringRate { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct MentorRegisterFromTokenRequestDto { - pub identity_and_verification: IdentityAndVerification, - pub professional_profile: ProfessionalProfile, - pub mentoring_logistics: MentoringLogistics, -} - -impl ZodValidate for MentorRegisterFromTokenRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/dto/mod.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/mod.rs new file mode 100644 index 0000000..1ca2a8c --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/mod.rs @@ -0,0 +1,14 @@ +pub mod nested; +pub mod request; +pub mod response; + +pub use nested::{ + IdentityAndVerification, MentoringLogistics, MentoringRate, ProfessionalProfile, +}; +pub use request::{ + MentorRegisterFromTokenRequestDto, MentorUpdateRequestDto, + MentorUserRegisterRequestDto, MentorVerifyRequestDto, +}; +pub use response::{ + MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto, +}; diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/dto/nested.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/nested.rs new file mode 100644 index 0000000..ad8a470 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/nested.rs @@ -0,0 +1,92 @@ +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zod_rs::prelude::*; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct IdentityAndVerification { + #[zod(min_length(3))] + pub legal_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub gender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domicile: Option, + #[zod(url)] + pub identity_document_url: String, + #[zod(min_length(10), max_length(15))] + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, +} + +impl ZodValidate for IdentityAndVerification { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct ProfessionalProfile { + #[zod(min_length(50))] + pub bio: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_education: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub linkedin_url: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub github_url: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub cv_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub portfolio_url: Option, + pub industries: Vec, + pub expertise: Vec, + pub languages: Vec, + #[zod(min_length(1))] + pub current_company: String, + #[zod(min_length(1))] + pub current_role: String, + #[zod(min(2.0), int)] + pub years_of_experience: i32, +} + +impl ZodValidate for ProfessionalProfile { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct MentoringLogistics { + pub topics_of_interest: Vec, + pub preferred_mentee_level: Vec, + pub preferred_mentoring_formats: Vec, + #[zod(min_length(5))] + pub availability_commitment: String, + #[zod(min(1.0))] + pub mentoring_rate_amount: u64, +} + +impl ZodValidate for MentoringLogistics { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema, Default)] +pub struct MentoringRate { + #[zod(min(1.0))] + pub amount: u64, + #[zod(min_length(1))] + pub currency: String, + #[zod(min_length(1))] + pub per_duration: String, +} + +impl ZodValidate for MentoringRate { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/dto/request.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/request.rs new file mode 100644 index 0000000..122d672 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/request.rs @@ -0,0 +1,187 @@ +use super::nested::{ + IdentityAndVerification, MentoringLogistics, ProfessionalProfile, +}; +use crate::mentors::domain::{ + MentorRegisterCommand, MentorUpdateCommand, MentorVerifyCommand, +}; +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zod_rs::prelude::*; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct MentorUserRegisterRequestDto { + #[zod(email, min_length(1))] + pub email: String, + #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] + pub password: String, + #[zod(min_length(2))] + pub fullname: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + pub identity_and_verification: IdentityAndVerification, + pub professional_profile: ProfessionalProfile, + pub mentoring_logistics: MentoringLogistics, +} + +impl ZodValidate for MentorUserRegisterRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for MentorRegisterCommand { + fn from(dto: MentorUserRegisterRequestDto) -> Self { + Self { + email: dto.email, + password: dto.password, + fullname: dto.fullname, + phone_number: dto.phone_number, + legal_name: dto.identity_and_verification.legal_name, + gender: dto.identity_and_verification.gender, + domicile: dto.identity_and_verification.domicile, + identity_document_url: dto.identity_and_verification.identity_document_url, + phone_for_verification: dto.identity_and_verification.phone_for_verification, + bio: dto.professional_profile.bio, + last_education: dto.professional_profile.last_education, + linkedin_url: dto.professional_profile.linkedin_url, + github_url: dto.professional_profile.github_url, + cv_url: dto.professional_profile.cv_url, + portfolio_url: dto.professional_profile.portfolio_url, + industries: dto.professional_profile.industries, + expertise: dto.professional_profile.expertise, + languages: dto.professional_profile.languages, + current_company: dto.professional_profile.current_company, + current_role: dto.professional_profile.current_role, + years_of_experience: dto.professional_profile.years_of_experience, + topics_of_interest: dto.mentoring_logistics.topics_of_interest, + preferred_mentee_level: dto.mentoring_logistics.preferred_mentee_level, + preferred_mentoring_formats: dto + .mentoring_logistics + .preferred_mentoring_formats, + availability_commitment: dto.mentoring_logistics.availability_commitment, + mentoring_rate_amount: dto.mentoring_logistics.mentoring_rate_amount, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct MentorUpdateRequestDto { + #[zod(min_length(3))] + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domicile: Option, + #[zod(min_length(10), max_length(15))] + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, + #[zod(min_length(50))] + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_education: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub linkedin_url: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub github_url: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub cv_url: Option, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub portfolio_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub industries: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expertise: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub languages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_company: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_role: Option, + #[zod(min(2.0), int)] + #[serde(skip_serializing_if = "Option::is_none")] + pub years_of_experience: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub topics_of_interest: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_mentee_level: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_mentoring_formats: Option>, + #[zod(min_length(5))] + #[serde(skip_serializing_if = "Option::is_none")] + pub availability_commitment: Option, + #[zod(min(1.0))] + #[serde(skip_serializing_if = "Option::is_none")] + pub mentoring_rate_amount: Option, +} + +impl ZodValidate for MentorUpdateRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for MentorUpdateCommand { + fn from(dto: MentorUpdateRequestDto) -> Self { + Self { + legal_name: dto.legal_name, + gender: dto.gender, + domicile: dto.domicile, + phone_for_verification: dto.phone_for_verification, + bio: dto.bio, + last_education: dto.last_education, + linkedin_url: dto.linkedin_url, + github_url: dto.github_url, + cv_url: dto.cv_url, + portfolio_url: dto.portfolio_url, + 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_amount: dto.mentoring_rate_amount, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct MentorVerifyRequestDto { + #[zod(min_length(1))] + pub status: String, +} + +impl ZodValidate for MentorVerifyRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for MentorVerifyCommand { + fn from(dto: MentorVerifyRequestDto) -> Self { + Self { status: dto.status } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct MentorRegisterFromTokenRequestDto { + pub identity_and_verification: IdentityAndVerification, + pub professional_profile: ProfessionalProfile, + pub mentoring_logistics: MentoringLogistics, +} + +impl ZodValidate for MentorRegisterFromTokenRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/dto/response.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/response.rs new file mode 100644 index 0000000..cfed825 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/dto/response.rs @@ -0,0 +1,118 @@ +use crate::mentors::domain::{MentorDetail, MentorListItem, MentorRegistered}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MentorListResponseDto { + pub id: String, + pub user_id: String, + pub fullname: Option, + pub email: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for MentorListResponseDto { + fn from(item: MentorListItem) -> Self { + Self { + id: item.id, + user_id: item.user_id, + fullname: item.fullname, + email: item.email, + status: item.status, + created_at: item.created_at, + updated_at: item.updated_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MentorDetailResponseDto { + pub id: String, + pub user_id: String, + pub fullname: Option, + pub email: Option, + pub legal_name: Option, + pub gender: Option, + pub domicile: Option, + pub phone_for_verification: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub industries: Vec, + pub expertise: Vec, + pub languages: Vec, + pub current_company: String, + pub current_role: String, + pub years_of_experience: i32, + pub topics_of_interest: Vec, + pub preferred_mentee_level: Vec, + pub preferred_mentoring_formats: Vec, + pub availability_commitment: String, + pub mentoring_rate: f64, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for MentorDetailResponseDto { + fn from(d: MentorDetail) -> Self { + Self { + id: d.id, + user_id: d.user_id, + fullname: d.fullname, + email: d.email, + legal_name: d.legal_name, + gender: d.gender, + domicile: d.domicile, + phone_for_verification: d.phone_for_verification, + bio: d.bio, + last_education: d.last_education, + linkedin_url: d.linkedin_url, + github_url: d.github_url, + cv_url: d.cv_url, + portfolio_url: d.portfolio_url, + industries: d.industries, + expertise: d.expertise, + languages: d.languages, + current_company: d.current_company, + current_role: d.current_role, + years_of_experience: d.years_of_experience, + topics_of_interest: d.topics_of_interest, + preferred_mentee_level: d.preferred_mentee_level, + preferred_mentoring_formats: d.preferred_mentoring_formats, + availability_commitment: d.availability_commitment, + mentoring_rate: d.mentoring_rate, + status: d.status, + created_at: d.created_at, + updated_at: d.updated_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MentorRegisterResponseDto { + pub id: String, + pub user_id: String, + pub email: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for MentorRegisterResponseDto { + fn from(r: MentorRegistered) -> Self { + Self { + id: r.id, + user_id: r.user_id, + email: r.email, + status: r.status, + created_at: r.created_at, + updated_at: r.updated_at, + } + } +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/handlers.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers.rs deleted file mode 100644 index 4f1ee49..0000000 --- a/imphnen-dimentorin/src/mentors/infrastructure/http/handlers.rs +++ /dev/null @@ -1,279 +0,0 @@ -use std::sync::Arc; -use axum::{ - extract::{Extension, Path}, - http::HeaderMap, - response::{IntoResponse, Response}, -}; -use paginator_axum::PaginationQuery; -use uuid::Uuid; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage, extract_email}; -use imphnen_iam::{PermissionsEnum, require_permissions}; -use imphnen_utils::AppError; -use crate::mentors::domain::MentorService; -use super::dto::{ - MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto, - MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto, -}; - -#[utoipa::path( - post, - path = "/v1/mentors/create", - request_body = MentorUserRegisterRequestDto, - responses( - (status = 200, description = "[PUBLIC] Mentor registered successfully", body = MentorRegisterResponseDto), - (status = 400, description = "[PUBLIC] Bad request - validation error"), - (status = 409, description = "[PUBLIC] Conflict - user already has mentor profile"), - (status = 500, description = "[PUBLIC] Internal server error") - ), - tag = "Mentors" -)] -pub async fn post_register_mentor( - Extension(service): Extension>, - ValidatedJson(dto): ValidatedJson, -) -> Response { - match service.register(dto).await { - Ok(resp) => ApiSuccess(resp).into_response(), - Err(e) => ApiMessage::new(e.status_code(), e.to_string()).into_response(), - } -} - -#[utoipa::path( - get, - path = "/v1/mentors", - params( - ("page" = Option, Query, description = "Page number"), - ("per_page" = Option, Query, description = "Items per page"), - ("search" = Option, Query, description = "Search query"), - ("sort_by" = Option, Query, description = "Sort by field"), - ("order" = Option, Query, description = "Sort order (ASC/DESC)"), - ), - responses( - (status = 200, description = "[ADMIN] Get list of mentors", body = Vec), - (status = 500, description = "[ADMIN] Internal server error") - ), - tag = "Mentors", - security(("Bearer" = [])) -)] -pub async fn get_mentor_list( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadListMentors], { - let result = service.list(params).await?; - Ok(ApiPaginated(result)) - }) -} - -#[utoipa::path( - get, - path = "/v1/mentors/detail/{id}", - params( - ("id" = String, Path, description = "Mentor ID") - ), - responses( - (status = 200, description = "[ADMIN] Get mentor by ID", body = MentorDetailResponseDto), - (status = 404, description = "[ADMIN] Mentor not found"), - (status = 500, description = "[ADMIN] Internal server error") - ), - tag = "Mentors", - security(("Bearer" = [])) -)] -pub async fn get_mentor_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, -) -> Result { - let mentor_uuid = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?; - require_permissions!(headers, state, [PermissionsEnum::ReadDetailMentors], { - let dto = service.get_by_id(mentor_uuid).await?; - Ok(ApiSuccess(dto)) - }) -} - -#[utoipa::path( - put, - path = "/v1/mentors/update/{id}", - params( - ("id" = String, Path, description = "Mentor ID") - ), - request_body = MentorUpdateRequestDto, - responses( - (status = 200, description = "[ADMIN] Mentor updated successfully", body = MentorDetailResponseDto), - (status = 400, description = "[ADMIN] Bad request - validation error"), - (status = 404, description = "[ADMIN] Mentor not found"), - (status = 500, description = "[ADMIN] Internal server error") - ), - tag = "Mentors - Admin", - security(("Bearer" = [])) -)] -pub async fn put_update_mentor( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - ValidatedJson(dto): ValidatedJson, -) -> Result { - let mentor_uuid = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?; - require_permissions!(headers, state, [PermissionsEnum::UpdateMentors], { - let result = service.update(mentor_uuid, dto).await?; - Ok(ApiSuccess(result)) - }) -} - -#[utoipa::path( - delete, - path = "/v1/mentors/delete/{id}", - params( - ("id" = String, Path, description = "Mentor ID") - ), - responses( - (status = 200, description = "[ADMIN] Mentor deleted successfully"), - (status = 404, description = "[ADMIN] Mentor not found"), - (status = 500, description = "[ADMIN] Internal server error") - ), - tag = "Mentors - Admin", - security(("Bearer" = [])) -)] -pub async fn delete_mentor( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, -) -> Result { - let mentor_uuid = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?; - require_permissions!(headers, state, [PermissionsEnum::DeleteMentors], { - service.delete(mentor_uuid).await?; - Ok(ApiMessage::ok("Mentor deleted successfully")) - }) -} - -#[utoipa::path( - put, - path = "/v1/mentors/verify/{id}", - params( - ("id" = String, Path, description = "Mentor ID") - ), - request_body = MentorVerifyRequestDto, - responses( - (status = 200, description = "[ADMIN] Mentor verified successfully", body = MentorDetailResponseDto), - (status = 400, description = "[ADMIN] Bad request - validation error"), - (status = 404, description = "[ADMIN] Mentor not found"), - (status = 500, description = "[ADMIN] Internal server error") - ), - tag = "Mentors - Admin", - security(("Bearer" = [])) -)] -pub async fn put_verify_mentor( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - ValidatedJson(dto): ValidatedJson, -) -> Result { - let mentor_uuid = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?; - require_permissions!(headers, state, [PermissionsEnum::VerifyMentors], { - let result = service.verify(mentor_uuid, dto).await?; - Ok(ApiSuccess(result)) - }) -} - -#[utoipa::path( - get, - path = "/v1/mentors/me", - responses( - (status = 200, description = "[MENTOR] Current user's mentor profile", body = MentorDetailResponseDto), - (status = 401, description = "[MENTOR] Unauthorized - invalid token"), - (status = 403, description = "[MENTOR] Mentor profile not found for current user"), - (status = 500, description = "[MENTOR] Internal server error") - ), - tag = "Mentors", - security(("Bearer" = [])) -)] -pub async fn get_mentor_me( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, -) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorProfile], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let dto = service.get_by_email(&email).await - .map_err(|_| AppError::ForbiddenError("Mentor profile not found for current user".to_string()))?; - Ok(ApiSuccess(dto)) - }) -} - -#[utoipa::path( - put, - path = "/v1/mentors/me/update", - request_body = MentorUpdateRequestDto, - responses( - (status = 200, description = "[MENTOR] Mentor profile updated successfully", body = MentorDetailResponseDto), - (status = 400, description = "[MENTOR] Bad request - validation error"), - (status = 401, description = "[MENTOR] Unauthorized - invalid token"), - (status = 404, description = "[MENTOR] Mentor profile not found"), - (status = 500, description = "[MENTOR] Internal server error") - ), - tag = "Mentors", - security(("Bearer" = [])) -)] -pub async fn put_update_mentor_me( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(dto): ValidatedJson, -) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::UpdateOwnMentorProfile], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.update_me(&email, dto).await?; - Ok(ApiSuccess(resp)) - }) -} - -#[utoipa::path( - put, - path = "/v1/mentors/update", - request_body = MentorUpdateRequestDto, - responses( - (status = 400, description = "[PUBLIC] Bad request - Mentor ID is required for update"), - ), - tag = "Mentors - Admin" -)] -pub async fn put_update_mentor_no_id() -> impl IntoResponse { - ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, "Mentor ID is required for update") -} - -#[utoipa::path( - get, - path = "/v1/mentors/me/status", - responses( - (status = 200, description = "[MENTOR] Mentor application status", body = String), - (status = 401, description = "[MENTOR] Unauthorized - invalid token"), - (status = 403, description = "[MENTOR] No mentor application found for current user"), - (status = 500, description = "[MENTOR] Internal server error") - ), - tag = "Mentors", - security(("Bearer" = [])) -)] -pub async fn get_mentor_status( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, -) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorStatus], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let status = service.get_status(&email).await - .map_err(|_| AppError::ForbiddenError("No mentor application found for current user".to_string()))?; - Ok(ApiMessage::ok(&status)) - }) -} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mod.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mod.rs new file mode 100644 index 0000000..fb9d3e7 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mod.rs @@ -0,0 +1,10 @@ +pub mod mutation_handlers; +pub mod query_handlers; + +pub use mutation_handlers::{ + delete_mentor, post_register_mentor, put_update_mentor, put_update_mentor_me, + put_update_mentor_no_id, put_verify_mentor, +}; +pub use query_handlers::{ + get_mentor_by_id, get_mentor_list, get_mentor_me, get_mentor_status, +}; diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mutation_handlers.rs new file mode 100644 index 0000000..66086ef --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/mutation_handlers.rs @@ -0,0 +1,192 @@ +use super::super::dto::{ + MentorDetailResponseDto, MentorRegisterResponseDto, MentorUpdateRequestDto, + MentorUserRegisterRequestDto, MentorVerifyRequestDto, +}; +use crate::mentors::domain::MentorService; +use axum::{ + extract::{Extension, Path}, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiSuccess, extract_email}; +use std::sync::Arc; +use uuid::Uuid; + +#[utoipa::path( + post, + path = "/v1/mentors/create", + request_body = MentorUserRegisterRequestDto, + responses( + (status = 200, description = "[PUBLIC] Mentor registered successfully", body = MentorRegisterResponseDto), + (status = 400, description = "[PUBLIC] Bad request - validation error"), + (status = 409, description = "[PUBLIC] Conflict - user already has mentor profile"), + (status = 500, description = "[PUBLIC] Internal server error") + ), + tag = "Mentors" +)] +pub async fn post_register_mentor( + Extension(service): Extension>, + ValidatedJson(dto): ValidatedJson, +) -> Response { + match service.register(dto.into()).await { + Ok(resp) => axum::response::IntoResponse::into_response( + imphnen_utils::ApiSuccess(MentorRegisterResponseDto::from(resp)), + ), + Err(e) => ApiMessage::new(e.status_code(), e.to_string()).into_response(), + } +} + +#[utoipa::path( + put, + path = "/v1/mentors/update/{id}", + params( + ("id" = String, Path, description = "Mentor ID") + ), + request_body = MentorUpdateRequestDto, + responses( + (status = 200, description = "[ADMIN] Mentor updated successfully", body = MentorDetailResponseDto), + (status = 400, description = "[ADMIN] Bad request - validation error"), + (status = 404, description = "[ADMIN] Mentor not found"), + (status = 500, description = "[ADMIN] Internal server error") + ), + tag = "Mentors - Admin", + security(("Bearer" = [])) +)] +pub async fn put_update_mentor( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let mentor_uuid = Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError( + "Invalid mentor ID format. Must be a valid UUID.".to_string(), + ) + })?; + require_permissions!(headers, state, [PermissionsEnum::UpdateMentors], { + let result = + MentorDetailResponseDto::from(service.update(mentor_uuid, dto.into()).await?); + Ok(ApiSuccess(result)) + }) +} + +#[utoipa::path( + delete, + path = "/v1/mentors/delete/{id}", + params( + ("id" = String, Path, description = "Mentor ID") + ), + responses( + (status = 200, description = "[ADMIN] Mentor deleted successfully"), + (status = 404, description = "[ADMIN] Mentor not found"), + (status = 500, description = "[ADMIN] Internal server error") + ), + tag = "Mentors - Admin", + security(("Bearer" = [])) +)] +pub async fn delete_mentor( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let mentor_uuid = Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError( + "Invalid mentor ID format. Must be a valid UUID.".to_string(), + ) + })?; + require_permissions!(headers, state, [PermissionsEnum::DeleteMentors], { + service.delete(mentor_uuid).await?; + Ok(ApiMessage::ok("Mentor deleted successfully")) + }) +} + +#[utoipa::path( + put, + path = "/v1/mentors/verify/{id}", + params( + ("id" = String, Path, description = "Mentor ID") + ), + request_body = MentorVerifyRequestDto, + responses( + (status = 200, description = "[ADMIN] Mentor verified successfully", body = MentorDetailResponseDto), + (status = 400, description = "[ADMIN] Bad request - validation error"), + (status = 404, description = "[ADMIN] Mentor not found"), + (status = 500, description = "[ADMIN] Internal server error") + ), + tag = "Mentors - Admin", + security(("Bearer" = [])) +)] +pub async fn put_verify_mentor( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let mentor_uuid = Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError( + "Invalid mentor ID format. Must be a valid UUID.".to_string(), + ) + })?; + require_permissions!(headers, state, [PermissionsEnum::VerifyMentors], { + let result = + MentorDetailResponseDto::from(service.verify(mentor_uuid, dto.into()).await?); + Ok(ApiSuccess(result)) + }) +} + +#[utoipa::path( + put, + path = "/v1/mentors/me/update", + request_body = MentorUpdateRequestDto, + responses( + (status = 200, description = "[MENTOR] Mentor profile updated successfully", body = MentorDetailResponseDto), + (status = 400, description = "[MENTOR] Bad request - validation error"), + (status = 401, description = "[MENTOR] Unauthorized - invalid token"), + (status = 404, description = "[MENTOR] Mentor profile not found"), + (status = 500, description = "[MENTOR] Internal server error") + ), + tag = "Mentors", + security(("Bearer" = [])) +)] +pub async fn put_update_mentor_me( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(dto): ValidatedJson, +) -> Result { + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::UpdateOwnMentorProfile], + { + let email = extract_email(&headers).ok_or_else(|| { + AppError::AuthenticationError("Token tidak valid".to_string()) + })?; + let resp = + MentorDetailResponseDto::from(service.update_me(&email, dto.into()).await?); + Ok(ApiSuccess(resp)) + } + ) +} + +#[utoipa::path( + put, + path = "/v1/mentors/update", + request_body = MentorUpdateRequestDto, + responses( + (status = 400, description = "[PUBLIC] Bad request - Mentor ID is required for update"), + ), + tag = "Mentors - Admin" +)] +pub async fn put_update_mentor_no_id() -> impl IntoResponse { + ApiMessage::new( + axum::http::StatusCode::BAD_REQUEST, + "Mentor ID is required for update", + ) +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/query_handlers.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/query_handlers.rs new file mode 100644 index 0000000..d50cd33 --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/handlers/query_handlers.rs @@ -0,0 +1,153 @@ +use super::super::dto::{MentorDetailResponseDto, MentorListResponseDto}; +use crate::mentors::domain::MentorService; +use axum::{ + extract::{Extension, Path}, + http::HeaderMap, + response::IntoResponse, +}; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess, extract_email}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[utoipa::path( + get, + path = "/v1/mentors", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search query"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Sort order (ASC/DESC)"), + ), + responses( + (status = 200, description = "[ADMIN] Get list of mentors", body = Vec), + (status = 500, description = "[ADMIN] Internal server error") + ), + tag = "Mentors", + security(("Bearer" = [])) +)] +pub async fn get_mentor_list( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::ReadListMentors], { + let result = service.list(params).await?; + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(MentorListResponseDto::from) + .collect(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) + }) +} + +#[utoipa::path( + get, + path = "/v1/mentors/detail/{id}", + params( + ("id" = String, Path, description = "Mentor ID") + ), + responses( + (status = 200, description = "[ADMIN] Get mentor by ID", body = MentorDetailResponseDto), + (status = 404, description = "[ADMIN] Mentor not found"), + (status = 500, description = "[ADMIN] Internal server error") + ), + tag = "Mentors", + security(("Bearer" = [])) +)] +pub async fn get_mentor_by_id( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let mentor_uuid = Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError( + "Invalid mentor ID format. Must be a valid UUID.".to_string(), + ) + })?; + require_permissions!(headers, state, [PermissionsEnum::ReadDetailMentors], { + let dto = MentorDetailResponseDto::from(service.get_by_id(mentor_uuid).await?); + Ok(ApiSuccess(dto)) + }) +} + +#[utoipa::path( + get, + path = "/v1/mentors/me", + responses( + (status = 200, description = "[MENTOR] Current user's mentor profile", body = MentorDetailResponseDto), + (status = 401, description = "[MENTOR] Unauthorized - invalid token"), + (status = 403, description = "[MENTOR] Mentor profile not found for current user"), + (status = 500, description = "[MENTOR] Internal server error") + ), + tag = "Mentors", + security(("Bearer" = [])) +)] +pub async fn get_mentor_me( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, +) -> Result { + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::ReadOwnMentorProfile], + { + let email = extract_email(&headers).ok_or_else(|| { + AppError::AuthenticationError("Token tidak valid".to_string()) + })?; + let detail = service.get_by_email(&email).await.map_err(|_| { + AppError::ForbiddenError( + "Mentor profile not found for current user".to_string(), + ) + })?; + Ok(ApiSuccess(MentorDetailResponseDto::from(detail))) + } + ) +} + +#[utoipa::path( + get, + path = "/v1/mentors/me/status", + responses( + (status = 200, description = "[MENTOR] Mentor application status", body = String), + (status = 401, description = "[MENTOR] Unauthorized - invalid token"), + (status = 403, description = "[MENTOR] No mentor application found for current user"), + (status = 500, description = "[MENTOR] Internal server error") + ), + tag = "Mentors", + security(("Bearer" = [])) +)] +pub async fn get_mentor_status( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, +) -> Result { + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::ReadOwnMentorStatus], + { + let email = extract_email(&headers).ok_or_else(|| { + AppError::AuthenticationError("Token tidak valid".to_string()) + })?; + let status = service.get_status(&email).await.map_err(|_| { + AppError::ForbiddenError( + "No mentor application found for current user".to_string(), + ) + })?; + Ok(ApiMessage::ok(&status)) + } + ) +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/http/routes.rs b/imphnen-dimentorin/src/mentors/infrastructure/http/routes.rs index 99838f4..0a30a1e 100644 --- a/imphnen-dimentorin/src/mentors/infrastructure/http/routes.rs +++ b/imphnen-dimentorin/src/mentors/infrastructure/http/routes.rs @@ -1,47 +1,56 @@ -use std::sync::Arc; -use axum::{ - routing::{delete, get, post, put}, - Extension, Router, +use super::handlers::{ + delete_mentor, get_mentor_by_id, get_mentor_list, get_mentor_me, + get_mentor_status, post_register_mentor, put_update_mentor, put_update_mentor_me, + put_update_mentor_no_id, put_verify_mentor, }; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; -use imphnen_iam::users::infrastructure::persistence::PostgresUserRepository; -use imphnen_iam::roles::infrastructure::persistence::PostgresRoleRepository; use crate::mentors::application::MentorServiceImpl; use crate::mentors::domain::MentorService; use crate::mentors::infrastructure::persistence::PostgresMentorRepository; -use super::handlers::{ - delete_mentor, get_mentor_by_id, get_mentor_list, get_mentor_me, get_mentor_status, - post_register_mentor, put_update_mentor, put_update_mentor_me, put_update_mentor_no_id, - put_verify_mentor, +use axum::{ + Extension, Router, + routing::{delete, get, post, put}, }; +use imphnen_iam::roles::infrastructure::persistence::PostgresRoleRepository; +use imphnen_iam::users::infrastructure::persistence::PostgresUserRepository; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; -fn build_service(db: DatabaseConnection, state: Arc) -> Arc { - let user_repo = Arc::new(PostgresUserRepository::new(db.clone())); - let role_repo = Arc::new(PostgresRoleRepository::new(db.clone())); - let repo = Arc::new(PostgresMentorRepository::new(db)); - Arc::new(MentorServiceImpl::new(repo, state, user_repo, role_repo)) +fn build_service( + db: DatabaseConnection, + state: Arc, +) -> Arc { + let user_repo = Arc::new(PostgresUserRepository::new(db.clone())); + let role_repo = Arc::new(PostgresRoleRepository::new(db.clone())); + let repo = Arc::new(PostgresMentorRepository::new(db)); + Arc::new(MentorServiceImpl::new(repo, state, user_repo, role_repo)) } -pub fn mentors_public_routes(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db, state); - Router::new() - .route("/mentors/create", post(post_register_mentor)) - .layer(Extension(service)) +pub fn mentors_public_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db, state); + Router::new() + .route("/mentors/create", post(post_register_mentor)) + .layer(Extension(service)) } -pub fn mentors_protected_routes(db: DatabaseConnection, state: Arc) -> Router { - let svc = build_service(db, Arc::clone(&state)); - Router::new() - .route("/mentors", get(get_mentor_list)) - .route("/mentors/me", get(get_mentor_me)) - .route("/mentors/me/update", put(put_update_mentor_me)) - .route("/mentors/me/status", get(get_mentor_status)) - .route("/mentors/detail/{id}", get(get_mentor_by_id)) - .route("/mentors/update/{id}", put(put_update_mentor)) - .route("/mentors/update", put(put_update_mentor_no_id)) - .route("/mentors/delete/{id}", delete(delete_mentor)) - .route("/mentors/verify/{id}", put(put_verify_mentor)) - .layer(Extension(svc)) - .layer(Extension((*state).clone())) +pub fn mentors_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let svc = build_service(db, Arc::clone(&state)); + Router::new() + .route("/mentors", get(get_mentor_list)) + .route("/mentors/me", get(get_mentor_me)) + .route("/mentors/me/update", put(put_update_mentor_me)) + .route("/mentors/me/status", get(get_mentor_status)) + .route("/mentors/detail/{id}", get(get_mentor_by_id)) + .route("/mentors/update/{id}", put(put_update_mentor)) + .route("/mentors/update", put(put_update_mentor_no_id)) + .route("/mentors/delete/{id}", delete(delete_mentor)) + .route("/mentors/verify/{id}", put(put_verify_mentor)) + .layer(Extension(svc)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-dimentorin/src/mentors/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/mentors/infrastructure/persistence/mod.rs index 83ffb8c..c41485d 100644 --- a/imphnen-dimentorin/src/mentors/infrastructure/persistence/mod.rs +++ b/imphnen-dimentorin/src/mentors/infrastructure/persistence/mod.rs @@ -1,3 +1,5 @@ +pub mod postgres_mentor_queries; pub mod postgres_mentor_repository; +pub mod postgres_mentor_write; pub use postgres_mentor_repository::PostgresMentorRepository; diff --git a/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_queries.rs b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_queries.rs new file mode 100644 index 0000000..cd0e8fb --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_queries.rs @@ -0,0 +1,70 @@ +use super::postgres_mentor_repository::model_to_entity; +use crate::mentors::domain::mentor::MentorEntity; +use imphnen_entities::seaorm::auth::mentors::{ + Column as MentorColumn, Entity as MentorsEntity, +}; +use imphnen_utils::AppError; +use paginator_rs::{PaginationParams, SortDirection}; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; + +pub async fn find_all_paginated( + db: &Arc, + params: PaginationParams, +) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); + + let mut query = MentorsEntity::find().filter(MentorColumn::IsDeleted.eq(false)); + + query = match params.sort_by.as_deref() { + Some("updated_at") => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by(MentorColumn::UpdatedAt, Order::Asc) + } + _ => query.order_by(MentorColumn::UpdatedAt, Order::Desc), + }, + _ => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by(MentorColumn::CreatedAt, Order::Asc) + } + _ => query.order_by(MentorColumn::CreatedAt, Order::Desc), + }, + }; + + let paginator = query.paginate(db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let mentors = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let data = mentors.into_iter().map(model_to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) +} + +pub async fn find_by_user_id( + db: &Arc, + user_id: Uuid, + include_deleted: bool, +) -> Result { + let mut query = MentorsEntity::find().filter(MentorColumn::UserId.eq(user_id)); + + if !include_deleted { + query = query.filter(MentorColumn::IsDeleted.eq(false)); + } + + let model = query + .one(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; + + Ok(model_to_entity(model)) +} diff --git a/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_repository.rs b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_repository.rs index 50996f4..84246ac 100644 --- a/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_repository.rs +++ b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_repository.rs @@ -1,283 +1,184 @@ -use std::sync::Arc; -use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait}; -use paginator_rs::{PaginationParams, SortDirection}; -use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; -use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::auth::mentors::{ - Entity as MentorsEntity, - Column as MentorColumn, - ActiveModel as MentorActiveModel, - Model as MentorModel, -}; +use super::postgres_mentor_queries; +use super::postgres_mentor_write::apply_entity_to_model; use crate::mentors::domain::{mentor::MentorEntity, repository::MentorRepository}; +use async_trait::async_trait; +use imphnen_entities::seaorm::auth::mentors::{ + ActiveModel as MentorActiveModel, Column as MentorColumn, Entity as MentorsEntity, + Model as MentorModel, +}; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use sea_orm::ActiveValue; +use sea_orm::prelude::*; +use std::sync::Arc; +use uuid::Uuid; -fn model_to_entity(model: MentorModel) -> MentorEntity { - MentorEntity { - id: model.id, - user_id: model.user_id, - industries: serde_json::from_value( - model.industries.unwrap_or(serde_json::Value::Array(vec![])), - ) - .unwrap_or_default(), - expertise: serde_json::from_value( - model.expertise.unwrap_or(serde_json::Value::Array(vec![])), - ) - .unwrap_or_default(), - languages: serde_json::from_value( - model.languages.unwrap_or(serde_json::Value::Array(vec![])), - ) - .unwrap_or_default(), - current_company: model.current_company.unwrap_or_default(), - current_role: model.current_role.unwrap_or_default(), - years_of_experience: model.years_of_experience.unwrap_or(0), - topics_of_interest: serde_json::from_value( - model.topics_of_interest.unwrap_or(serde_json::Value::Array(vec![])), - ) - .unwrap_or_default(), - preferred_mentee_level: serde_json::from_str( - &model.preferred_mentee_level.unwrap_or_default(), - ) - .unwrap_or_default(), - preferred_mentoring_formats: serde_json::from_value( - model - .preferred_mentoring_formats - .unwrap_or(serde_json::Value::Array(vec![])), - ) - .unwrap_or_default(), - availability_commitment: model.availability_commitment.unwrap_or_default(), - mentoring_rate: model.mentoring_rate.unwrap_or(0.0), - status: model.status.unwrap_or_default(), - is_deleted: model.is_deleted, - created_at: model.created_at, - updated_at: model.updated_at, - } +pub fn model_to_entity(model: MentorModel) -> MentorEntity { + MentorEntity { + id: model.id, + user_id: model.user_id, + industries: serde_json::from_value( + model.industries.unwrap_or(serde_json::Value::Array(vec![])), + ) + .unwrap_or_default(), + expertise: serde_json::from_value( + model.expertise.unwrap_or(serde_json::Value::Array(vec![])), + ) + .unwrap_or_default(), + languages: serde_json::from_value( + model.languages.unwrap_or(serde_json::Value::Array(vec![])), + ) + .unwrap_or_default(), + current_company: model.current_company.unwrap_or_default(), + current_role: model.current_role.unwrap_or_default(), + years_of_experience: model.years_of_experience.unwrap_or(0), + topics_of_interest: serde_json::from_value( + model + .topics_of_interest + .unwrap_or(serde_json::Value::Array(vec![])), + ) + .unwrap_or_default(), + preferred_mentee_level: serde_json::from_str( + &model.preferred_mentee_level.unwrap_or_default(), + ) + .unwrap_or_default(), + preferred_mentoring_formats: serde_json::from_value( + model + .preferred_mentoring_formats + .unwrap_or(serde_json::Value::Array(vec![])), + ) + .unwrap_or_default(), + availability_commitment: model.availability_commitment.unwrap_or_default(), + mentoring_rate: model.mentoring_rate.unwrap_or(0.0), + status: model.status.unwrap_or_default(), + is_deleted: model.is_deleted, + created_at: model.created_at, + updated_at: model.updated_at, + } } pub struct PostgresMentorRepository { - db: Arc, + pub db: Arc, } impl PostgresMentorRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl MentorRepository for PostgresMentorRepository { - async fn find_all( - &self, - params: PaginationParams, - ) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + postgres_mentor_queries::find_all_paginated(&self.db, params).await + } - let mut query = MentorsEntity::find() - .filter(MentorColumn::IsDeleted.eq(false)); + async fn find_by_id( + &self, + id: Uuid, + include_deleted: bool, + ) -> Result { + let mut query = MentorsEntity::find_by_id(id); + if !include_deleted { + query = query.filter(MentorColumn::IsDeleted.eq(false)); + } + let model = query + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; + Ok(model_to_entity(model)) + } - query = match params.sort_by.as_deref() { - Some("updated_at") => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by(MentorColumn::UpdatedAt, Order::Asc), - _ => query.order_by(MentorColumn::UpdatedAt, Order::Desc), - }, - _ => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by(MentorColumn::CreatedAt, Order::Asc), - _ => query.order_by(MentorColumn::CreatedAt, Order::Desc), - }, - }; + async fn find_by_user_id( + &self, + user_id: Uuid, + include_deleted: bool, + ) -> Result { + postgres_mentor_queries::find_by_user_id(&self.db, user_id, include_deleted) + .await + } - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let total = paginator - .num_items() - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let mentors = paginator - .fetch_page((page - 1) as u64) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn create(&self, entity: MentorEntity) -> Result { + let active_model = MentorActiveModel { + user_id: ActiveValue::Set(entity.user_id), + industries: ActiveValue::Set(Some( + serde_json::to_value(&entity.industries) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + expertise: ActiveValue::Set(Some( + serde_json::to_value(&entity.expertise) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + languages: ActiveValue::Set(Some( + serde_json::to_value(&entity.languages) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + current_company: ActiveValue::Set(Some(entity.current_company)), + current_role: ActiveValue::Set(Some(entity.current_role)), + years_of_experience: ActiveValue::Set(Some(entity.years_of_experience)), + topics_of_interest: ActiveValue::Set(Some( + serde_json::to_value(&entity.topics_of_interest) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + preferred_mentee_level: ActiveValue::Set(Some( + serde_json::to_string(&entity.preferred_mentee_level) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + preferred_mentoring_formats: ActiveValue::Set(Some( + serde_json::to_value(&entity.preferred_mentoring_formats) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )), + availability_commitment: ActiveValue::Set(Some( + entity.availability_commitment, + )), + mentoring_rate: ActiveValue::Set(Some(entity.mentoring_rate)), + status: ActiveValue::Set(Some(entity.status)), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + ..Default::default() + }; + let result = MentorsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(result.last_insert_id) + } - let data = mentors.into_iter().map(model_to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } + async fn update(&self, entity: MentorEntity) -> Result<(), AppError> { + let mut active_model: MentorActiveModel = MentorsEntity::find_by_id(entity.id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))? + .into(); + apply_entity_to_model(&entity, &mut active_model)?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn find_by_id( - &self, - id: Uuid, - include_deleted: bool, - ) -> Result { - let mut query = MentorsEntity::find_by_id(id); - - if !include_deleted { - query = query.filter(MentorColumn::IsDeleted.eq(false)); - } - - let model = query - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; - - Ok(model_to_entity(model)) - } - - async fn find_by_user_id( - &self, - user_id: Uuid, - include_deleted: bool, - ) -> Result { - let mut query = MentorsEntity::find() - .filter(MentorColumn::UserId.eq(user_id)); - - if !include_deleted { - query = query.filter(MentorColumn::IsDeleted.eq(false)); - } - - let model = query - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; - - Ok(model_to_entity(model)) - } - - async fn create(&self, entity: MentorEntity) -> Result { - let active_model = MentorActiveModel { - user_id: ActiveValue::Set(entity.user_id), - industries: ActiveValue::Set(Some( - serde_json::to_value(&entity.industries) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - expertise: ActiveValue::Set(Some( - serde_json::to_value(&entity.expertise) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - languages: ActiveValue::Set(Some( - serde_json::to_value(&entity.languages) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - current_company: ActiveValue::Set(Some(entity.current_company)), - current_role: ActiveValue::Set(Some(entity.current_role)), - years_of_experience: ActiveValue::Set(Some(entity.years_of_experience)), - topics_of_interest: ActiveValue::Set(Some( - serde_json::to_value(&entity.topics_of_interest) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - preferred_mentee_level: ActiveValue::Set(Some( - serde_json::to_string(&entity.preferred_mentee_level) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - preferred_mentoring_formats: ActiveValue::Set(Some( - serde_json::to_value(&entity.preferred_mentoring_formats) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )), - availability_commitment: ActiveValue::Set(Some(entity.availability_commitment)), - mentoring_rate: ActiveValue::Set(Some(entity.mentoring_rate)), - status: ActiveValue::Set(Some(entity.status)), - is_deleted: ActiveValue::Set(false), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - ..Default::default() - }; - - let result = MentorsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(result.last_insert_id) - } - - async fn update(&self, entity: MentorEntity) -> Result<(), AppError> { - let mut active_model: MentorActiveModel = MentorsEntity::find_by_id(entity.id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))? - .into(); - - if !entity.industries.is_empty() { - active_model.industries = ActiveValue::Set(Some( - serde_json::to_value(&entity.industries) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.expertise.is_empty() { - active_model.expertise = ActiveValue::Set(Some( - serde_json::to_value(&entity.expertise) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.languages.is_empty() { - active_model.languages = ActiveValue::Set(Some( - serde_json::to_value(&entity.languages) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.current_company.is_empty() { - active_model.current_company = ActiveValue::Set(Some(entity.current_company)); - } - if !entity.current_role.is_empty() { - active_model.current_role = ActiveValue::Set(Some(entity.current_role)); - } - active_model.years_of_experience = ActiveValue::Set(Some(entity.years_of_experience)); - if !entity.topics_of_interest.is_empty() { - active_model.topics_of_interest = ActiveValue::Set(Some( - serde_json::to_value(&entity.topics_of_interest) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.preferred_mentee_level.is_empty() { - active_model.preferred_mentee_level = ActiveValue::Set(Some( - serde_json::to_string(&entity.preferred_mentee_level) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.preferred_mentoring_formats.is_empty() { - active_model.preferred_mentoring_formats = ActiveValue::Set(Some( - serde_json::to_value(&entity.preferred_mentoring_formats) - .map_err(|e| AppError::InternalServerError(e.to_string()))?, - )); - } - if !entity.availability_commitment.is_empty() { - active_model.availability_commitment = - ActiveValue::Set(Some(entity.availability_commitment)); - } - active_model.mentoring_rate = ActiveValue::Set(Some(entity.mentoring_rate)); - if !entity.status.is_empty() { - active_model.status = ActiveValue::Set(Some(entity.status)); - } - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - - active_model - .update(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(()) - } - - async fn soft_delete(&self, id: Uuid) -> Result<(), AppError> { - let model = MentorsEntity::find_by_id(id) - .filter(MentorColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; - - let mut active_model: MentorActiveModel = model.into(); - active_model.is_deleted = ActiveValue::Set(true); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - - active_model - .update(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(()) - } + async fn soft_delete(&self, id: Uuid) -> Result<(), AppError> { + let model = MentorsEntity::find_by_id(id) + .filter(MentorColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; + let mut active_model: MentorActiveModel = model.into(); + active_model.is_deleted = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } } diff --git a/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_write.rs b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_write.rs new file mode 100644 index 0000000..ddf8fec --- /dev/null +++ b/imphnen-dimentorin/src/mentors/infrastructure/persistence/postgres_mentor_write.rs @@ -0,0 +1,65 @@ +use crate::mentors::domain::mentor::MentorEntity; +use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorActiveModel; +use imphnen_utils::AppError; +use sea_orm::ActiveValue; + +pub fn apply_entity_to_model( + entity: &MentorEntity, + active_model: &mut MentorActiveModel, +) -> Result<(), AppError> { + if !entity.industries.is_empty() { + active_model.industries = ActiveValue::Set(Some( + serde_json::to_value(&entity.industries) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.expertise.is_empty() { + active_model.expertise = ActiveValue::Set(Some( + serde_json::to_value(&entity.expertise) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.languages.is_empty() { + active_model.languages = ActiveValue::Set(Some( + serde_json::to_value(&entity.languages) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.current_company.is_empty() { + active_model.current_company = + ActiveValue::Set(Some(entity.current_company.clone())); + } + if !entity.current_role.is_empty() { + active_model.current_role = ActiveValue::Set(Some(entity.current_role.clone())); + } + active_model.years_of_experience = + ActiveValue::Set(Some(entity.years_of_experience)); + if !entity.topics_of_interest.is_empty() { + active_model.topics_of_interest = ActiveValue::Set(Some( + serde_json::to_value(&entity.topics_of_interest) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.preferred_mentee_level.is_empty() { + active_model.preferred_mentee_level = ActiveValue::Set(Some( + serde_json::to_string(&entity.preferred_mentee_level) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.preferred_mentoring_formats.is_empty() { + active_model.preferred_mentoring_formats = ActiveValue::Set(Some( + serde_json::to_value(&entity.preferred_mentoring_formats) + .map_err(|e| AppError::InternalServerError(e.to_string()))?, + )); + } + if !entity.availability_commitment.is_empty() { + active_model.availability_commitment = + ActiveValue::Set(Some(entity.availability_commitment.clone())); + } + active_model.mentoring_rate = ActiveValue::Set(Some(entity.mentoring_rate)); + if !entity.status.is_empty() { + active_model.status = ActiveValue::Set(Some(entity.status.clone())); + } + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + Ok(()) +} diff --git a/imphnen-dimentorin/src/mentors/mod.rs b/imphnen-dimentorin/src/mentors/mod.rs index 7cf1639..f3120ce 100644 --- a/imphnen-dimentorin/src/mentors/mod.rs +++ b/imphnen-dimentorin/src/mentors/mod.rs @@ -2,4 +2,6 @@ pub mod application; pub mod domain; pub mod infrastructure; -pub use infrastructure::http::routes::{mentors_protected_routes, mentors_public_routes}; +pub use infrastructure::http::routes::{ + mentors_protected_routes, mentors_public_routes, +}; diff --git a/imphnen-dimentorin/src/sessions/application/mod.rs b/imphnen-dimentorin/src/sessions/application/mod.rs index b9897d0..03f8433 100644 --- a/imphnen-dimentorin/src/sessions/application/mod.rs +++ b/imphnen-dimentorin/src/sessions/application/mod.rs @@ -1,3 +1,5 @@ +pub mod session_booking_service; +pub mod session_query_service; pub mod session_service; pub use session_service::SessionServiceImpl; diff --git a/imphnen-dimentorin/src/sessions/application/session_booking_service.rs b/imphnen-dimentorin/src/sessions/application/session_booking_service.rs new file mode 100644 index 0000000..126720d --- /dev/null +++ b/imphnen-dimentorin/src/sessions/application/session_booking_service.rs @@ -0,0 +1,146 @@ +use crate::sessions::domain::{ + BookSessionCommand, BookedSession, SessionEntity, SessionFeedbackCommand, + SessionFeedbackResult, SessionRepository, UpdateSessionStatusCommand, + UpdatedSessionStatus, +}; +use chrono::{DateTime, Utc}; +use imphnen_utils::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct SessionBookingService { + pub repo: Arc, +} + +impl SessionBookingService { + pub async fn book_session( + &self, + mentor_id: String, + user_id: String, + cmd: BookSessionCommand, + ) -> Result { + let scheduled_at = DateTime::parse_from_rfc3339(&cmd.scheduled_at) + .map_err(|e| { + AppError::BadRequestError(format!("Invalid scheduled_at format: {}", e)) + })? + .with_timezone(&Utc); + + let mentor_uuid = Uuid::parse_str(&mentor_id) + .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; + + let mentee_uuid = Uuid::parse_str(&user_id) + .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?; + + let entity = SessionEntity { + id: Uuid::new_v4(), + mentor_id: mentor_uuid, + mentee_id: mentee_uuid, + topic: cmd.topic, + description: cmd.description, + scheduled_at, + duration_minutes: cmd.duration_minutes.unwrap_or(60), + meeting_link: None, + session_type: cmd.session_type.unwrap_or_else(|| "video_call".to_string()), + status: "pending".to_string(), + feedback: None, + rating: None, + feedback_submitted_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let created = self.repo.create(entity).await?; + + Ok(BookedSession { + id: created.id.to_string(), + mentor_id: created.mentor_id.to_string(), + mentee_id: created.mentee_id.to_string(), + topic: created.topic, + description: created.description, + scheduled_at: created.scheduled_at.to_rfc3339(), + duration_minutes: created.duration_minutes, + session_type: created.session_type, + status: created.status, + created_at: created.created_at.to_rfc3339(), + }) + } + + pub async fn update_session_status( + &self, + session_id: String, + _user_id: String, + cmd: UpdateSessionStatusCommand, + ) -> Result { + let session_uuid = Uuid::parse_str(&session_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid session ID: {}", e)) + })?; + + let mut session = self + .repo + .find_by_id(session_uuid) + .await? + .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; + + session.status = cmd.status; + if let Some(link) = cmd.meeting_link { + session.meeting_link = Some(link); + } + session.updated_at = Utc::now(); + + let updated = self.repo.update(session_uuid, session).await?; + + Ok(UpdatedSessionStatus { + id: updated.id.to_string(), + status: updated.status, + meeting_link: updated.meeting_link, + updated_at: updated.updated_at.to_rfc3339(), + }) + } + + pub async fn submit_feedback( + &self, + session_id: String, + user_id: String, + cmd: SessionFeedbackCommand, + ) -> Result { + let session_uuid = Uuid::parse_str(&session_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid session ID: {}", e)) + })?; + + let mut session = self + .repo + .find_by_id(session_uuid) + .await? + .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; + + if session.mentee_id.to_string() != user_id { + return Err(AppError::ForbiddenError( + "Only the mentee can submit feedback".to_string(), + )); + } + + if session.status != "completed" { + return Err(AppError::BadRequestError( + "Feedback can only be submitted for completed sessions".to_string(), + )); + } + + session.feedback = Some(cmd.feedback.clone()); + session.rating = Some(cmd.rating); + session.feedback_submitted_at = Some(Utc::now()); + session.updated_at = Utc::now(); + + let updated = self.repo.update(session_uuid, session).await?; + let submitted_at = updated + .feedback_submitted_at + .unwrap_or_else(Utc::now) + .to_rfc3339(); + + Ok(SessionFeedbackResult { + id: updated.id.to_string(), + feedback: cmd.feedback, + rating: cmd.rating, + submitted_at, + }) + } +} diff --git a/imphnen-dimentorin/src/sessions/application/session_query_service.rs b/imphnen-dimentorin/src/sessions/application/session_query_service.rs new file mode 100644 index 0000000..58812a1 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/application/session_query_service.rs @@ -0,0 +1,174 @@ +use crate::sessions::domain::{ + AvailabilitySlot, MentorAvailability, SessionDetail, SessionList, SessionListItem, + SessionRepository, +}; +use chrono::{Duration, Utc}; +use imphnen_utils::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct SessionQueryService { + pub repo: Arc, +} + +impl SessionQueryService { + pub async fn get_mentor_sessions( + &self, + mentor_id: String, + status_filter: Option, + ) -> Result { + let mentor_uuid = Uuid::parse_str(&mentor_id) + .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; + + let count = self + .repo + .count_by_mentor(mentor_uuid, status_filter.clone()) + .await?; + + let sessions = self + .repo + .find_by_mentor_id(mentor_uuid, status_filter) + .await?; + + let items: Vec = sessions + .into_iter() + .map(|s| SessionListItem { + id: s.id.to_string(), + mentor_id: s.mentor_id.to_string(), + mentee_id: s.mentee_id.to_string(), + mentee_fullname: None, + mentee_email: None, + topic: s.topic, + scheduled_at: s.scheduled_at.to_rfc3339(), + duration_minutes: s.duration_minutes, + session_type: s.session_type, + status: s.status, + rating: s.rating, + created_at: s.created_at.to_rfc3339(), + }) + .collect(); + + Ok(SessionList { + sessions: items, + total: count, + }) + } + + pub async fn get_user_sessions( + &self, + user_id: String, + status_filter: Option, + ) -> Result { + let user_uuid = Uuid::parse_str(&user_id) + .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?; + + let count = self + .repo + .count_by_mentee(user_uuid, status_filter.clone()) + .await?; + + let sessions = self + .repo + .find_by_mentee_id(user_uuid, status_filter) + .await?; + + let items: Vec = sessions + .into_iter() + .map(|s| SessionListItem { + id: s.id.to_string(), + mentor_id: s.mentor_id.to_string(), + mentee_id: s.mentee_id.to_string(), + mentee_fullname: None, + mentee_email: None, + topic: s.topic, + scheduled_at: s.scheduled_at.to_rfc3339(), + duration_minutes: s.duration_minutes, + session_type: s.session_type, + status: s.status, + rating: s.rating, + created_at: s.created_at.to_rfc3339(), + }) + .collect(); + + Ok(SessionList { + sessions: items, + total: count, + }) + } + + pub async fn get_mentor_availability( + &self, + mentor_id: String, + ) -> Result { + let mentor_uuid = Uuid::parse_str(&mentor_id) + .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; + + let booked_dates = self.repo.find_booked_dates(mentor_uuid).await?; + + let mut slots = Vec::new(); + let today = Utc::now().date_naive(); + + for i in 0..7 { + let date = today + Duration::days(i); + let date_str = date.format("%Y-%m-%d").to_string(); + + for hour in 9..17 { + let time_str = format!("{:02}:00", hour); + let datetime_prefix = format!("{}T{}", date_str, time_str); + + let is_booked = booked_dates + .iter() + .any(|d| d.starts_with(&datetime_prefix[..13])); + + slots.push(AvailabilitySlot { + date: date_str.clone(), + time: time_str, + available: !is_booked, + }); + } + } + + Ok(MentorAvailability { + mentor_id, + availability_commitment: "Available weekdays 9 AM - 5 PM".to_string(), + preferred_formats: vec!["video_call".to_string(), "phone_call".to_string()], + slots, + booked_dates, + }) + } + + pub async fn get_session_detail( + &self, + session_id: String, + ) -> Result { + let session_uuid = Uuid::parse_str(&session_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid session ID: {}", e)) + })?; + + let session = self + .repo + .find_by_id(session_uuid) + .await? + .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; + + Ok(SessionDetail { + id: session.id.to_string(), + mentor_id: session.mentor_id.to_string(), + mentor_fullname: None, + mentee_id: session.mentee_id.to_string(), + mentee_fullname: None, + topic: session.topic, + description: session.description, + scheduled_at: session.scheduled_at.to_rfc3339(), + duration_minutes: session.duration_minutes, + meeting_link: session.meeting_link, + session_type: session.session_type, + status: session.status, + feedback: session.feedback, + rating: session.rating, + feedback_submitted_at: session.feedback_submitted_at.map(|dt| dt.to_rfc3339()), + created_at: session.created_at.to_rfc3339(), + updated_at: session.updated_at.to_rfc3339(), + }) + } +} diff --git a/imphnen-dimentorin/src/sessions/application/session_service.rs b/imphnen-dimentorin/src/sessions/application/session_service.rs index f9b14a6..8fa95a9 100644 --- a/imphnen-dimentorin/src/sessions/application/session_service.rs +++ b/imphnen-dimentorin/src/sessions/application/session_service.rs @@ -1,310 +1,92 @@ -use std::sync::Arc; -use async_trait::async_trait; -use chrono::{DateTime, Duration, Utc}; -use uuid::Uuid; -use imphnen_utils::AppError; -use crate::sessions::domain::{SessionEntity, SessionRepository, SessionService}; -use crate::sessions::infrastructure::http::dto::{ - AvailabilitySlotDto, BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, - SessionDetailDto, SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto, - SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, +use super::session_booking_service::SessionBookingService; +use super::session_query_service::SessionQueryService; +use crate::sessions::domain::{ + BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, + SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionRepository, + SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus, }; +use async_trait::async_trait; +use imphnen_utils::AppError; +use std::sync::Arc; pub struct SessionServiceImpl { - repo: Arc, + booking: SessionBookingService, + query: SessionQueryService, } impl SessionServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { + booking: SessionBookingService { + repo: Arc::clone(&repo), + }, + query: SessionQueryService { repo }, + } + } } #[async_trait] impl SessionService for SessionServiceImpl { - async fn book_session( - &self, - mentor_id: String, - user_id: String, - dto: BookSessionRequestDto, - ) -> Result { - let scheduled_at = DateTime::parse_from_rfc3339(&dto.scheduled_at) - .map_err(|e| AppError::BadRequestError(format!("Invalid scheduled_at format: {}", e)))? - .with_timezone(&Utc); + async fn book_session( + &self, + mentor_id: String, + user_id: String, + cmd: BookSessionCommand, + ) -> Result { + self.booking.book_session(mentor_id, user_id, cmd).await + } - let mentor_uuid = Uuid::parse_str(&mentor_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; + async fn get_mentor_sessions( + &self, + mentor_id: String, + status_filter: Option, + ) -> Result { + self + .query + .get_mentor_sessions(mentor_id, status_filter) + .await + } - let mentee_uuid = Uuid::parse_str(&user_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?; + async fn get_user_sessions( + &self, + user_id: String, + status_filter: Option, + ) -> Result { + self.query.get_user_sessions(user_id, status_filter).await + } - let entity = SessionEntity { - id: Uuid::new_v4(), - mentor_id: mentor_uuid, - mentee_id: mentee_uuid, - topic: dto.topic, - description: dto.description, - scheduled_at, - duration_minutes: dto.duration_minutes.unwrap_or(60), - meeting_link: None, - session_type: dto.session_type.unwrap_or_else(|| "video_call".to_string()), - status: "pending".to_string(), - feedback: None, - rating: None, - feedback_submitted_at: None, - created_at: Utc::now(), - updated_at: Utc::now(), - }; + async fn get_mentor_availability( + &self, + mentor_id: String, + ) -> Result { + self.query.get_mentor_availability(mentor_id).await + } - let created = self.repo.create(entity).await?; + async fn update_session_status( + &self, + session_id: String, + user_id: String, + cmd: UpdateSessionStatusCommand, + ) -> Result { + self + .booking + .update_session_status(session_id, user_id, cmd) + .await + } - Ok(BookSessionResponseDto { - id: created.id.to_string(), - mentor_id: created.mentor_id.to_string(), - mentee_id: created.mentee_id.to_string(), - topic: created.topic, - description: created.description, - scheduled_at: created.scheduled_at.to_rfc3339(), - duration_minutes: created.duration_minutes, - session_type: created.session_type, - status: created.status, - created_at: created.created_at.to_rfc3339(), - }) - } + async fn submit_feedback( + &self, + session_id: String, + user_id: String, + cmd: SessionFeedbackCommand, + ) -> Result { + self.booking.submit_feedback(session_id, user_id, cmd).await + } - async fn get_mentor_sessions( - &self, - mentor_id: String, - status_filter: Option, - ) -> Result { - let mentor_uuid = Uuid::parse_str(&mentor_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; - - let count = self - .repo - .count_by_mentor(mentor_uuid, status_filter.clone()) - .await?; - - let sessions = self - .repo - .find_by_mentor_id(mentor_uuid, status_filter) - .await?; - - let items: Vec = sessions - .into_iter() - .map(|s| SessionListItemDto { - id: s.id.to_string(), - mentor_id: s.mentor_id.to_string(), - mentee_id: s.mentee_id.to_string(), - mentee_fullname: None, - mentee_email: None, - topic: s.topic, - scheduled_at: s.scheduled_at.to_rfc3339(), - duration_minutes: s.duration_minutes, - session_type: s.session_type, - status: s.status, - rating: s.rating, - created_at: s.created_at.to_rfc3339(), - }) - .collect(); - - Ok(SessionListResponseDto { - sessions: items, - total: count, - }) - } - - async fn get_user_sessions( - &self, - user_id: String, - status_filter: Option, - ) -> Result { - let user_uuid = Uuid::parse_str(&user_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?; - - let count = self - .repo - .count_by_mentee(user_uuid, status_filter.clone()) - .await?; - - let sessions = self - .repo - .find_by_mentee_id(user_uuid, status_filter) - .await?; - - let items: Vec = sessions - .into_iter() - .map(|s| SessionListItemDto { - id: s.id.to_string(), - mentor_id: s.mentor_id.to_string(), - mentee_id: s.mentee_id.to_string(), - mentee_fullname: None, - mentee_email: None, - topic: s.topic, - scheduled_at: s.scheduled_at.to_rfc3339(), - duration_minutes: s.duration_minutes, - session_type: s.session_type, - status: s.status, - rating: s.rating, - created_at: s.created_at.to_rfc3339(), - }) - .collect(); - - Ok(SessionListResponseDto { - sessions: items, - total: count, - }) - } - - async fn get_mentor_availability( - &self, - mentor_id: String, - ) -> Result { - let mentor_uuid = Uuid::parse_str(&mentor_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; - - let booked_dates = self.repo.find_booked_dates(mentor_uuid).await?; - - let mut slots = Vec::new(); - let today = Utc::now().date_naive(); - - for i in 0..7 { - let date = today + Duration::days(i); - let date_str = date.format("%Y-%m-%d").to_string(); - - for hour in 9..17 { - let time_str = format!("{:02}:00", hour); - let datetime_prefix = format!("{}T{}", date_str, time_str); - - let is_booked = booked_dates - .iter() - .any(|d| d.starts_with(&datetime_prefix[..13])); - - slots.push(AvailabilitySlotDto { - date: date_str.clone(), - time: time_str, - available: !is_booked, - }); - } - } - - Ok(MentorAvailabilityDto { - mentor_id, - availability_commitment: "Available weekdays 9 AM - 5 PM".to_string(), - preferred_formats: vec!["video_call".to_string(), "phone_call".to_string()], - slots, - booked_dates, - }) - } - - async fn update_session_status( - &self, - session_id: String, - _user_id: String, - dto: UpdateSessionStatusRequestDto, - ) -> Result { - let session_uuid = Uuid::parse_str(&session_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?; - - let mut session = self - .repo - .find_by_id(session_uuid) - .await? - .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - - session.status = dto.status; - if let Some(link) = dto.meeting_link { - session.meeting_link = Some(link); - } - session.updated_at = Utc::now(); - - let updated = self.repo.update(session_uuid, session).await?; - - Ok(UpdateSessionStatusResponseDto { - id: updated.id.to_string(), - status: updated.status, - meeting_link: updated.meeting_link, - updated_at: updated.updated_at.to_rfc3339(), - }) - } - - async fn submit_feedback( - &self, - session_id: String, - user_id: String, - dto: SessionFeedbackRequestDto, - ) -> Result { - let session_uuid = Uuid::parse_str(&session_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?; - - let mut session = self - .repo - .find_by_id(session_uuid) - .await? - .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - - if session.mentee_id.to_string() != user_id { - return Err(AppError::ForbiddenError( - "Only the mentee can submit feedback".to_string(), - )); - } - - if session.status != "completed" { - return Err(AppError::BadRequestError( - "Feedback can only be submitted for completed sessions".to_string(), - )); - } - - session.feedback = Some(dto.feedback.clone()); - session.rating = Some(dto.rating); - session.feedback_submitted_at = Some(Utc::now()); - session.updated_at = Utc::now(); - - let updated = self.repo.update(session_uuid, session).await?; - let submitted_at = updated - .feedback_submitted_at - .unwrap_or_else(Utc::now) - .to_rfc3339(); - - Ok(SessionFeedbackResponseDto { - id: updated.id.to_string(), - feedback: dto.feedback, - rating: dto.rating, - submitted_at, - }) - } - - async fn get_session_detail( - &self, - session_id: String, - ) -> Result { - let session_uuid = Uuid::parse_str(&session_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?; - - let session = self - .repo - .find_by_id(session_uuid) - .await? - .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - - Ok(SessionDetailDto { - id: session.id.to_string(), - mentor_id: session.mentor_id.to_string(), - mentor_fullname: None, - mentee_id: session.mentee_id.to_string(), - mentee_fullname: None, - topic: session.topic, - description: session.description, - scheduled_at: session.scheduled_at.to_rfc3339(), - duration_minutes: session.duration_minutes, - meeting_link: session.meeting_link, - session_type: session.session_type, - status: session.status, - feedback: session.feedback, - rating: session.rating, - feedback_submitted_at: session.feedback_submitted_at.map(|dt| dt.to_rfc3339()), - created_at: session.created_at.to_rfc3339(), - updated_at: session.updated_at.to_rfc3339(), - }) - } + async fn get_session_detail( + &self, + session_id: String, + ) -> Result { + self.query.get_session_detail(session_id).await + } } diff --git a/imphnen-dimentorin/src/sessions/domain/mod.rs b/imphnen-dimentorin/src/sessions/domain/mod.rs index 27e0c2b..9e0d5aa 100644 --- a/imphnen-dimentorin/src/sessions/domain/mod.rs +++ b/imphnen-dimentorin/src/sessions/domain/mod.rs @@ -1,7 +1,13 @@ pub mod repository; pub mod service; pub mod session; +pub mod session_types; pub use repository::SessionRepository; pub use service::SessionService; pub use session::SessionEntity; +pub use session_types::{ + AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability, + SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList, + SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus, +}; diff --git a/imphnen-dimentorin/src/sessions/domain/repository.rs b/imphnen-dimentorin/src/sessions/domain/repository.rs index 4b2d512..4f20a54 100644 --- a/imphnen-dimentorin/src/sessions/domain/repository.rs +++ b/imphnen-dimentorin/src/sessions/domain/repository.rs @@ -1,48 +1,55 @@ +use super::session::SessionEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::session::SessionEntity; #[async_trait] pub trait SessionRepository: Send + Sync { - async fn create(&self, entity: SessionEntity) -> Result; + async fn create(&self, entity: SessionEntity) -> Result; - async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result, AppError>; - async fn find_by_mentor_id( - &self, - mentor_id: Uuid, - status_filter: Option, - ) -> Result, AppError>; + async fn find_by_mentor_id( + &self, + mentor_id: Uuid, + status_filter: Option, + ) -> Result, AppError>; - async fn find_by_mentee_id( - &self, - mentee_id: Uuid, - status_filter: Option, - ) -> Result, AppError>; + async fn find_by_mentee_id( + &self, + mentee_id: Uuid, + status_filter: Option, + ) -> Result, AppError>; - async fn find_booked_dates(&self, mentor_id: Uuid) -> Result, AppError>; + async fn find_booked_dates( + &self, + mentor_id: Uuid, + ) -> Result, AppError>; - async fn update(&self, id: Uuid, entity: SessionEntity) -> Result; + async fn update( + &self, + id: Uuid, + entity: SessionEntity, + ) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; - async fn count_by_mentor( - &self, - mentor_id: Uuid, - status_filter: Option, - ) -> Result; + async fn count_by_mentor( + &self, + mentor_id: Uuid, + status_filter: Option, + ) -> Result; - async fn count_by_mentee( - &self, - mentee_id: Uuid, - status_filter: Option, - ) -> Result; + async fn count_by_mentee( + &self, + mentee_id: Uuid, + status_filter: Option, + ) -> Result; - async fn find_all_paginated( - &self, - params: PaginationParams, - ) -> Result, AppError>; + async fn find_all_paginated( + &self, + params: PaginationParams, + ) -> Result, AppError>; } diff --git a/imphnen-dimentorin/src/sessions/domain/service.rs b/imphnen-dimentorin/src/sessions/domain/service.rs index ce5d74d..e1451c0 100644 --- a/imphnen-dimentorin/src/sessions/domain/service.rs +++ b/imphnen-dimentorin/src/sessions/domain/service.rs @@ -1,53 +1,53 @@ +use super::session_types::{ + BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, + SessionFeedbackCommand, SessionFeedbackResult, SessionList, + UpdateSessionStatusCommand, UpdatedSessionStatus, +}; use async_trait::async_trait; use imphnen_utils::AppError; -use crate::sessions::infrastructure::http::dto::{ - BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, - SessionDetailDto, SessionFeedbackRequestDto, SessionFeedbackResponseDto, - SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, -}; #[async_trait] pub trait SessionService: Send + Sync { - async fn book_session( - &self, - mentor_id: String, - user_id: String, - dto: BookSessionRequestDto, - ) -> Result; + async fn book_session( + &self, + mentor_id: String, + user_id: String, + cmd: BookSessionCommand, + ) -> Result; - async fn get_mentor_sessions( - &self, - mentor_id: String, - status_filter: Option, - ) -> Result; + async fn get_mentor_sessions( + &self, + mentor_id: String, + status_filter: Option, + ) -> Result; - async fn get_user_sessions( - &self, - user_id: String, - status_filter: Option, - ) -> Result; + async fn get_user_sessions( + &self, + user_id: String, + status_filter: Option, + ) -> Result; - async fn get_mentor_availability( - &self, - mentor_id: String, - ) -> Result; + async fn get_mentor_availability( + &self, + mentor_id: String, + ) -> Result; - async fn update_session_status( - &self, - session_id: String, - user_id: String, - dto: UpdateSessionStatusRequestDto, - ) -> Result; + async fn update_session_status( + &self, + session_id: String, + user_id: String, + cmd: UpdateSessionStatusCommand, + ) -> Result; - async fn submit_feedback( - &self, - session_id: String, - user_id: String, - dto: SessionFeedbackRequestDto, - ) -> Result; + async fn submit_feedback( + &self, + session_id: String, + user_id: String, + cmd: SessionFeedbackCommand, + ) -> Result; - async fn get_session_detail( - &self, - session_id: String, - ) -> Result; + async fn get_session_detail( + &self, + session_id: String, + ) -> Result; } diff --git a/imphnen-dimentorin/src/sessions/domain/session.rs b/imphnen-dimentorin/src/sessions/domain/session.rs index 8a93dfe..dae1123 100644 --- a/imphnen-dimentorin/src/sessions/domain/session.rs +++ b/imphnen-dimentorin/src/sessions/domain/session.rs @@ -3,19 +3,19 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct SessionEntity { - pub id: Uuid, - pub mentor_id: Uuid, - pub mentee_id: Uuid, - pub topic: String, - pub description: Option, - pub scheduled_at: DateTime, - pub duration_minutes: i32, - pub meeting_link: Option, - pub session_type: String, - pub status: String, - pub feedback: Option, - pub rating: Option, - pub feedback_submitted_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub mentor_id: Uuid, + pub mentee_id: Uuid, + pub topic: String, + pub description: Option, + pub scheduled_at: DateTime, + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, + pub status: String, + pub feedback: Option, + pub rating: Option, + pub feedback_submitted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, } diff --git a/imphnen-dimentorin/src/sessions/domain/session_types.rs b/imphnen-dimentorin/src/sessions/domain/session_types.rs new file mode 100644 index 0000000..5fae06d --- /dev/null +++ b/imphnen-dimentorin/src/sessions/domain/session_types.rs @@ -0,0 +1,98 @@ +pub struct BookSessionCommand { + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: Option, + pub session_type: Option, +} + +pub struct BookedSession { + pub id: String, + pub mentor_id: String, + pub mentee_id: String, + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: i32, + pub session_type: String, + pub status: String, + pub created_at: String, +} + +pub struct SessionListItem { + pub id: String, + pub mentor_id: String, + pub mentee_id: String, + pub mentee_fullname: Option, + pub mentee_email: Option, + pub topic: String, + pub scheduled_at: String, + pub duration_minutes: i32, + pub session_type: String, + pub status: String, + pub rating: Option, + pub created_at: String, +} + +pub struct SessionList { + pub sessions: Vec, + pub total: usize, +} + +pub struct SessionDetail { + pub id: String, + pub mentor_id: String, + pub mentor_fullname: Option, + pub mentee_id: String, + pub mentee_fullname: Option, + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, + pub status: String, + pub feedback: Option, + pub rating: Option, + pub feedback_submitted_at: Option, + pub created_at: String, + pub updated_at: String, +} + +pub struct AvailabilitySlot { + pub date: String, + pub time: String, + pub available: bool, +} + +pub struct MentorAvailability { + pub mentor_id: String, + pub availability_commitment: String, + pub preferred_formats: Vec, + pub slots: Vec, + pub booked_dates: Vec, +} + +pub struct UpdateSessionStatusCommand { + pub status: String, + pub meeting_link: Option, +} + +pub struct UpdatedSessionStatus { + pub id: String, + pub status: String, + pub meeting_link: Option, + pub updated_at: String, +} + +pub struct SessionFeedbackCommand { + pub feedback: String, + pub rating: i32, +} + +pub struct SessionFeedbackResult { + pub id: String, + pub feedback: String, + pub rating: i32, + pub submitted_at: String, +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto.rs deleted file mode 100644 index ea3b935..0000000 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/dto.rs +++ /dev/null @@ -1,153 +0,0 @@ -use imphnen_libs::ZodValidate; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use zod_rs::prelude::*; - -// ============================================================ -// Request DTOs -// ============================================================ - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct BookSessionRequestDto { - #[zod(min_length(3), max_length(200))] - pub topic: String, - #[zod(max_length(1000))] - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[zod(min_length(1))] - pub scheduled_at: String, - #[zod(min(15.0), max(240.0), int)] - #[serde(skip_serializing_if = "Option::is_none")] - pub duration_minutes: Option, - #[zod(max_length(50))] - #[serde(skip_serializing_if = "Option::is_none")] - pub session_type: Option, -} - -impl ZodValidate for BookSessionRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct UpdateSessionStatusRequestDto { - #[zod(min_length(1), max_length(50))] - pub status: String, - #[zod(url)] - #[serde(skip_serializing_if = "Option::is_none")] - pub meeting_link: Option, -} - -impl ZodValidate for UpdateSessionStatusRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] -pub struct SessionFeedbackRequestDto { - #[zod(min_length(10), max_length(2000))] - pub feedback: String, - #[zod(min(1.0), max(5.0), int)] - pub rating: i32, -} - -impl ZodValidate for SessionFeedbackRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } -} - -// ============================================================ -// Response DTOs -// ============================================================ - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct BookSessionResponseDto { - pub id: String, - pub mentor_id: String, - pub mentee_id: String, - pub topic: String, - pub description: Option, - pub scheduled_at: String, - pub duration_minutes: i32, - pub session_type: String, - pub status: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct SessionListItemDto { - pub id: String, - pub mentor_id: String, - pub mentee_id: String, - pub mentee_fullname: Option, - pub mentee_email: Option, - pub topic: String, - pub scheduled_at: String, - pub duration_minutes: i32, - pub session_type: String, - pub status: String, - pub rating: Option, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct SessionListResponseDto { - pub sessions: Vec, - pub total: usize, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct SessionDetailDto { - pub id: String, - pub mentor_id: String, - pub mentor_fullname: Option, - pub mentee_id: String, - pub mentee_fullname: Option, - pub topic: String, - pub description: Option, - pub scheduled_at: String, - pub duration_minutes: i32, - pub meeting_link: Option, - pub session_type: String, - pub status: String, - pub feedback: Option, - pub rating: Option, - pub feedback_submitted_at: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct AvailabilitySlotDto { - pub date: String, - pub time: String, - pub available: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct MentorAvailabilityDto { - pub mentor_id: String, - pub availability_commitment: String, - pub preferred_formats: Vec, - pub slots: Vec, - pub booked_dates: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct UpdateSessionStatusResponseDto { - pub id: String, - pub status: String, - pub meeting_link: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct SessionFeedbackResponseDto { - pub id: String, - pub feedback: String, - pub rating: i32, - pub submitted_at: String, -} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs new file mode 100644 index 0000000..e6a5bee --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs @@ -0,0 +1,11 @@ +pub mod request; +pub mod response; + +pub use request::{ + BookSessionRequestDto, SessionFeedbackRequestDto, UpdateSessionStatusRequestDto, +}; +pub use response::{ + AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto, + SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto, + SessionListResponseDto, UpdateSessionStatusResponseDto, +}; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/request.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/request.rs new file mode 100644 index 0000000..0099c00 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/request.rs @@ -0,0 +1,89 @@ +use crate::sessions::domain::{ + BookSessionCommand, SessionFeedbackCommand, UpdateSessionStatusCommand, +}; +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zod_rs::prelude::*; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct BookSessionRequestDto { + #[zod(min_length(3), max_length(200))] + pub topic: String, + #[zod(max_length(1000))] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[zod(min_length(1))] + pub scheduled_at: String, + #[zod(min(15.0), max(240.0), int)] + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_minutes: Option, + #[zod(max_length(50))] + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, +} + +impl ZodValidate for BookSessionRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for BookSessionCommand { + fn from(dto: BookSessionRequestDto) -> Self { + Self { + topic: dto.topic, + description: dto.description, + scheduled_at: dto.scheduled_at, + duration_minutes: dto.duration_minutes, + session_type: dto.session_type, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct UpdateSessionStatusRequestDto { + #[zod(min_length(1), max_length(50))] + pub status: String, + #[zod(url)] + #[serde(skip_serializing_if = "Option::is_none")] + pub meeting_link: Option, +} + +impl ZodValidate for UpdateSessionStatusRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for UpdateSessionStatusCommand { + fn from(dto: UpdateSessionStatusRequestDto) -> Self { + Self { + status: dto.status, + meeting_link: dto.meeting_link, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct SessionFeedbackRequestDto { + #[zod(min_length(10), max_length(2000))] + pub feedback: String, + #[zod(min(1.0), max(5.0), int)] + pub rating: i32, +} + +impl ZodValidate for SessionFeedbackRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +impl From for SessionFeedbackCommand { + fn from(dto: SessionFeedbackRequestDto) -> Self { + Self { + feedback: dto.feedback, + rating: dto.rating, + } + } +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs new file mode 100644 index 0000000..d191f34 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs @@ -0,0 +1,212 @@ +use crate::sessions::domain::{ + AvailabilitySlot, BookedSession, MentorAvailability, SessionDetail, + SessionFeedbackResult, SessionList, SessionListItem, UpdatedSessionStatus, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct BookSessionResponseDto { + pub id: String, + pub mentor_id: String, + pub mentee_id: String, + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: i32, + pub session_type: String, + pub status: String, + pub created_at: String, +} + +impl From for BookSessionResponseDto { + fn from(s: BookedSession) -> Self { + Self { + id: s.id, + mentor_id: s.mentor_id, + mentee_id: s.mentee_id, + topic: s.topic, + description: s.description, + scheduled_at: s.scheduled_at, + duration_minutes: s.duration_minutes, + session_type: s.session_type, + status: s.status, + created_at: s.created_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionListItemDto { + pub id: String, + pub mentor_id: String, + pub mentee_id: String, + pub mentee_fullname: Option, + pub mentee_email: Option, + pub topic: String, + pub scheduled_at: String, + pub duration_minutes: i32, + pub session_type: String, + pub status: String, + pub rating: Option, + pub created_at: String, +} + +impl From for SessionListItemDto { + fn from(s: SessionListItem) -> Self { + Self { + id: s.id, + mentor_id: s.mentor_id, + mentee_id: s.mentee_id, + mentee_fullname: s.mentee_fullname, + mentee_email: s.mentee_email, + topic: s.topic, + scheduled_at: s.scheduled_at, + duration_minutes: s.duration_minutes, + session_type: s.session_type, + status: s.status, + rating: s.rating, + created_at: s.created_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionListResponseDto { + pub sessions: Vec, + pub total: usize, +} + +impl From for SessionListResponseDto { + fn from(list: SessionList) -> Self { + Self { + sessions: list + .sessions + .into_iter() + .map(SessionListItemDto::from) + .collect(), + total: list.total, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionDetailDto { + pub id: String, + pub mentor_id: String, + pub mentor_fullname: Option, + pub mentee_id: String, + pub mentee_fullname: Option, + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, + pub status: String, + pub feedback: Option, + pub rating: Option, + pub feedback_submitted_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From for SessionDetailDto { + fn from(d: SessionDetail) -> Self { + Self { + id: d.id, + mentor_id: d.mentor_id, + mentor_fullname: d.mentor_fullname, + mentee_id: d.mentee_id, + mentee_fullname: d.mentee_fullname, + topic: d.topic, + description: d.description, + scheduled_at: d.scheduled_at, + duration_minutes: d.duration_minutes, + meeting_link: d.meeting_link, + session_type: d.session_type, + status: d.status, + feedback: d.feedback, + rating: d.rating, + feedback_submitted_at: d.feedback_submitted_at, + created_at: d.created_at, + updated_at: d.updated_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AvailabilitySlotDto { + pub date: String, + pub time: String, + pub available: bool, +} + +impl From for AvailabilitySlotDto { + fn from(s: AvailabilitySlot) -> Self { + Self { + date: s.date, + time: s.time, + available: s.available, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MentorAvailabilityDto { + pub mentor_id: String, + pub availability_commitment: String, + pub preferred_formats: Vec, + pub slots: Vec, + pub booked_dates: Vec, +} + +impl From for MentorAvailabilityDto { + fn from(a: MentorAvailability) -> Self { + Self { + mentor_id: a.mentor_id, + availability_commitment: a.availability_commitment, + preferred_formats: a.preferred_formats, + slots: a.slots.into_iter().map(AvailabilitySlotDto::from).collect(), + booked_dates: a.booked_dates, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdateSessionStatusResponseDto { + pub id: String, + pub status: String, + pub meeting_link: Option, + pub updated_at: String, +} + +impl From for UpdateSessionStatusResponseDto { + fn from(u: UpdatedSessionStatus) -> Self { + Self { + id: u.id, + status: u.status, + meeting_link: u.meeting_link, + updated_at: u.updated_at, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionFeedbackResponseDto { + pub id: String, + pub feedback: String, + pub rating: i32, + pub submitted_at: String, +} + +impl From for SessionFeedbackResponseDto { + fn from(r: SessionFeedbackResult) -> Self { + Self { + id: r.id, + feedback: r.feedback, + rating: r.rating, + submitted_at: r.submitted_at, + } + } +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers.rs deleted file mode 100644 index 441502f..0000000 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::sync::Arc; -use axum::{ - extract::{Extension, Path, Query}, - http::HeaderMap, - response::IntoResponse, -}; -use serde::Deserialize; -use imphnen_libs::ValidatedJson; -use imphnen_utils::{ApiSuccess, extract_email}; -use imphnen_utils::AppError; -use crate::sessions::domain::SessionService; -use super::dto::{ - BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, - SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListResponseDto, - UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, -}; - -#[derive(Deserialize)] -pub struct SessionStatusFilter { - pub status: Option, -} - -#[utoipa::path( - post, - path = "/v1/mentors/{id}/sessions/create", - tag = "sessions", - security(("Bearer" = [])), - params( - ("id" = String, Path, description = "Mentor ID"), - ), - request_body = BookSessionRequestDto, - responses( - (status = 201, description = "Session booked successfully", body = BookSessionResponseDto), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Mentor not found"), - ) -)] -pub async fn post_book_session( - headers: HeaderMap, - Extension(service): Extension>, - Path(mentor_id): Path, - ValidatedJson(dto): ValidatedJson, -) -> Result { - let user_email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.book_session(mentor_id, user_email, dto).await?; - Ok(ApiSuccess(resp)) -} - -#[utoipa::path( - get, - path = "/v1/mentors/{id}/sessions", - tag = "sessions", - security(("Bearer" = [])), - params( - ("id" = String, Path, description = "Mentor ID"), - ("status" = Option, Query, description = "Filter by status"), - ), - responses( - (status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Mentor not found"), - ) -)] -pub async fn get_mentor_sessions( - headers: HeaderMap, - Extension(service): Extension>, - Path(mentor_id): Path, - Query(filter): Query, -) -> Result { - let _user_email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.get_mentor_sessions(mentor_id, filter.status).await?; - Ok(ApiSuccess(resp)) -} - -#[utoipa::path( - get, - path = "/v1/mentors/{id}/availability", - tag = "sessions", - params( - ("id" = String, Path, description = "Mentor ID"), - ), - responses( - (status = 200, description = "Availability retrieved successfully", body = MentorAvailabilityDto), - (status = 404, description = "Mentor not found"), - ) -)] -pub async fn get_mentor_availability( - Extension(service): Extension>, - Path(mentor_id): Path, -) -> Result { - let resp = service.get_mentor_availability(mentor_id).await?; - Ok(ApiSuccess(resp)) -} - -#[utoipa::path( - put, - path = "/v1/sessions/update/{id}/status", - tag = "sessions", - security(("Bearer" = [])), - params( - ("id" = String, Path, description = "Session ID"), - ), - request_body = UpdateSessionStatusRequestDto, - responses( - (status = 200, description = "Status updated successfully", body = UpdateSessionStatusResponseDto), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), - ) -)] -pub async fn put_update_session_status( - headers: HeaderMap, - Extension(service): Extension>, - Path(session_id): Path, - ValidatedJson(dto): ValidatedJson, -) -> Result { - let user_email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.update_session_status(session_id, user_email, dto).await?; - Ok(ApiSuccess(resp)) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{id}/feedback/create", - tag = "sessions", - security(("Bearer" = [])), - params( - ("id" = String, Path, description = "Session ID"), - ), - request_body = SessionFeedbackRequestDto, - responses( - (status = 200, description = "Feedback submitted successfully", body = SessionFeedbackResponseDto), - (status = 400, description = "Invalid request or session not completed"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Session not found"), - ) -)] -pub async fn post_submit_feedback( - headers: HeaderMap, - Extension(service): Extension>, - Path(session_id): Path, - ValidatedJson(dto): ValidatedJson, -) -> Result { - let user_email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.submit_feedback(session_id, user_email, dto).await?; - Ok(ApiSuccess(resp)) -} - -#[utoipa::path( - get, - path = "/v1/users/me/sessions", - tag = "sessions", - security(("Bearer" = [])), - params( - ("status" = Option, Query, description = "Filter by status"), - ), - responses( - (status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto), - (status = 401, description = "Unauthorized"), - ) -)] -pub async fn get_my_sessions( - headers: HeaderMap, - Extension(service): Extension>, - Query(filter): Query, -) -> Result { - let user_email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; - let resp = service.get_user_sessions(user_email, filter.status).await?; - Ok(ApiSuccess(resp)) -} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs new file mode 100644 index 0000000..1a01370 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs @@ -0,0 +1,9 @@ +pub mod mutation_handlers; +pub mod query_handlers; + +pub use mutation_handlers::{ + post_book_session, post_submit_feedback, put_update_session_status, +}; +pub use query_handlers::{ + get_mentor_availability, get_mentor_sessions, get_my_sessions, +}; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs new file mode 100644 index 0000000..a15dc49 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mutation_handlers.rs @@ -0,0 +1,112 @@ +use super::super::dto::{ + BookSessionRequestDto, BookSessionResponseDto, SessionFeedbackRequestDto, + SessionFeedbackResponseDto, UpdateSessionStatusRequestDto, + UpdateSessionStatusResponseDto, +}; +use crate::sessions::domain::SessionService; +use axum::{ + extract::{Extension, Path}, + http::HeaderMap, + response::IntoResponse, +}; +use imphnen_libs::ValidatedJson; +use imphnen_utils::AppError; +use imphnen_utils::{ApiSuccess, extract_email}; +use std::sync::Arc; + +#[utoipa::path( + post, + path = "/v1/mentors/{id}/sessions/create", + tag = "sessions", + security(("Bearer" = [])), + params( + ("id" = String, Path, description = "Mentor ID"), + ), + request_body = BookSessionRequestDto, + responses( + (status = 201, description = "Session booked successfully", body = BookSessionResponseDto), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mentor not found"), + ) +)] +pub async fn post_book_session( + headers: HeaderMap, + Extension(service): Extension>, + Path(mentor_id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let user_email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let resp = BookSessionResponseDto::from( + service + .book_session(mentor_id, user_email, dto.into()) + .await?, + ); + Ok(ApiSuccess(resp)) +} + +#[utoipa::path( + put, + path = "/v1/sessions/update/{id}/status", + tag = "sessions", + security(("Bearer" = [])), + params( + ("id" = String, Path, description = "Session ID"), + ), + request_body = UpdateSessionStatusRequestDto, + responses( + (status = 200, description = "Status updated successfully", body = UpdateSessionStatusResponseDto), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session not found"), + ) +)] +pub async fn put_update_session_status( + headers: HeaderMap, + Extension(service): Extension>, + Path(session_id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let user_email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let resp = UpdateSessionStatusResponseDto::from( + service + .update_session_status(session_id, user_email, dto.into()) + .await?, + ); + Ok(ApiSuccess(resp)) +} + +#[utoipa::path( + post, + path = "/v1/sessions/{id}/feedback/create", + tag = "sessions", + security(("Bearer" = [])), + params( + ("id" = String, Path, description = "Session ID"), + ), + request_body = SessionFeedbackRequestDto, + responses( + (status = 200, description = "Feedback submitted successfully", body = SessionFeedbackResponseDto), + (status = 400, description = "Invalid request or session not completed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Session not found"), + ) +)] +pub async fn post_submit_feedback( + headers: HeaderMap, + Extension(service): Extension>, + Path(session_id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let user_email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let resp = SessionFeedbackResponseDto::from( + service + .submit_feedback(session_id, user_email, dto.into()) + .await?, + ); + Ok(ApiSuccess(resp)) +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs new file mode 100644 index 0000000..09708ef --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs @@ -0,0 +1,94 @@ +use super::super::dto::{MentorAvailabilityDto, SessionListResponseDto}; +use crate::sessions::domain::SessionService; +use axum::{ + extract::{Extension, Path, Query}, + http::HeaderMap, + response::IntoResponse, +}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiSuccess, extract_email}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Deserialize)] +pub struct SessionStatusFilter { + pub status: Option, +} + +#[utoipa::path( + get, + path = "/v1/mentors/{id}/sessions", + tag = "sessions", + security(("Bearer" = [])), + params( + ("id" = String, Path, description = "Mentor ID"), + ("status" = Option, Query, description = "Filter by status"), + ), + responses( + (status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mentor not found"), + ) +)] +pub async fn get_mentor_sessions( + headers: HeaderMap, + Extension(service): Extension>, + Path(mentor_id): Path, + Query(filter): Query, +) -> Result { + let _user_email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let resp = SessionListResponseDto::from( + service + .get_mentor_sessions(mentor_id, filter.status) + .await?, + ); + Ok(ApiSuccess(resp)) +} + +#[utoipa::path( + get, + path = "/v1/mentors/{id}/availability", + tag = "sessions", + params( + ("id" = String, Path, description = "Mentor ID"), + ), + responses( + (status = 200, description = "Availability retrieved successfully", body = MentorAvailabilityDto), + (status = 404, description = "Mentor not found"), + ) +)] +pub async fn get_mentor_availability( + Extension(service): Extension>, + Path(mentor_id): Path, +) -> Result { + let resp = + MentorAvailabilityDto::from(service.get_mentor_availability(mentor_id).await?); + Ok(ApiSuccess(resp)) +} + +#[utoipa::path( + get, + path = "/v1/users/me/sessions", + tag = "sessions", + security(("Bearer" = [])), + params( + ("status" = Option, Query, description = "Filter by status"), + ), + responses( + (status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto), + (status = 401, description = "Unauthorized"), + ) +)] +pub async fn get_my_sessions( + headers: HeaderMap, + Extension(service): Extension>, + Query(filter): Query, +) -> Result { + let user_email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let resp = SessionListResponseDto::from( + service.get_user_sessions(user_email, filter.status).await?, + ); + Ok(ApiSuccess(resp)) +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs index 624b677..c87c81d 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs @@ -1,38 +1,44 @@ -use std::sync::Arc; -use axum::{ - routing::{get, post, put}, - Extension, Router, +use super::handlers::{ + get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session, + post_submit_feedback, put_update_session_status, }; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; use crate::sessions::application::SessionServiceImpl; use crate::sessions::domain::SessionService; use crate::sessions::infrastructure::persistence::PostgresSessionRepository; -use super::handlers::{ - get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session, - post_submit_feedback, put_update_session_status, +use axum::{ + Extension, Router, + routing::{get, post, put}, }; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresSessionRepository::new(db)); - Arc::new(SessionServiceImpl::new(repo)) + let repo = Arc::new(PostgresSessionRepository::new(db)); + Arc::new(SessionServiceImpl::new(repo)) } pub fn sessions_public_routes(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/mentors/{id}/availability", get(get_mentor_availability)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/mentors/{id}/availability", get(get_mentor_availability)) + .layer(Extension(service)) } -pub fn sessions_protected_routes(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db); - Router::new() - .route("/mentors/{id}/sessions/create", post(post_book_session)) - .route("/mentors/{id}/sessions", get(get_mentor_sessions)) - .route("/sessions/update/{id}/status", put(put_update_session_status)) - .route("/sessions/{id}/feedback/create", post(post_submit_feedback)) - .route("/users/me/sessions", get(get_my_sessions)) - .layer(Extension(service)) - .layer(Extension((*state).clone())) +pub fn sessions_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/mentors/{id}/sessions/create", post(post_book_session)) + .route("/mentors/{id}/sessions", get(get_mentor_sessions)) + .route( + "/sessions/update/{id}/status", + put(put_update_session_status), + ) + .route("/sessions/{id}/feedback/create", post(post_submit_feedback)) + .route("/users/me/sessions", get(get_my_sessions)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-dimentorin/src/sessions/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/sessions/infrastructure/persistence/mod.rs index bcd4b00..79f166d 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/persistence/mod.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/persistence/mod.rs @@ -1,3 +1,4 @@ +pub mod postgres_session_queries; pub mod postgres_session_repository; pub use postgres_session_repository::PostgresSessionRepository; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_queries.rs b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_queries.rs new file mode 100644 index 0000000..2e99065 --- /dev/null +++ b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_queries.rs @@ -0,0 +1,142 @@ +use super::postgres_session_repository::model_to_entity; +use crate::sessions::domain::session::SessionEntity; +use imphnen_entities::seaorm::auth::sessions::{ + Column as SessionColumn, Entity as SessionsEntity, +}; +use imphnen_utils::AppError; +use paginator_rs::{PaginationParams, SortDirection}; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; + +pub async fn find_by_mentor_id( + db: &Arc, + mentor_id: Uuid, + status_filter: Option, +) -> Result, AppError> { + let mut query = SessionsEntity::find() + .filter(SessionColumn::MentorId.eq(mentor_id)) + .order_by(SessionColumn::ScheduledAt, Order::Desc); + + if let Some(status) = status_filter { + query = query.filter(SessionColumn::Status.eq(status)); + } + + let models = query + .all(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(models.into_iter().map(model_to_entity).collect()) +} + +pub async fn find_by_mentee_id( + db: &Arc, + mentee_id: Uuid, + status_filter: Option, +) -> Result, AppError> { + let mut query = SessionsEntity::find() + .filter(SessionColumn::MenteeId.eq(mentee_id)) + .order_by(SessionColumn::ScheduledAt, Order::Desc); + + if let Some(status) = status_filter { + query = query.filter(SessionColumn::Status.eq(status)); + } + + let models = query + .all(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(models.into_iter().map(model_to_entity).collect()) +} + +pub async fn find_booked_dates( + db: &Arc, + mentor_id: Uuid, +) -> Result, AppError> { + let sessions = SessionsEntity::find() + .filter(SessionColumn::MentorId.eq(mentor_id)) + .filter(SessionColumn::Status.is_in(["pending", "confirmed"])) + .order_by(SessionColumn::ScheduledAt, Order::Asc) + .all(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok( + sessions + .into_iter() + .map(|s| s.scheduled_at.to_rfc3339()) + .collect(), + ) +} + +pub async fn count_by_mentor( + db: &Arc, + mentor_id: Uuid, + status_filter: Option, +) -> Result { + let mut query = + SessionsEntity::find().filter(SessionColumn::MentorId.eq(mentor_id)); + + if let Some(status) = status_filter { + query = query.filter(SessionColumn::Status.eq(status)); + } + + let count = query + .count(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(count as usize) +} + +pub async fn count_by_mentee( + db: &Arc, + mentee_id: Uuid, + status_filter: Option, +) -> Result { + let mut query = + SessionsEntity::find().filter(SessionColumn::MenteeId.eq(mentee_id)); + + if let Some(status) = status_filter { + query = query.filter(SessionColumn::Status.eq(status)); + } + + let count = query + .count(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(count as usize) +} + +pub async fn find_all_paginated( + db: &Arc, + params: PaginationParams, +) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); + + let query = match params.sort_direction { + Some(SortDirection::Asc) => { + SessionsEntity::find().order_by(SessionColumn::CreatedAt, Order::Asc) + } + _ => SessionsEntity::find().order_by(SessionColumn::CreatedAt, Order::Desc), + }; + + let paginator = query.paginate(db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let sessions = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let data = sessions.into_iter().map(model_to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) +} diff --git a/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs index c95b175..85d68c3 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs @@ -1,255 +1,184 @@ -use std::sync::Arc; -use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, IntoActiveModel, Order, QueryOrder, PaginatorTrait}; -use paginator_rs::{PaginationParams, SortDirection}; -use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; -use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::auth::sessions::{ - Entity as SessionsEntity, - Column as SessionColumn, - ActiveModel as SessionActiveModel, - Model as SessionModel, +use super::postgres_session_queries; +use crate::sessions::domain::{ + repository::SessionRepository, session::SessionEntity, }; -use crate::sessions::domain::{session::SessionEntity, repository::SessionRepository}; +use async_trait::async_trait; +use imphnen_entities::seaorm::auth::sessions::{ + ActiveModel as SessionActiveModel, Entity as SessionsEntity, Model as SessionModel, +}; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, IntoActiveModel}; +use std::sync::Arc; +use uuid::Uuid; -fn model_to_entity(model: SessionModel) -> SessionEntity { - SessionEntity { - id: model.id, - mentor_id: model.mentor_id, - mentee_id: model.mentee_id, - topic: model.topic, - description: model.description, - scheduled_at: model.scheduled_at, - duration_minutes: model.duration_minutes, - meeting_link: model.meeting_link, - session_type: model.session_type, - status: model.status, - feedback: model.feedback, - rating: model.rating, - feedback_submitted_at: model.feedback_submitted_at, - created_at: model.created_at, - updated_at: model.updated_at, - } +pub fn model_to_entity(model: SessionModel) -> SessionEntity { + SessionEntity { + id: model.id, + mentor_id: model.mentor_id, + mentee_id: model.mentee_id, + topic: model.topic, + description: model.description, + scheduled_at: model.scheduled_at, + duration_minutes: model.duration_minutes, + meeting_link: model.meeting_link, + session_type: model.session_type, + status: model.status, + feedback: model.feedback, + rating: model.rating, + feedback_submitted_at: model.feedback_submitted_at, + created_at: model.created_at, + updated_at: model.updated_at, + } } pub struct PostgresSessionRepository { - db: Arc, + pub db: Arc, } impl PostgresSessionRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl SessionRepository for PostgresSessionRepository { - async fn create(&self, entity: SessionEntity) -> Result { - let active_model = SessionActiveModel { - id: ActiveValue::Set(entity.id), - mentor_id: ActiveValue::Set(entity.mentor_id), - mentee_id: ActiveValue::Set(entity.mentee_id), - topic: ActiveValue::Set(entity.topic.clone()), - description: ActiveValue::Set(entity.description.clone()), - scheduled_at: ActiveValue::Set(entity.scheduled_at), - duration_minutes: ActiveValue::Set(entity.duration_minutes), - meeting_link: ActiveValue::Set(entity.meeting_link.clone()), - session_type: ActiveValue::Set(entity.session_type.clone()), - status: ActiveValue::Set(entity.status.clone()), - feedback: ActiveValue::Set(entity.feedback.clone()), - rating: ActiveValue::Set(entity.rating), - feedback_submitted_at: ActiveValue::Set(entity.feedback_submitted_at), - created_at: ActiveValue::Set(entity.created_at), - updated_at: ActiveValue::Set(entity.updated_at), - }; + async fn create(&self, entity: SessionEntity) -> Result { + let active_model = SessionActiveModel { + id: ActiveValue::Set(entity.id), + mentor_id: ActiveValue::Set(entity.mentor_id), + mentee_id: ActiveValue::Set(entity.mentee_id), + topic: ActiveValue::Set(entity.topic.clone()), + description: ActiveValue::Set(entity.description.clone()), + scheduled_at: ActiveValue::Set(entity.scheduled_at), + duration_minutes: ActiveValue::Set(entity.duration_minutes), + meeting_link: ActiveValue::Set(entity.meeting_link.clone()), + session_type: ActiveValue::Set(entity.session_type.clone()), + status: ActiveValue::Set(entity.status.clone()), + feedback: ActiveValue::Set(entity.feedback.clone()), + rating: ActiveValue::Set(entity.rating), + feedback_submitted_at: ActiveValue::Set(entity.feedback_submitted_at), + created_at: ActiveValue::Set(entity.created_at), + updated_at: ActiveValue::Set(entity.updated_at), + }; - let model: SessionModel = active_model - .insert(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let model: SessionModel = active_model + .insert(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(model_to_entity(model)) - } + Ok(model_to_entity(model)) + } - async fn find_by_id(&self, id: Uuid) -> Result, AppError> { - let model = SessionsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result, AppError> { + let model = SessionsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(model.map(model_to_entity)) - } + Ok(model.map(model_to_entity)) + } - async fn find_by_mentor_id( - &self, - mentor_id: Uuid, - status_filter: Option, - ) -> Result, AppError> { - let mut query = SessionsEntity::find() - .filter(SessionColumn::MentorId.eq(mentor_id)) - .order_by(SessionColumn::ScheduledAt, Order::Desc); + async fn find_by_mentor_id( + &self, + mentor_id: Uuid, + status_filter: Option, + ) -> Result, AppError> { + postgres_session_queries::find_by_mentor_id(&self.db, mentor_id, status_filter) + .await + } - if let Some(status) = status_filter { - query = query.filter(SessionColumn::Status.eq(status)); - } + async fn find_by_mentee_id( + &self, + mentee_id: Uuid, + status_filter: Option, + ) -> Result, AppError> { + postgres_session_queries::find_by_mentee_id(&self.db, mentee_id, status_filter) + .await + } - let models = query - .all(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn find_booked_dates( + &self, + mentor_id: Uuid, + ) -> Result, AppError> { + postgres_session_queries::find_booked_dates(&self.db, mentor_id).await + } - Ok(models.into_iter().map(model_to_entity).collect()) - } + async fn update( + &self, + id: Uuid, + entity: SessionEntity, + ) -> Result { + let model = SessionsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - async fn find_by_mentee_id( - &self, - mentee_id: Uuid, - status_filter: Option, - ) -> Result, AppError> { - let mut query = SessionsEntity::find() - .filter(SessionColumn::MenteeId.eq(mentee_id)) - .order_by(SessionColumn::ScheduledAt, Order::Desc); + let mut active_model = model.into_active_model(); - if let Some(status) = status_filter { - query = query.filter(SessionColumn::Status.eq(status)); - } + active_model.topic = ActiveValue::Set(entity.topic); + active_model.description = ActiveValue::Set(entity.description); + active_model.scheduled_at = ActiveValue::Set(entity.scheduled_at); + active_model.duration_minutes = ActiveValue::Set(entity.duration_minutes); + active_model.meeting_link = ActiveValue::Set(entity.meeting_link); + active_model.session_type = ActiveValue::Set(entity.session_type); + active_model.status = ActiveValue::Set(entity.status); + active_model.feedback = ActiveValue::Set(entity.feedback); + active_model.rating = ActiveValue::Set(entity.rating); + active_model.feedback_submitted_at = + ActiveValue::Set(entity.feedback_submitted_at); + active_model.updated_at = ActiveValue::Set(entity.updated_at); - let models = query - .all(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let updated: SessionModel = active_model + .update(self.db.as_ref()) + .await + .map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?; - Ok(models.into_iter().map(model_to_entity).collect()) - } + Ok(model_to_entity(updated)) + } - async fn find_booked_dates(&self, mentor_id: Uuid) -> Result, AppError> { - let sessions = SessionsEntity::find() - .filter(SessionColumn::MentorId.eq(mentor_id)) - .filter(SessionColumn::Status.is_in(["pending", "confirmed"])) - .order_by(SessionColumn::ScheduledAt, Order::Asc) - .all(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let model = SessionsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - Ok(sessions - .into_iter() - .map(|s| s.scheduled_at.to_rfc3339()) - .collect()) - } + model + .into_active_model() + .delete(self.db.as_ref()) + .await + .map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?; - async fn update(&self, id: Uuid, entity: SessionEntity) -> Result { - let model = SessionsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; + Ok(()) + } - let mut active_model = model.into_active_model(); + async fn count_by_mentor( + &self, + mentor_id: Uuid, + status_filter: Option, + ) -> Result { + postgres_session_queries::count_by_mentor(&self.db, mentor_id, status_filter) + .await + } - active_model.topic = ActiveValue::Set(entity.topic); - active_model.description = ActiveValue::Set(entity.description); - active_model.scheduled_at = ActiveValue::Set(entity.scheduled_at); - active_model.duration_minutes = ActiveValue::Set(entity.duration_minutes); - active_model.meeting_link = ActiveValue::Set(entity.meeting_link); - active_model.session_type = ActiveValue::Set(entity.session_type); - active_model.status = ActiveValue::Set(entity.status); - active_model.feedback = ActiveValue::Set(entity.feedback); - active_model.rating = ActiveValue::Set(entity.rating); - active_model.feedback_submitted_at = ActiveValue::Set(entity.feedback_submitted_at); - active_model.updated_at = ActiveValue::Set(entity.updated_at); + async fn count_by_mentee( + &self, + mentee_id: Uuid, + status_filter: Option, + ) -> Result { + postgres_session_queries::count_by_mentee(&self.db, mentee_id, status_filter) + .await + } - let updated: SessionModel = active_model - .update(self.db.as_ref()) - .await - .map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?; - - Ok(model_to_entity(updated)) - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - let model = SessionsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?; - - model - .into_active_model() - .delete(self.db.as_ref()) - .await - .map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?; - - Ok(()) - } - - async fn count_by_mentor( - &self, - mentor_id: Uuid, - status_filter: Option, - ) -> Result { - let mut query = SessionsEntity::find() - .filter(SessionColumn::MentorId.eq(mentor_id)); - - if let Some(status) = status_filter { - query = query.filter(SessionColumn::Status.eq(status)); - } - - let count = query - .count(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(count as usize) - } - - async fn count_by_mentee( - &self, - mentee_id: Uuid, - status_filter: Option, - ) -> Result { - let mut query = SessionsEntity::find() - .filter(SessionColumn::MenteeId.eq(mentee_id)); - - if let Some(status) = status_filter { - query = query.filter(SessionColumn::Status.eq(status)); - } - - let count = query - .count(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(count as usize) - } - - async fn find_all_paginated( - &self, - params: PaginationParams, - ) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); - - let query = match params.sort_direction { - Some(SortDirection::Asc) => SessionsEntity::find() - .order_by(SessionColumn::CreatedAt, Order::Asc), - _ => SessionsEntity::find() - .order_by(SessionColumn::CreatedAt, Order::Desc), - }; - - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let total = paginator - .num_items() - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let sessions = paginator - .fetch_page((page - 1) as u64) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let data = sessions.into_iter().map(model_to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } + async fn find_all_paginated( + &self, + params: PaginationParams, + ) -> Result, AppError> { + postgres_session_queries::find_all_paginated(&self.db, params).await + } } diff --git a/imphnen-dimentorin/src/sessions/mod.rs b/imphnen-dimentorin/src/sessions/mod.rs index cb050e0..7a98518 100644 --- a/imphnen-dimentorin/src/sessions/mod.rs +++ b/imphnen-dimentorin/src/sessions/mod.rs @@ -2,4 +2,6 @@ pub mod application; pub mod domain; pub mod infrastructure; -pub use infrastructure::http::routes::{sessions_protected_routes, sessions_public_routes}; +pub use infrastructure::http::routes::{ + sessions_protected_routes, sessions_public_routes, +}; diff --git a/imphnen-email/Cargo.toml b/imphnen-email/Cargo.toml new file mode 100644 index 0000000..7a9c5af --- /dev/null +++ b/imphnen-email/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "imphnen-email" +version = "0.3.0" +edition = "2024" + +[dependencies] +imphnen-libs.workspace = true +lettre.workspace = true +tracing.workspace = true diff --git a/imphnen-email/src/error.rs b/imphnen-email/src/error.rs new file mode 100644 index 0000000..4f4447e --- /dev/null +++ b/imphnen-email/src/error.rs @@ -0,0 +1,20 @@ +use std::fmt; + +#[derive(Debug)] +pub enum EmailError { + SmtpConfig(String), + MessageBuild(String), + Transport(String), +} + +impl fmt::Display for EmailError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EmailError::SmtpConfig(msg) => write!(f, "SMTP configuration error: {msg}"), + EmailError::MessageBuild(msg) => write!(f, "Message building error: {msg}"), + EmailError::Transport(msg) => write!(f, "SMTP transport error: {msg}"), + } + } +} + +impl std::error::Error for EmailError {} diff --git a/imphnen-email/src/lib.rs b/imphnen-email/src/lib.rs new file mode 100644 index 0000000..7df7163 --- /dev/null +++ b/imphnen-email/src/lib.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod service; + +pub use error::EmailError; +pub use service::send_email; diff --git a/imphnen-email/src/service.rs b/imphnen-email/src/service.rs new file mode 100644 index 0000000..a1c0cc8 --- /dev/null +++ b/imphnen-email/src/service.rs @@ -0,0 +1,48 @@ +use imphnen_libs::{ENV, Env}; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use std::error::Error; + +use crate::error::EmailError; + +pub fn send_email( + to: &str, + subject: &str, + body: &str, +) -> Result<(), Box> { + let env = &ENV; + let message = build_message(to, subject, body, env)?; + let mailer = build_transport(env)?; + mailer.send(&message).map_err(|e| { + tracing::error!("Failed to send email to {}: {}", to, e); + Box::new(EmailError::Transport(e.to_string())) as Box + })?; + tracing::info!("Email sent to: {}", to); + Ok(()) +} + +fn build_message( + to: &str, + subject: &str, + body: &str, + env: &Env, +) -> Result> { + let sender_name = env.smtp_name.replace("-", " "); + Message::builder() + .from(Mailbox::new(Some(sender_name), env.smtp_email.parse()?)) + .to(to.parse()?) + .subject(subject) + .body(body.to_string()) + .map_err(|e| Box::new(EmailError::MessageBuild(e.to_string())) as Box) +} + +fn build_transport(env: &Env) -> Result> { + let credentials = + Credentials::new(env.smtp_email.clone(), env.smtp_password.replace("-", " ")); + Ok( + SmtpTransport::relay(&env.smtp_host)? + .credentials(credentials) + .build(), + ) +} diff --git a/imphnen-entities/Cargo.toml b/imphnen-entities/Cargo.toml index 5b94977..666901d 100644 --- a/imphnen-entities/Cargo.toml +++ b/imphnen-entities/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-entities" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/imphnen-entities/src/audit_log.rs b/imphnen-entities/src/audit_log.rs index 13514ef..51aa1d1 100644 --- a/imphnen-entities/src/audit_log.rs +++ b/imphnen-entities/src/audit_log.rs @@ -1,3 +1,3 @@ -use crate::seaorm::common::audit_log; - -pub type AuditLogSchema = audit_log::Model; \ No newline at end of file +use crate::seaorm::common::audit_log; + +pub type AuditLogSchema = audit_log::Model; diff --git a/imphnen-entities/src/common_dto.rs b/imphnen-entities/src/common_dto.rs index f2b0ee2..d8eaf7a 100644 --- a/imphnen-entities/src/common_dto.rs +++ b/imphnen-entities/src/common_dto.rs @@ -1,35 +1,33 @@ -use serde::{Deserialize, Serialize}; -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 MetaResponseDto { - pub page: Option, - pub per_page: Option, - pub total: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct ResponseSuccessDto { - pub data: T, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct ResponseListSuccessDto { - pub data: T, - pub meta: Option, -} - - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct ErrorDto { - pub status: u16, - pub message: String, - pub details: Option, -} - +use serde::{Deserialize, Serialize}; +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 MetaResponseDto { + pub page: Option, + pub per_page: Option, + pub total: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ResponseSuccessDto { + pub data: T, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ResponseListSuccessDto { + pub data: T, + pub meta: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ErrorDto { + pub status: u16, + pub message: String, + pub details: Option, +} diff --git a/imphnen-entities/src/error_dto.rs b/imphnen-entities/src/error_dto.rs index 30cb302..2a63985 100644 --- a/imphnen-entities/src/error_dto.rs +++ b/imphnen-entities/src/error_dto.rs @@ -1,52 +1,52 @@ -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 for Error { - fn from(status: StatusCode) -> Self { - Self::StatusCode(status) - } - } -} +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 for Error { + fn from(status: StatusCode) -> Self { + Self::StatusCode(status) + } + } +} diff --git a/imphnen-entities/src/lib.rs b/imphnen-entities/src/lib.rs index 0c1f070..016f5bd 100644 --- a/imphnen-entities/src/lib.rs +++ b/imphnen-entities/src/lib.rs @@ -1,29 +1,24 @@ -pub mod common_dto; -pub mod error_dto; -pub mod users; -pub mod permissions; -pub mod audit_log; -pub mod seaorm; - -// Explicit common_dto exports -pub use common_dto::ErrorDto; -pub use common_dto::MessageResponseDto; -pub use common_dto::ResponseListSuccessDto; -pub use common_dto::ResponseSuccessDto; - -// Explicit users exports -pub use users::RolesDetailItemDto; -pub use users::RolesDetailQueryDto; -pub use users::UsersDetailQueryDto; - -// Explicit permissions exports -pub use permissions::PermissionsEnum; -pub use permissions::PermissionsItemDto; -pub use permissions::PermissionsQueryDto; -pub use seaorm::common::enums::ResourceEnum; - -// Explicit audit_log exports -pub use audit_log::AuditLogSchema; - -// SeaORM entity exports -pub use seaorm::*; +pub mod audit_log; +pub mod common_dto; +pub mod error_dto; +pub mod permissions; +pub mod seaorm; +pub mod users; + +pub use common_dto::ErrorDto; +pub use common_dto::MessageResponseDto; +pub use common_dto::ResponseListSuccessDto; +pub use common_dto::ResponseSuccessDto; + +pub use users::RolesDetailItemDto; +pub use users::RolesDetailQueryDto; +pub use users::UsersDetailQueryDto; + +pub use permissions::PermissionsEnum; +pub use permissions::PermissionsItemDto; +pub use permissions::PermissionsQueryDto; +pub use seaorm::common::enums::ResourceEnum; + +pub use audit_log::AuditLogSchema; + +pub use seaorm::*; diff --git a/imphnen-entities/src/permissions.rs b/imphnen-entities/src/permissions.rs deleted file mode 100644 index a915485..0000000 --- a/imphnen-entities/src/permissions.rs +++ /dev/null @@ -1,273 +0,0 @@ -use std::fmt; -use uuid::Uuid; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, Clone, PartialEq, Eq, strum::EnumIter)] -pub enum PermissionsEnum { - // User permissions - ReadListUsers, - ReadDetailUsers, - CreateUsers, - DeleteUsers, - UpdateUsers, - ActivateUsers, - - // Role permissions - ReadListRoles, - ReadDetailRoles, - CreateRoles, - DeleteRoles, - UpdateRoles, - - // Permission permissions - ReadListPermissions, - ReadDetailPermissions, - CreatePermissions, - DeletePermissions, - UpdatePermissions, - - // Administrator permissions - ManageAllUsers, - ManageAllRoles, - ManageAllPermissions, - ViewAllSensitiveData, - AccessAdminDashboard, - Administrator, - - // Gacha permissions - CreateGachaClaims, - ReadDetailGachaClaims, - ReadListGachaItems, - ReadDetailGachaItems, - CreateGachaItems, - DeleteGachaItems, - UpdateGachaItems, - ReadDetailGachaRolls, - CreateGachaRolls, - ExecuteGachaRolls, - DeleteGachaRolls, - - // Mentor permissions - ReadListMentors, - ReadDetailMentors, - RegisterMentors, - ReadOwnMentorProfile, - UpdateOwnMentorProfile, - ReadOwnMentorStatus, - UpdateMentors, - VerifyMentors, - DeleteMentors, -} - -impl fmt::Display for PermissionsEnum { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let permission_str = match self { - // User permissions - PermissionsEnum::ReadListUsers => "Read List Users", - PermissionsEnum::ReadDetailUsers => "Read Detail Users", - PermissionsEnum::CreateUsers => "Create Users", - PermissionsEnum::DeleteUsers => "Delete Users", - PermissionsEnum::UpdateUsers => "Update Users", - PermissionsEnum::ActivateUsers => "Activate Users", - - // Role permissions - PermissionsEnum::ReadListRoles => "Read List Roles", - PermissionsEnum::ReadDetailRoles => "Read Detail Roles", - PermissionsEnum::CreateRoles => "Create Roles", - PermissionsEnum::DeleteRoles => "Delete Roles", - PermissionsEnum::UpdateRoles => "Update Roles", - - // Permission permissions - PermissionsEnum::ReadListPermissions => "Read List Permissions", - PermissionsEnum::ReadDetailPermissions => "Read Detail Permissions", - PermissionsEnum::CreatePermissions => "Create Permissions", - PermissionsEnum::DeletePermissions => "Delete Permissions", - PermissionsEnum::UpdatePermissions => "Update Permissions", - - // Gacha permissions - PermissionsEnum::CreateGachaClaims => "Create Gacha Claims", - PermissionsEnum::ReadDetailGachaClaims => "Read Detail Gacha Claims", - PermissionsEnum::ReadListGachaItems => "Read List Gacha Items", - PermissionsEnum::ReadDetailGachaItems => "Read Detail Gacha Items", - PermissionsEnum::CreateGachaItems => "Create Gacha Items", - PermissionsEnum::DeleteGachaItems => "Delete Gacha Items", - PermissionsEnum::UpdateGachaItems => "Update Gacha Items", - PermissionsEnum::ReadDetailGachaRolls => "Read Detail Gacha Rolls", - PermissionsEnum::CreateGachaRolls => "Create Gacha Rolls", - PermissionsEnum::ExecuteGachaRolls => "Execute Gacha Rolls", - PermissionsEnum::DeleteGachaRolls => "Delete Gacha Rolls", - - // Mentor permissions - PermissionsEnum::ReadListMentors => "Read List Mentors", - PermissionsEnum::ReadDetailMentors => "Read Detail Mentors", - PermissionsEnum::RegisterMentors => "Register Mentors", - PermissionsEnum::ReadOwnMentorProfile => "Read Own Mentor Profile", - PermissionsEnum::UpdateOwnMentorProfile => "Update Own Mentor Profile", - PermissionsEnum::ReadOwnMentorStatus => "Read Own Mentor Status", - PermissionsEnum::UpdateMentors => "Update Mentors", - PermissionsEnum::VerifyMentors => "Verify Mentors", - PermissionsEnum::DeleteMentors => "Delete Mentors", - - // Administrator permissions - PermissionsEnum::ManageAllUsers => "Manage All Users", - PermissionsEnum::ManageAllRoles => "Manage All Roles", - PermissionsEnum::ManageAllPermissions => "Manage All Permissions", - PermissionsEnum::ViewAllSensitiveData => "View All Sensitive Data", - PermissionsEnum::AccessAdminDashboard => "Access Admin Dashboard", - PermissionsEnum::Administrator => "Administrator", - }; - write!(f, "{permission_str}") - } -} - -impl PermissionsEnum { - pub fn id(&self) -> String { - match self { - // User permissions - PermissionsEnum::ReadListUsers => "7c15e31d-36e2-49f9-97db-138c03fb0cf6".to_string(), - PermissionsEnum::ReadDetailUsers => "319ee593-ff0a-4f29-bbaf-9feb3174a3a6".to_string(), - PermissionsEnum::CreateUsers => "023e2dfe-93c3-4008-94a8-b5dff403f73b".to_string(), - PermissionsEnum::DeleteUsers => "96df0689-2ae9-4894-bf00-837c19415e5c".to_string(), - PermissionsEnum::UpdateUsers => "98b3dc4c-0124-461f-afcd-166637c5e6e8".to_string(), - PermissionsEnum::ActivateUsers => "4da8b434-89f9-4d91-85ae-eebd63cdbeda".to_string(), - - // Role permissions - PermissionsEnum::ReadListRoles => "9164ca6e-c7e3-4238-a15f-f36ab9577e7e".to_string(), - PermissionsEnum::ReadDetailRoles => "73888d18-b3e9-4f62-95a5-ba2c0d69fccb".to_string(), - PermissionsEnum::CreateRoles => "319ee593-ff0a-4f29-bbaf-9feb3174a3a2".to_string(), - PermissionsEnum::DeleteRoles => "35b0d992-65c8-4b62-b030-e6e0320e4048".to_string(), - PermissionsEnum::UpdateRoles => "a00d5608-4c48-4542-845c-dfe004687022".to_string(), - - // Permission permissions - PermissionsEnum::ReadListPermissions => "8195eeb8-e64f-4172-aa57-596492c84a72".to_string(), - PermissionsEnum::ReadDetailPermissions => "dad435cf-042c-41bd-a946-cea61ed2ffbc".to_string(), - PermissionsEnum::CreatePermissions => "0269ed71-0ae0-4c43-ad29-e3d861d8f9a0".to_string(), - PermissionsEnum::DeletePermissions => "b2dc3928-86ba-4c59-a03d-0b57d5183ebc".to_string(), - PermissionsEnum::UpdatePermissions => "299cb4d5-6556-4cc9-b6c1-32e6d31e0f9b".to_string(), - - // Gacha permissions - PermissionsEnum::CreateGachaClaims => "f41d53ce-4f88-4bb6-b9b4-5e3a8c38d962".to_string(), - PermissionsEnum::ReadDetailGachaClaims => "c1c3d6c2-19fb-4b70-b58c-c19f2e8cfc79".to_string(), - PermissionsEnum::ReadListGachaItems => "fa6eb842-0a61-40c2-9c24-b226ad975037".to_string(), - PermissionsEnum::ReadDetailGachaItems => "9c7857d7-b5ae-4688-923d-ef5572e9bc8b".to_string(), - PermissionsEnum::CreateGachaItems => "cf063be1-4d71-489e-b9fb-1c08c65f396c".to_string(), - PermissionsEnum::DeleteGachaItems => "46f8c6cf-ea0c-4c90-860c-69e2e65f7eb1".to_string(), - PermissionsEnum::UpdateGachaItems => "2d0cf4ae-56ae-4714-a12e-655cfc3d9eb2".to_string(), - PermissionsEnum::ReadDetailGachaRolls => "53d6483a-04cd-4667-8792-2d0cc8e2d343".to_string(), - PermissionsEnum::CreateGachaRolls => "18e36c63-fcb7-4877-b911-c5aa611e878f".to_string(), - PermissionsEnum::ExecuteGachaRolls => "14c6a1cd-5c63-4643-89b5-b1a5f9920cc0".to_string(), - PermissionsEnum::DeleteGachaRolls => "12345678-ABCD-EFAB-CDEF-0123456789AB".to_string(), - - // Mentor permissions - PermissionsEnum::ReadListMentors => "a1b2c3d4-5e6f-7890-abcd-ef1234567890".to_string(), - PermissionsEnum::ReadDetailMentors => "b2c3d4e5-6f78-9012-bcde-f23456789012".to_string(), - PermissionsEnum::RegisterMentors => "c3d4e5f6-7890-1234-cdef-345678901234".to_string(), - PermissionsEnum::ReadOwnMentorProfile => "d4e5f6a7-8901-2345-def0-456789012345".to_string(), - PermissionsEnum::UpdateOwnMentorProfile => "e5f6a7b8-9012-3456-ef01-567890123456".to_string(), - PermissionsEnum::ReadOwnMentorStatus => "f6a7b8c9-0123-4567-f012-678901234567".to_string(), - PermissionsEnum::UpdateMentors => "a7b8c9d0-1234-5678-0123-789012345678".to_string(), - PermissionsEnum::VerifyMentors => "b8c9d0e1-2345-6789-1234-890123456789".to_string(), - PermissionsEnum::DeleteMentors => "c9d0e1f2-3456-7890-2345-901234567890".to_string(), - - // Administrator permissions - PermissionsEnum::ManageAllUsers => "d0e1f2a3-4567-8901-2345-0123456789ab".to_string(), - PermissionsEnum::ManageAllRoles => "e1f2a3b4-5678-9012-3456-1234567890ab".to_string(), - PermissionsEnum::ManageAllPermissions => "f2a3b4c5-6789-0123-4567-2345678901ab".to_string(), - PermissionsEnum::ViewAllSensitiveData => "b4c5d6e7-8901-2345-6789-4567890123ab".to_string(), - PermissionsEnum::AccessAdminDashboard => "c5d6e7f8-9012-3456-7890-5678901234ab".to_string(), - PermissionsEnum::Administrator => "d6e7f8a9-0123-4567-8901-6789012345ab".to_string(), - } - } - - /// Generate a new unique ID for a permission - pub fn generate_id() -> String { - Uuid::new_v4().to_string() - } - - /// Get all permissions as a vector - pub fn all() -> Vec { - vec![ - // User permissions - PermissionsEnum::ReadListUsers, - PermissionsEnum::ReadDetailUsers, - PermissionsEnum::CreateUsers, - PermissionsEnum::DeleteUsers, - PermissionsEnum::UpdateUsers, - PermissionsEnum::ActivateUsers, - - // Role permissions - PermissionsEnum::ReadListRoles, - PermissionsEnum::ReadDetailRoles, - PermissionsEnum::CreateRoles, - PermissionsEnum::DeleteRoles, - PermissionsEnum::UpdateRoles, - - // Permission permissions - PermissionsEnum::ReadListPermissions, - PermissionsEnum::ReadDetailPermissions, - PermissionsEnum::CreatePermissions, - PermissionsEnum::DeletePermissions, - PermissionsEnum::UpdatePermissions, - - // Gacha permissions - PermissionsEnum::CreateGachaClaims, - PermissionsEnum::ReadDetailGachaClaims, - PermissionsEnum::ReadListGachaItems, - PermissionsEnum::ReadDetailGachaItems, - PermissionsEnum::CreateGachaItems, - PermissionsEnum::DeleteGachaItems, - PermissionsEnum::UpdateGachaItems, - PermissionsEnum::ReadDetailGachaRolls, - PermissionsEnum::CreateGachaRolls, - PermissionsEnum::ExecuteGachaRolls, - PermissionsEnum::DeleteGachaRolls, - - // Mentor permissions - PermissionsEnum::ReadListMentors, - PermissionsEnum::ReadDetailMentors, - PermissionsEnum::RegisterMentors, - PermissionsEnum::ReadOwnMentorProfile, - PermissionsEnum::UpdateOwnMentorProfile, - PermissionsEnum::ReadOwnMentorStatus, - PermissionsEnum::UpdateMentors, - PermissionsEnum::VerifyMentors, - PermissionsEnum::DeleteMentors, - - // Administrator permissions - PermissionsEnum::ManageAllUsers, - PermissionsEnum::ManageAllRoles, - PermissionsEnum::ManageAllPermissions, - PermissionsEnum::ViewAllSensitiveData, - PermissionsEnum::AccessAdminDashboard, - PermissionsEnum::Administrator, - ] - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct PermissionsItemDto { - pub id: String, - pub name: String, - pub created_at: Option, - pub updated_at: Option, -} - -impl PermissionsItemDto { - pub fn from(dto: &PermissionsQueryDto) -> Self { - Self { - id: dto.id.clone().unwrap_or_default(), - name: dto.name.clone().unwrap_or_default(), - created_at: dto.created_at.clone(), - updated_at: dto.updated_at.clone(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PermissionsQueryDto { - pub id: Option, - pub name: Option, - pub created_at: Option, - pub updated_at: Option, -} \ No newline at end of file diff --git a/imphnen-entities/src/permissions/definitions.rs b/imphnen-entities/src/permissions/definitions.rs new file mode 100644 index 0000000..a17ed52 --- /dev/null +++ b/imphnen-entities/src/permissions/definitions.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, strum::EnumIter)] +pub enum PermissionsEnum { + ReadListUsers, + ReadDetailUsers, + CreateUsers, + DeleteUsers, + UpdateUsers, + ActivateUsers, + + ReadListRoles, + ReadDetailRoles, + CreateRoles, + DeleteRoles, + UpdateRoles, + + ReadListPermissions, + ReadDetailPermissions, + CreatePermissions, + DeletePermissions, + UpdatePermissions, + + ManageAllUsers, + ManageAllRoles, + ManageAllPermissions, + ViewAllSensitiveData, + AccessAdminDashboard, + Administrator, + + CreateGachaClaims, + ReadDetailGachaClaims, + ReadListGachaItems, + ReadDetailGachaItems, + CreateGachaItems, + DeleteGachaItems, + UpdateGachaItems, + ReadDetailGachaRolls, + CreateGachaRolls, + ExecuteGachaRolls, + DeleteGachaRolls, + + ReadListMentors, + ReadDetailMentors, + RegisterMentors, + ReadOwnMentorProfile, + UpdateOwnMentorProfile, + ReadOwnMentorStatus, + UpdateMentors, + VerifyMentors, + DeleteMentors, +} + +impl fmt::Display for PermissionsEnum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let permission_str = match self { + PermissionsEnum::ReadListUsers => "Read List Users", + PermissionsEnum::ReadDetailUsers => "Read Detail Users", + PermissionsEnum::CreateUsers => "Create Users", + PermissionsEnum::DeleteUsers => "Delete Users", + PermissionsEnum::UpdateUsers => "Update Users", + PermissionsEnum::ActivateUsers => "Activate Users", + + PermissionsEnum::ReadListRoles => "Read List Roles", + PermissionsEnum::ReadDetailRoles => "Read Detail Roles", + PermissionsEnum::CreateRoles => "Create Roles", + PermissionsEnum::DeleteRoles => "Delete Roles", + PermissionsEnum::UpdateRoles => "Update Roles", + + PermissionsEnum::ReadListPermissions => "Read List Permissions", + PermissionsEnum::ReadDetailPermissions => "Read Detail Permissions", + PermissionsEnum::CreatePermissions => "Create Permissions", + PermissionsEnum::DeletePermissions => "Delete Permissions", + PermissionsEnum::UpdatePermissions => "Update Permissions", + + PermissionsEnum::CreateGachaClaims => "Create Gacha Claims", + PermissionsEnum::ReadDetailGachaClaims => "Read Detail Gacha Claims", + PermissionsEnum::ReadListGachaItems => "Read List Gacha Items", + PermissionsEnum::ReadDetailGachaItems => "Read Detail Gacha Items", + PermissionsEnum::CreateGachaItems => "Create Gacha Items", + PermissionsEnum::DeleteGachaItems => "Delete Gacha Items", + PermissionsEnum::UpdateGachaItems => "Update Gacha Items", + PermissionsEnum::ReadDetailGachaRolls => "Read Detail Gacha Rolls", + PermissionsEnum::CreateGachaRolls => "Create Gacha Rolls", + PermissionsEnum::ExecuteGachaRolls => "Execute Gacha Rolls", + PermissionsEnum::DeleteGachaRolls => "Delete Gacha Rolls", + + PermissionsEnum::ReadListMentors => "Read List Mentors", + PermissionsEnum::ReadDetailMentors => "Read Detail Mentors", + PermissionsEnum::RegisterMentors => "Register Mentors", + PermissionsEnum::ReadOwnMentorProfile => "Read Own Mentor Profile", + PermissionsEnum::UpdateOwnMentorProfile => "Update Own Mentor Profile", + PermissionsEnum::ReadOwnMentorStatus => "Read Own Mentor Status", + PermissionsEnum::UpdateMentors => "Update Mentors", + PermissionsEnum::VerifyMentors => "Verify Mentors", + PermissionsEnum::DeleteMentors => "Delete Mentors", + + PermissionsEnum::ManageAllUsers => "Manage All Users", + PermissionsEnum::ManageAllRoles => "Manage All Roles", + PermissionsEnum::ManageAllPermissions => "Manage All Permissions", + PermissionsEnum::ViewAllSensitiveData => "View All Sensitive Data", + PermissionsEnum::AccessAdminDashboard => "Access Admin Dashboard", + PermissionsEnum::Administrator => "Administrator", + }; + write!(f, "{permission_str}") + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct PermissionsItemDto { + pub id: String, + pub name: String, + pub created_at: Option, + pub updated_at: Option, +} + +impl PermissionsItemDto { + pub fn from(dto: &PermissionsQueryDto) -> Self { + Self { + id: dto.id.clone().unwrap_or_default(), + name: dto.name.clone().unwrap_or_default(), + created_at: dto.created_at.clone(), + updated_at: dto.updated_at.clone(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermissionsQueryDto { + pub id: Option, + pub name: Option, + pub created_at: Option, + pub updated_at: Option, +} + +impl PermissionsEnum { + pub fn generate_id() -> String { + Uuid::new_v4().to_string() + } +} diff --git a/imphnen-entities/src/permissions/mappings.rs b/imphnen-entities/src/permissions/mappings.rs new file mode 100644 index 0000000..99ac997 --- /dev/null +++ b/imphnen-entities/src/permissions/mappings.rs @@ -0,0 +1,186 @@ +use super::definitions::PermissionsEnum; + +impl PermissionsEnum { + pub fn id(&self) -> String { + match self { + PermissionsEnum::ReadListUsers => { + "7c15e31d-36e2-49f9-97db-138c03fb0cf6".to_string() + } + PermissionsEnum::ReadDetailUsers => { + "319ee593-ff0a-4f29-bbaf-9feb3174a3a6".to_string() + } + PermissionsEnum::CreateUsers => { + "023e2dfe-93c3-4008-94a8-b5dff403f73b".to_string() + } + PermissionsEnum::DeleteUsers => { + "96df0689-2ae9-4894-bf00-837c19415e5c".to_string() + } + PermissionsEnum::UpdateUsers => { + "98b3dc4c-0124-461f-afcd-166637c5e6e8".to_string() + } + PermissionsEnum::ActivateUsers => { + "4da8b434-89f9-4d91-85ae-eebd63cdbeda".to_string() + } + + PermissionsEnum::ReadListRoles => { + "9164ca6e-c7e3-4238-a15f-f36ab9577e7e".to_string() + } + PermissionsEnum::ReadDetailRoles => { + "73888d18-b3e9-4f62-95a5-ba2c0d69fccb".to_string() + } + PermissionsEnum::CreateRoles => { + "319ee593-ff0a-4f29-bbaf-9feb3174a3a2".to_string() + } + PermissionsEnum::DeleteRoles => { + "35b0d992-65c8-4b62-b030-e6e0320e4048".to_string() + } + PermissionsEnum::UpdateRoles => { + "a00d5608-4c48-4542-845c-dfe004687022".to_string() + } + + PermissionsEnum::ReadListPermissions => { + "8195eeb8-e64f-4172-aa57-596492c84a72".to_string() + } + PermissionsEnum::ReadDetailPermissions => { + "dad435cf-042c-41bd-a946-cea61ed2ffbc".to_string() + } + PermissionsEnum::CreatePermissions => { + "0269ed71-0ae0-4c43-ad29-e3d861d8f9a0".to_string() + } + PermissionsEnum::DeletePermissions => { + "b2dc3928-86ba-4c59-a03d-0b57d5183ebc".to_string() + } + PermissionsEnum::UpdatePermissions => { + "299cb4d5-6556-4cc9-b6c1-32e6d31e0f9b".to_string() + } + + PermissionsEnum::CreateGachaClaims => { + "f41d53ce-4f88-4bb6-b9b4-5e3a8c38d962".to_string() + } + PermissionsEnum::ReadDetailGachaClaims => { + "c1c3d6c2-19fb-4b70-b58c-c19f2e8cfc79".to_string() + } + PermissionsEnum::ReadListGachaItems => { + "fa6eb842-0a61-40c2-9c24-b226ad975037".to_string() + } + PermissionsEnum::ReadDetailGachaItems => { + "9c7857d7-b5ae-4688-923d-ef5572e9bc8b".to_string() + } + PermissionsEnum::CreateGachaItems => { + "cf063be1-4d71-489e-b9fb-1c08c65f396c".to_string() + } + PermissionsEnum::DeleteGachaItems => { + "46f8c6cf-ea0c-4c90-860c-69e2e65f7eb1".to_string() + } + PermissionsEnum::UpdateGachaItems => { + "2d0cf4ae-56ae-4714-a12e-655cfc3d9eb2".to_string() + } + PermissionsEnum::ReadDetailGachaRolls => { + "53d6483a-04cd-4667-8792-2d0cc8e2d343".to_string() + } + PermissionsEnum::CreateGachaRolls => { + "18e36c63-fcb7-4877-b911-c5aa611e878f".to_string() + } + PermissionsEnum::ExecuteGachaRolls => { + "14c6a1cd-5c63-4643-89b5-b1a5f9920cc0".to_string() + } + PermissionsEnum::DeleteGachaRolls => { + "12345678-ABCD-EFAB-CDEF-0123456789AB".to_string() + } + + PermissionsEnum::ReadListMentors => { + "a1b2c3d4-5e6f-7890-abcd-ef1234567890".to_string() + } + PermissionsEnum::ReadDetailMentors => { + "b2c3d4e5-6f78-9012-bcde-f23456789012".to_string() + } + PermissionsEnum::RegisterMentors => { + "c3d4e5f6-7890-1234-cdef-345678901234".to_string() + } + PermissionsEnum::ReadOwnMentorProfile => { + "d4e5f6a7-8901-2345-def0-456789012345".to_string() + } + PermissionsEnum::UpdateOwnMentorProfile => { + "e5f6a7b8-9012-3456-ef01-567890123456".to_string() + } + PermissionsEnum::ReadOwnMentorStatus => { + "f6a7b8c9-0123-4567-f012-678901234567".to_string() + } + PermissionsEnum::UpdateMentors => { + "a7b8c9d0-1234-5678-0123-789012345678".to_string() + } + PermissionsEnum::VerifyMentors => { + "b8c9d0e1-2345-6789-1234-890123456789".to_string() + } + PermissionsEnum::DeleteMentors => { + "c9d0e1f2-3456-7890-2345-901234567890".to_string() + } + + PermissionsEnum::ManageAllUsers => { + "d0e1f2a3-4567-8901-2345-0123456789ab".to_string() + } + PermissionsEnum::ManageAllRoles => { + "e1f2a3b4-5678-9012-3456-1234567890ab".to_string() + } + PermissionsEnum::ManageAllPermissions => { + "f2a3b4c5-6789-0123-4567-2345678901ab".to_string() + } + PermissionsEnum::ViewAllSensitiveData => { + "b4c5d6e7-8901-2345-6789-4567890123ab".to_string() + } + PermissionsEnum::AccessAdminDashboard => { + "c5d6e7f8-9012-3456-7890-5678901234ab".to_string() + } + PermissionsEnum::Administrator => { + "d6e7f8a9-0123-4567-8901-6789012345ab".to_string() + } + } + } + + pub fn all() -> Vec { + 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::DeleteGachaRolls, + PermissionsEnum::ReadListMentors, + PermissionsEnum::ReadDetailMentors, + PermissionsEnum::RegisterMentors, + PermissionsEnum::ReadOwnMentorProfile, + PermissionsEnum::UpdateOwnMentorProfile, + PermissionsEnum::ReadOwnMentorStatus, + PermissionsEnum::UpdateMentors, + PermissionsEnum::VerifyMentors, + PermissionsEnum::DeleteMentors, + PermissionsEnum::ManageAllUsers, + PermissionsEnum::ManageAllRoles, + PermissionsEnum::ManageAllPermissions, + PermissionsEnum::ViewAllSensitiveData, + PermissionsEnum::AccessAdminDashboard, + PermissionsEnum::Administrator, + ] + } +} diff --git a/imphnen-entities/src/permissions/mod.rs b/imphnen-entities/src/permissions/mod.rs new file mode 100644 index 0000000..2743bd1 --- /dev/null +++ b/imphnen-entities/src/permissions/mod.rs @@ -0,0 +1,4 @@ +pub mod definitions; +pub mod mappings; + +pub use definitions::{PermissionsEnum, PermissionsItemDto, PermissionsQueryDto}; diff --git a/imphnen-entities/src/seaorm/auth/mentors.rs b/imphnen-entities/src/seaorm/auth/mentors.rs index 703d2a1..e4b1f47 100644 --- a/imphnen-entities/src/seaorm/auth/mentors.rs +++ b/imphnen-entities/src/seaorm/auth/mentors.rs @@ -1,298 +1,198 @@ -//! SeaORM entity for Mentors table -//! Corresponding to ResourceEnum::Mentors - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, imphnen_macros::Builder)] -#[sea_orm(table_name = "app_mentors")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(unique, not_null)] - pub user_id: Uuid, - - #[sea_orm(type = "jsonb", nullable)] - pub industries: Option, - - #[sea_orm(type = "jsonb", nullable)] - pub expertise: Option, - - #[sea_orm(type = "jsonb", nullable)] - pub languages: Option, - - #[sea_orm(nullable)] - pub current_company: Option, - - #[sea_orm(nullable)] - pub current_role: Option, - - #[sea_orm(nullable)] - pub years_of_experience: Option, - - #[sea_orm(type = "jsonb", nullable)] - pub topics_of_interest: Option, - - #[sea_orm(nullable)] - pub preferred_mentee_level: Option, - - #[sea_orm(type = "jsonb", nullable)] - pub preferred_mentoring_formats: Option, - - #[sea_orm(nullable)] - pub availability_commitment: Option, - - #[sea_orm(nullable)] - pub mentoring_rate: Option, - - #[sea_orm(nullable)] - pub status: Option, - - #[sea_orm(default = "false")] - pub is_deleted: bool, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(belongs_to = "super::users::Entity", from = "Column::UserId", to = "super::users::Column::Id")] - User, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for Mentor creation -#[derive(Default, Serialize, Deserialize)] -pub struct MentorBuilder { - user_id: Option, - industries: Option>, - expertise: Option>, - languages: Option>, - current_company: Option, - current_role: Option, - years_of_experience: Option, - topics_of_interest: Option>, - preferred_mentee_level: Option, - preferred_mentoring_formats: Option>, - availability_commitment: Option, - mentoring_rate: Option, - status: Option, - is_deleted: Option, -} - -impl MentorBuilder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn user_id(mut self, user_id: Uuid) -> Self { - self.user_id = Some(user_id); - self - } - - #[must_use] - pub fn industries(mut self, industries: Vec) -> Self { - self.industries = Some(industries); - self - } - - #[must_use] - pub fn expertise(mut self, expertise: Vec) -> Self { - self.expertise = Some(expertise); - self - } - - #[must_use] - pub fn languages(mut self, languages: Vec) -> Self { - self.languages = Some(languages); - self - } - - #[must_use] - pub fn current_company(mut self, current_company: String) -> Self { - self.current_company = Some(current_company); - self - } - - #[must_use] - pub fn current_role(mut self, current_role: String) -> Self { - self.current_role = Some(current_role); - self - } - - #[must_use] - pub fn years_of_experience(mut self, years_of_experience: i32) -> Self { - self.years_of_experience = Some(years_of_experience); - self - } - - #[must_use] - pub fn topics_of_interest(mut self, topics_of_interest: Vec) -> Self { - self.topics_of_interest = Some(topics_of_interest); - self - } - - #[must_use] - pub fn preferred_mentee_level(mut self, preferred_mentee_level: String) -> Self { - self.preferred_mentee_level = Some(preferred_mentee_level); - self - } - - #[must_use] - pub fn preferred_mentoring_formats(mut self, preferred_mentoring_formats: Vec) -> Self { - self.preferred_mentoring_formats = Some(preferred_mentoring_formats); - self - } - - #[must_use] - pub fn availability_commitment(mut self, availability_commitment: String) -> Self { - self.availability_commitment = Some(availability_commitment); - self - } - - #[must_use] - pub fn mentoring_rate(mut self, mentoring_rate: f64) -> Self { - self.mentoring_rate = Some(mentoring_rate); - self - } - - #[must_use] - pub fn status(mut self, status: String) -> Self { - self.status = Some(status); - self - } - - #[must_use] - pub fn is_deleted(mut self, is_deleted: bool) -> Self { - self.is_deleted = Some(is_deleted); - self - } - - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let Some(user_id) = self.user_id { - active_model.user_id = Set(user_id); - } else { - return Err("User ID is required".to_string()); - } - - if let Some(industries) = self.industries { - active_model.industries = Set(Some(serde_json::to_value(industries).map_err(|e| format!("Failed to serialize industries: {}", e))?)); - } - - if let Some(expertise) = self.expertise { - active_model.expertise = Set(Some(serde_json::to_value(expertise).map_err(|e| format!("Failed to serialize expertise: {}", e))?)); - } - - if let Some(languages) = self.languages { - active_model.languages = Set(Some(serde_json::to_value(languages).map_err(|e| format!("Failed to serialize languages: {}", e))?)); - } - - if let Some(current_company) = self.current_company { - active_model.current_company = Set(Some(current_company)); - } - - if let Some(current_role) = self.current_role { - active_model.current_role = Set(Some(current_role)); - } - - if let Some(years_of_experience) = self.years_of_experience { - active_model.years_of_experience = Set(Some(years_of_experience)); - } - - if let Some(topics_of_interest) = self.topics_of_interest { - active_model.topics_of_interest = Set(Some(serde_json::to_value(topics_of_interest).map_err(|e| format!("Failed to serialize topics_of_interest: {}", e))?)); - } - - if let Some(preferred_mentee_level) = self.preferred_mentee_level { - active_model.preferred_mentee_level = Set(Some(preferred_mentee_level)); - } - - if let Some(preferred_mentoring_formats) = self.preferred_mentoring_formats { - active_model.preferred_mentoring_formats = Set(Some(serde_json::to_value(preferred_mentoring_formats).map_err(|e| format!("Failed to serialize preferred_mentoring_formats: {}", e))?)); - } - - if let Some(availability_commitment) = self.availability_commitment { - active_model.availability_commitment = Set(Some(availability_commitment)); - } - - if let Some(mentoring_rate) = self.mentoring_rate { - active_model.mentoring_rate = Set(Some(mentoring_rate)); - } - - if let Some(status) = self.status { - active_model.status = Set(Some(status)); - } - - if let Some(is_deleted) = self.is_deleted { - active_model.is_deleted = Set(is_deleted); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::seaorm::common::utils::generate_uuid; - use serde_json::json; - - #[test] - fn test_mentor_model_creation() { - let uid = generate_uuid(); - let mentor = MentorBuilder::new() - .user_id(uid) - .industries(vec!["Technology".to_string(), "Finance".to_string()]) - .expertise(vec!["Blockchain".to_string(), "AI".to_string()]) - .languages(vec!["English".to_string(), "Spanish".to_string()]) - .current_company("Tech Corp".to_string()) - .current_role("Senior Engineer".to_string()) - .years_of_experience(10) - .topics_of_interest(vec!["Web3".to_string(), "Machine Learning".to_string()]) - .preferred_mentee_level("Intermediate".to_string()) - .preferred_mentoring_formats(vec!["1:1".to_string(), "Group".to_string()]) - .availability_commitment("Weekly".to_string()) - .mentoring_rate(150.0) - .status("active".to_string()) - .build(); - - assert!(mentor.is_ok()); - let mentor_model = mentor.unwrap(); - assert_eq!(mentor_model.user_id, Set(uid)); - assert_eq!(mentor_model.industries, Set(Some(json!(["Technology", "Finance"])))); - assert_eq!(mentor_model.expertise, Set(Some(json!(["Blockchain", "AI"])))); - } - - #[test] - fn test_mentor_model_missing_required_fields() { - let mentor = MentorBuilder::new() - // Missing user_id - .industries(vec!["Technology".to_string()]) - .build(); - - assert!(mentor.is_err()); - assert_eq!(mentor.unwrap_err(), "User ID is required"); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive( + Clone, + Debug, + PartialEq, + DeriveEntityModel, + Serialize, + Deserialize, + imphnen_macros::Builder, +)] +#[sea_orm(table_name = "app_mentors")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(unique, not_null)] + pub user_id: Uuid, + + #[sea_orm(type = "jsonb", nullable)] + pub industries: Option, + + #[sea_orm(type = "jsonb", nullable)] + pub expertise: Option, + + #[sea_orm(type = "jsonb", nullable)] + pub languages: Option, + + #[sea_orm(nullable)] + pub current_company: Option, + + #[sea_orm(nullable)] + pub current_role: Option, + + #[sea_orm(nullable)] + pub years_of_experience: Option, + + #[sea_orm(type = "jsonb", nullable)] + pub topics_of_interest: Option, + + #[sea_orm(nullable)] + pub preferred_mentee_level: Option, + + #[sea_orm(type = "jsonb", nullable)] + pub preferred_mentoring_formats: Option, + + #[sea_orm(nullable)] + pub availability_commitment: Option, + + #[sea_orm(nullable)] + pub mentoring_rate: Option, + + #[sea_orm(nullable)] + pub status: Option, + + #[sea_orm(default = "false")] + pub is_deleted: bool, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Default, Serialize, Deserialize)] +pub struct MentorBuilder { + pub user_id: Option, + pub industries: Option>, + pub expertise: Option>, + pub languages: Option>, + pub current_company: Option, + pub current_role: Option, + pub years_of_experience: Option, + pub topics_of_interest: Option>, + pub preferred_mentee_level: Option, + pub preferred_mentoring_formats: Option>, + pub availability_commitment: Option, + pub mentoring_rate: Option, + pub status: Option, + pub is_deleted: Option, +} + +impl MentorBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn user_id(mut self, user_id: Uuid) -> Self { + self.user_id = Some(user_id); + self + } + + #[must_use] + pub fn industries(mut self, industries: Vec) -> Self { + self.industries = Some(industries); + self + } + + #[must_use] + pub fn expertise(mut self, expertise: Vec) -> Self { + self.expertise = Some(expertise); + self + } + + #[must_use] + pub fn languages(mut self, languages: Vec) -> Self { + self.languages = Some(languages); + self + } + + #[must_use] + pub fn current_company(mut self, current_company: String) -> Self { + self.current_company = Some(current_company); + self + } + + #[must_use] + pub fn current_role(mut self, current_role: String) -> Self { + self.current_role = Some(current_role); + self + } + + #[must_use] + pub fn years_of_experience(mut self, years_of_experience: i32) -> Self { + self.years_of_experience = Some(years_of_experience); + self + } + + #[must_use] + pub fn topics_of_interest(mut self, topics_of_interest: Vec) -> Self { + self.topics_of_interest = Some(topics_of_interest); + self + } + + #[must_use] + pub fn preferred_mentee_level(mut self, preferred_mentee_level: String) -> Self { + self.preferred_mentee_level = Some(preferred_mentee_level); + self + } + + #[must_use] + pub fn preferred_mentoring_formats( + mut self, + preferred_mentoring_formats: Vec, + ) -> Self { + self.preferred_mentoring_formats = Some(preferred_mentoring_formats); + self + } + + #[must_use] + pub fn availability_commitment(mut self, availability_commitment: String) -> Self { + self.availability_commitment = Some(availability_commitment); + self + } + + #[must_use] + pub fn mentoring_rate(mut self, mentoring_rate: f64) -> Self { + self.mentoring_rate = Some(mentoring_rate); + self + } + + #[must_use] + pub fn status(mut self, status: String) -> Self { + self.status = Some(status); + self + } + + #[must_use] + pub fn is_deleted(mut self, is_deleted: bool) -> Self { + self.is_deleted = Some(is_deleted); + self + } +} diff --git a/imphnen-entities/src/seaorm/auth/mentors_queries.rs b/imphnen-entities/src/seaorm/auth/mentors_queries.rs new file mode 100644 index 0000000..daa8dd0 --- /dev/null +++ b/imphnen-entities/src/seaorm/auth/mentors_queries.rs @@ -0,0 +1,84 @@ +use super::mentors::{ActiveModel, MentorBuilder}; +use sea_orm::ActiveValue::Set; + +impl MentorBuilder { + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let Some(user_id) = self.user_id { + active_model.user_id = Set(user_id); + } else { + return Err("User ID is required".to_string()); + } + + if let Some(industries) = self.industries { + active_model.industries = + Set(Some(serde_json::to_value(industries).map_err(|e| { + format!("Failed to serialize industries: {}", e) + })?)); + } + + if let Some(expertise) = self.expertise { + active_model.expertise = + Set(Some(serde_json::to_value(expertise).map_err(|e| { + format!("Failed to serialize expertise: {}", e) + })?)); + } + + if let Some(languages) = self.languages { + active_model.languages = + Set(Some(serde_json::to_value(languages).map_err(|e| { + format!("Failed to serialize languages: {}", e) + })?)); + } + + if let Some(current_company) = self.current_company { + active_model.current_company = Set(Some(current_company)); + } + + if let Some(current_role) = self.current_role { + active_model.current_role = Set(Some(current_role)); + } + + if let Some(years_of_experience) = self.years_of_experience { + active_model.years_of_experience = Set(Some(years_of_experience)); + } + + if let Some(topics_of_interest) = self.topics_of_interest { + active_model.topics_of_interest = Set(Some( + serde_json::to_value(topics_of_interest) + .map_err(|e| format!("Failed to serialize topics_of_interest: {}", e))?, + )); + } + + if let Some(preferred_mentee_level) = self.preferred_mentee_level { + active_model.preferred_mentee_level = Set(Some(preferred_mentee_level)); + } + + if let Some(preferred_mentoring_formats) = self.preferred_mentoring_formats { + active_model.preferred_mentoring_formats = Set(Some( + serde_json::to_value(preferred_mentoring_formats).map_err(|e| { + format!("Failed to serialize preferred_mentoring_formats: {}", e) + })?, + )); + } + + if let Some(availability_commitment) = self.availability_commitment { + active_model.availability_commitment = Set(Some(availability_commitment)); + } + + if let Some(mentoring_rate) = self.mentoring_rate { + active_model.mentoring_rate = Set(Some(mentoring_rate)); + } + + if let Some(status) = self.status { + active_model.status = Set(Some(status)); + } + + if let Some(is_deleted) = self.is_deleted { + active_model.is_deleted = Set(is_deleted); + } + + Ok(active_model) + } +} diff --git a/imphnen-entities/src/seaorm/auth/mod.rs b/imphnen-entities/src/seaorm/auth/mod.rs index 0ac1e94..a654ede 100644 --- a/imphnen-entities/src/seaorm/auth/mod.rs +++ b/imphnen-entities/src/seaorm/auth/mod.rs @@ -1,7 +1,7 @@ -pub mod users; -pub mod roles; -pub mod permissions; -pub mod roles_permissions; -pub mod mentors; -pub mod sessions; - +pub mod mentors; +pub mod mentors_queries; +pub mod permissions; +pub mod roles; +pub mod roles_permissions; +pub mod sessions; +pub mod users; diff --git a/imphnen-entities/src/seaorm/auth/permissions.rs b/imphnen-entities/src/seaorm/auth/permissions.rs index 0466a09..003c431 100644 --- a/imphnen-entities/src/seaorm/auth/permissions.rs +++ b/imphnen-entities/src/seaorm/auth/permissions.rs @@ -1,60 +1,55 @@ -//! SeaORM entity for Permissions table -//! Corresponding to ResourceEnum::Permissions -//! Represents system permissions - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "app_permissions")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(not_null)] - pub name: String, - - #[sea_orm(not_null, default = "false")] - pub is_deleted: bool, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::roles_permissions::Entity")] - RolesPermissions, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::RolesPermissions.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} - -impl Entity { - pub fn find_by_id(id: Uuid) -> Select { - Self::find().filter(Column::Id.eq(id)) - } - - pub fn find_by_name(name: &str) -> Select { - Self::find().filter(Column::Name.eq(name)) - } - - pub fn find_active() -> Select { - Self::find().filter(Column::IsDeleted.eq(false)) - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "app_permissions")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null)] + pub name: String, + + #[sea_orm(not_null, default = "false")] + pub is_deleted: bool, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::roles_permissions::Entity")] + RolesPermissions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolesPermissions.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +impl Entity { + pub fn find_by_id(id: Uuid) -> Select { + Self::find().filter(Column::Id.eq(id)) + } + + pub fn find_by_name(name: &str) -> Select { + Self::find().filter(Column::Name.eq(name)) + } + + pub fn find_active() -> Select { + Self::find().filter(Column::IsDeleted.eq(false)) + } +} diff --git a/imphnen-entities/src/seaorm/auth/roles.rs b/imphnen-entities/src/seaorm/auth/roles.rs index 70e755f..edf9df4 100644 --- a/imphnen-entities/src/seaorm/auth/roles.rs +++ b/imphnen-entities/src/seaorm/auth/roles.rs @@ -1,150 +1,148 @@ -//! SeaORM entity for Roles table -//! Corresponding to ResourceEnum::Roles - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "app_roles")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(unique, not_null)] - pub name: String, - - #[sea_orm(not_null)] - pub description: String, - - #[sea_orm(default = "false")] - pub is_system_role: bool, - - #[sea_orm(default = "false")] - pub is_default: bool, - - #[sea_orm(type = "jsonb", nullable)] - pub permissions: Option, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for Role creation -#[derive(Default, Serialize, Deserialize)] -pub struct RoleBuilder { - name: Option, - description: Option, - is_system_role: Option, - is_default: Option, - permissions: Option>, -} - -impl RoleBuilder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn name(mut self, name: String) -> Self { - self.name = Some(name); - self - } - - #[must_use] - pub fn description(mut self, description: String) -> Self { - self.description = Some(description); - self - } - - #[must_use] - pub fn is_system_role(mut self, is_system_role: bool) -> Self { - self.is_system_role = Some(is_system_role); - self - } - - #[must_use] - pub fn is_default(mut self, is_default: bool) -> Self { - self.is_default = Some(is_default); - self - } - - #[must_use] - pub fn permissions(mut self, permissions: Vec) -> Self { - self.permissions = Some(permissions); - self - } - - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let Some(name) = self.name { - active_model.name = Set(name); - } else { - return Err("Role name is required".to_string()); - } - - if let Some(description) = self.description { - active_model.description = Set(description); - } else { - return Err("Role description is required".to_string()); - } - - if let Some(is_system_role) = self.is_system_role { - active_model.is_system_role = Set(is_system_role); - } - - if let Some(is_default) = self.is_default { - active_model.is_default = Set(is_default); - } - - if let Some(permissions) = self.permissions { - active_model.permissions = Set(Some(serde_json::Value::Array( - permissions.into_iter().map(serde_json::Value::String).collect() - ))); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_role_model_creation() { - let role = RoleBuilder::new() - .name("admin".to_string()) - .description("Administrator role".to_string()) - .is_system_role(true) - .is_default(false) - .build(); - - assert!(role.is_ok()); - let role_model = role.unwrap(); - assert_eq!(role_model.name, Set("admin".to_string())); - assert_eq!(role_model.description, Set("Administrator role".to_string())); - assert_eq!(role_model.is_system_role, Set(true)); - assert_eq!(role_model.is_default, Set(false)); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "app_roles")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(unique, not_null)] + pub name: String, + + #[sea_orm(not_null)] + pub description: String, + + #[sea_orm(default = "false")] + pub is_system_role: bool, + + #[sea_orm(default = "false")] + pub is_default: bool, + + #[sea_orm(type = "jsonb", nullable)] + pub permissions: Option, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Default, Serialize, Deserialize)] +pub struct RoleBuilder { + name: Option, + description: Option, + is_system_role: Option, + is_default: Option, + permissions: Option>, +} + +impl RoleBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + #[must_use] + pub fn description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + #[must_use] + pub fn is_system_role(mut self, is_system_role: bool) -> Self { + self.is_system_role = Some(is_system_role); + self + } + + #[must_use] + pub fn is_default(mut self, is_default: bool) -> Self { + self.is_default = Some(is_default); + self + } + + #[must_use] + pub fn permissions(mut self, permissions: Vec) -> Self { + self.permissions = Some(permissions); + self + } + + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let Some(name) = self.name { + active_model.name = Set(name); + } else { + return Err("Role name is required".to_string()); + } + + if let Some(description) = self.description { + active_model.description = Set(description); + } else { + return Err("Role description is required".to_string()); + } + + if let Some(is_system_role) = self.is_system_role { + active_model.is_system_role = Set(is_system_role); + } + + if let Some(is_default) = self.is_default { + active_model.is_default = Set(is_default); + } + + if let Some(permissions) = self.permissions { + active_model.permissions = Set(Some(serde_json::Value::Array( + permissions + .into_iter() + .map(serde_json::Value::String) + .collect(), + ))); + } + + Ok(active_model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_role_model_creation() { + let role = RoleBuilder::new() + .name("admin".to_string()) + .description("Administrator role".to_string()) + .is_system_role(true) + .is_default(false) + .build(); + + assert!(role.is_ok()); + let role_model = role.unwrap(); + assert_eq!(role_model.name, Set("admin".to_string())); + assert_eq!( + role_model.description, + Set("Administrator role".to_string()) + ); + assert_eq!(role_model.is_system_role, Set(true)); + assert_eq!(role_model.is_default, Set(false)); + } +} diff --git a/imphnen-entities/src/seaorm/auth/roles_permissions.rs b/imphnen-entities/src/seaorm/auth/roles_permissions.rs index 0b297a0..2562c04 100644 --- a/imphnen-entities/src/seaorm/auth/roles_permissions.rs +++ b/imphnen-entities/src/seaorm/auth/roles_permissions.rs @@ -1,159 +1,165 @@ -//! SeaORM entity for RolesPermissions table -//! Corresponding to ResourceEnum::RolesPermissions -//! Represents the many-to-many relationship between Users and Roles - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "app_roles_permissions")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(not_null)] - pub user_id: Uuid, - - #[sea_orm(not_null)] - pub role_id: Uuid, - - #[sea_orm(not_null)] - pub permission_id: Uuid, - - #[sea_orm(not_null, default = "now()")] - pub assigned_at: DateTime, - - #[sea_orm(not_null, default = "false")] - pub is_active: bool, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(belongs_to = "super::users::Entity", from = "Column::UserId", to = "super::users::Column::Id")] - User, - #[sea_orm(belongs_to = "super::roles::Entity", from = "Column::RoleId", to = "super::roles::Column::Id")] - Role, - #[sea_orm(belongs_to = "super::permissions::Entity", from = "Column::PermissionId", to = "super::permissions::Column::Id")] - Permission, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Role.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Permission.def() - } -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for RolePermission creation -#[derive(Default, Serialize, Deserialize)] -pub struct RolePermissionBuilder { - user_id: Option, - role_id: Option, - permission_id: Option, - is_active: Option, -} - -impl RolePermissionBuilder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn user_id(mut self, user_id: Uuid) -> Self { - self.user_id = Some(user_id); - self - } - - #[must_use] - pub fn role_id(mut self, role_id: Uuid) -> Self { - self.role_id = Some(role_id); - self - } - - #[must_use] - pub fn permission_id(mut self, permission_id: Uuid) -> Self { - self.permission_id = Some(permission_id); - self - } - - #[must_use] - pub fn is_active(mut self, is_active: bool) -> Self { - self.is_active = Some(is_active); - self - } - - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let (Some(user_id), Some(role_id), Some(permission_id)) = (self.user_id, self.role_id, self.permission_id) { - active_model.user_id = Set(user_id); - active_model.role_id = Set(role_id); - active_model.permission_id = Set(permission_id); - } else { - return Err("User ID, Role ID, and Permission ID are required".to_string()); - } - - if let Some(is_active) = self.is_active { - active_model.is_active = Set(is_active); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::seaorm::common::utils::generate_uuid; - - #[test] - fn test_role_permission_model_creation() { - let user_id = generate_uuid(); - let role_id = generate_uuid(); - let permission_id = generate_uuid(); - - let role_permission = RolePermissionBuilder::new() - .user_id(user_id) - .role_id(role_id) - .permission_id(permission_id) - .is_active(true) - .build(); - - assert!(role_permission.is_ok()); - let role_permission_model = role_permission.unwrap(); - assert_eq!(role_permission_model.user_id, Set(user_id)); - assert_eq!(role_permission_model.role_id, Set(role_id)); - assert_eq!(role_permission_model.permission_id, Set(permission_id)); - assert_eq!(role_permission_model.is_active, Set(true)); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "app_roles_permissions")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null)] + pub user_id: Uuid, + + #[sea_orm(not_null)] + pub role_id: Uuid, + + #[sea_orm(not_null)] + pub permission_id: Uuid, + + #[sea_orm(not_null, default = "now()")] + pub assigned_at: DateTime, + + #[sea_orm(not_null, default = "false")] + pub is_active: bool, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id" + )] + User, + #[sea_orm( + belongs_to = "super::roles::Entity", + from = "Column::RoleId", + to = "super::roles::Column::Id" + )] + Role, + #[sea_orm( + belongs_to = "super::permissions::Entity", + from = "Column::PermissionId", + to = "super::permissions::Column::Id" + )] + Permission, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Permission.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Default, Serialize, Deserialize)] +pub struct RolePermissionBuilder { + user_id: Option, + role_id: Option, + permission_id: Option, + is_active: Option, +} + +impl RolePermissionBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn user_id(mut self, user_id: Uuid) -> Self { + self.user_id = Some(user_id); + self + } + + #[must_use] + pub fn role_id(mut self, role_id: Uuid) -> Self { + self.role_id = Some(role_id); + self + } + + #[must_use] + pub fn permission_id(mut self, permission_id: Uuid) -> Self { + self.permission_id = Some(permission_id); + self + } + + #[must_use] + pub fn is_active(mut self, is_active: bool) -> Self { + self.is_active = Some(is_active); + self + } + + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let (Some(user_id), Some(role_id), Some(permission_id)) = + (self.user_id, self.role_id, self.permission_id) + { + active_model.user_id = Set(user_id); + active_model.role_id = Set(role_id); + active_model.permission_id = Set(permission_id); + } else { + return Err("User ID, Role ID, and Permission ID are required".to_string()); + } + + if let Some(is_active) = self.is_active { + active_model.is_active = Set(is_active); + } + + Ok(active_model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seaorm::common::utils::generate_uuid; + + #[test] + fn test_role_permission_model_creation() { + let user_id = generate_uuid(); + let role_id = generate_uuid(); + let permission_id = generate_uuid(); + + let role_permission = RolePermissionBuilder::new() + .user_id(user_id) + .role_id(role_id) + .permission_id(permission_id) + .is_active(true) + .build(); + + assert!(role_permission.is_ok()); + let role_permission_model = role_permission.unwrap(); + assert_eq!(role_permission_model.user_id, Set(user_id)); + assert_eq!(role_permission_model.role_id, Set(role_id)); + assert_eq!(role_permission_model.permission_id, Set(permission_id)); + assert_eq!(role_permission_model.is_active, Set(true)); + } +} diff --git a/imphnen-entities/src/seaorm/auth/sessions.rs b/imphnen-entities/src/seaorm/auth/sessions.rs index c82d5de..522a2db 100644 --- a/imphnen-entities/src/seaorm/auth/sessions.rs +++ b/imphnen-entities/src/seaorm/auth/sessions.rs @@ -1,70 +1,70 @@ -use sea_orm::entity::prelude::*; -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "sessions")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(column_type = "Uuid")] - pub mentor_id: Uuid, - - #[sea_orm(column_type = "Uuid")] - pub mentee_id: Uuid, - - pub topic: String, - - #[sea_orm(nullable)] - pub description: Option, - - pub scheduled_at: DateTime, - - pub duration_minutes: i32, - - #[sea_orm(nullable)] - pub meeting_link: Option, - - pub session_type: String, // "video_call", "phone_call", "chat" - - pub status: String, // "pending", "confirmed", "completed", "cancelled", "no_show" - - #[sea_orm(nullable)] - pub feedback: Option, - - #[sea_orm(nullable)] - pub rating: Option, // 1-5 - - #[sea_orm(nullable)] - pub feedback_submitted_at: Option>, - - pub created_at: DateTime, - - pub updated_at: DateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::MentorId", - to = "super::users::Column::Id" - )] - Mentor, - - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::MenteeId", - to = "super::users::Column::Id" - )] - Mentee, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Mentor.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "sessions")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(column_type = "Uuid")] + pub mentor_id: Uuid, + + #[sea_orm(column_type = "Uuid")] + pub mentee_id: Uuid, + + pub topic: String, + + #[sea_orm(nullable)] + pub description: Option, + + pub scheduled_at: DateTime, + + pub duration_minutes: i32, + + #[sea_orm(nullable)] + pub meeting_link: Option, + + pub session_type: String, // "video_call", "phone_call", "chat" + + pub status: String, // "pending", "confirmed", "completed", "cancelled", "no_show" + + #[sea_orm(nullable)] + pub feedback: Option, + + #[sea_orm(nullable)] + pub rating: Option, // 1-5 + + #[sea_orm(nullable)] + pub feedback_submitted_at: Option>, + + pub created_at: DateTime, + + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::MentorId", + to = "super::users::Column::Id" + )] + Mentor, + + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::MenteeId", + to = "super::users::Column::Id" + )] + Mentee, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Mentor.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/auth/users.rs b/imphnen-entities/src/seaorm/auth/users.rs index 59786ab..0ccc282 100644 --- a/imphnen-entities/src/seaorm/auth/users.rs +++ b/imphnen-entities/src/seaorm/auth/users.rs @@ -1,178 +1,180 @@ -//! SeaORM entity for Users table -//! Corresponding to ResourceEnum::Users - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, imphnen_macros::Builder)] -#[sea_orm(table_name = "app_users")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(unique, not_null)] - pub email: String, - - #[sea_orm(not_null)] - pub password_hash: String, - - #[sea_orm(not_null)] - pub username: String, - - #[sea_orm(column_name = "role_id", nullable)] - pub role_id: Option, - - #[sea_orm(nullable)] - pub first_name: Option, - - #[sea_orm(nullable)] - pub last_name: Option, - - #[sea_orm(nullable)] - pub avatar_url: Option, - - #[sea_orm(default = "false")] - pub is_verified: bool, - - #[sea_orm(default = "false")] - pub is_active: bool, - - - #[sea_orm(type = "jsonb", nullable)] - pub metadata: Option, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::roles_permissions::Entity")] - RolesPermissions, - #[sea_orm(belongs_to = "super::roles::Entity", from = "Column::RoleId", to = "super::roles::Column::Id")] - Role, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::RolesPermissions.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Role.def() - } -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for User creation -// Generated by #[derive(Builder)] -pub type UserBuilder = ModelBuilder; - -impl ModelBuilder { - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let Some(email) = self.email { - active_model.email = Set(email); - } else { - return Err("Email is required".to_string()); - } - - if let Some(password_hash) = self.password_hash { - active_model.password_hash = Set(password_hash); - } else { - return Err("Password hash is required".to_string()); - } - - if let Some(username) = self.username { - active_model.username = Set(username); - } else { - return Err("Username is required".to_string()); - } - - if let Some(role_id) = self.role_id { - active_model.role_id = Set(Some(role_id)); - } - - if let Some(first_name) = self.first_name { - active_model.first_name = Set(Some(first_name)); - } - - if let Some(last_name) = self.last_name { - active_model.last_name = Set(Some(last_name)); - } - - if let Some(avatar_url) = self.avatar_url { - active_model.avatar_url = Set(Some(avatar_url)); - } - - if let Some(is_verified) = self.is_verified { - active_model.is_verified = Set(is_verified); - } - - if let Some(is_active) = self.is_active { - active_model.is_active = Set(is_active); - } - - if let Some(metadata) = self.metadata { - active_model.metadata = Set(Some(metadata)); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_user_model_creation() { - let user = UserBuilder::new() - .email("test@example.com".to_string()) - .password_hash("hashed_password".to_string()) - .username("testuser".to_string()) - .first_name("Test".to_string()) - .last_name("User".to_string()) - .is_verified(true) - .is_active(true) - .build(); - - assert!(user.is_ok()); - let user_model = user.unwrap(); - assert_eq!(user_model.email, Set("test@example.com".to_string())); - assert_eq!(user_model.password_hash, Set("hashed_password".to_string())); - assert_eq!(user_model.username, Set("testuser".to_string())); - assert_eq!(user_model.first_name, Set(Some("Test".to_string()))); - assert_eq!(user_model.last_name, Set(Some("User".to_string()))); - assert_eq!(user_model.is_verified, Set(true)); - assert_eq!(user_model.is_active, Set(true)); - } - - #[test] - fn test_user_model_missing_required_fields() { - let user = UserBuilder::new() - .email("test@example.com".to_string()) - // Missing password_hash - .username("testuser".to_string()) - .build(); - - assert!(user.is_err()); - assert_eq!(user.unwrap_err(), "Password hash is required"); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive( + Clone, + Debug, + PartialEq, + DeriveEntityModel, + Serialize, + Deserialize, + imphnen_macros::Builder, +)] +#[sea_orm(table_name = "app_users")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(unique, not_null)] + pub email: String, + + #[sea_orm(not_null)] + pub password_hash: String, + + #[sea_orm(not_null)] + pub username: String, + + #[sea_orm(column_name = "role_id", nullable)] + pub role_id: Option, + + #[sea_orm(nullable)] + pub first_name: Option, + + #[sea_orm(nullable)] + pub last_name: Option, + + #[sea_orm(nullable)] + pub avatar_url: Option, + + #[sea_orm(default = "false")] + pub is_verified: bool, + + #[sea_orm(default = "false")] + pub is_active: bool, + + #[sea_orm(type = "jsonb", nullable)] + pub metadata: Option, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::roles_permissions::Entity")] + RolesPermissions, + #[sea_orm( + belongs_to = "super::roles::Entity", + from = "Column::RoleId", + to = "super::roles::Column::Id" + )] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolesPermissions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +pub type UserBuilder = ModelBuilder; + +impl ModelBuilder { + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let Some(email) = self.email { + active_model.email = Set(email); + } else { + return Err("Email is required".to_string()); + } + + if let Some(password_hash) = self.password_hash { + active_model.password_hash = Set(password_hash); + } else { + return Err("Password hash is required".to_string()); + } + + if let Some(username) = self.username { + active_model.username = Set(username); + } else { + return Err("Username is required".to_string()); + } + + if let Some(role_id) = self.role_id { + active_model.role_id = Set(Some(role_id)); + } + + if let Some(first_name) = self.first_name { + active_model.first_name = Set(Some(first_name)); + } + + if let Some(last_name) = self.last_name { + active_model.last_name = Set(Some(last_name)); + } + + if let Some(avatar_url) = self.avatar_url { + active_model.avatar_url = Set(Some(avatar_url)); + } + + if let Some(is_verified) = self.is_verified { + active_model.is_verified = Set(is_verified); + } + + if let Some(is_active) = self.is_active { + active_model.is_active = Set(is_active); + } + + if let Some(metadata) = self.metadata { + active_model.metadata = Set(Some(metadata)); + } + + Ok(active_model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_model_creation() { + let user = UserBuilder::new() + .email("test@example.com".to_string()) + .password_hash("hashed_password".to_string()) + .username("testuser".to_string()) + .first_name("Test".to_string()) + .last_name("User".to_string()) + .is_verified(true) + .is_active(true) + .build(); + + assert!(user.is_ok()); + let user_model = user.unwrap(); + assert_eq!(user_model.email, Set("test@example.com".to_string())); + assert_eq!(user_model.password_hash, Set("hashed_password".to_string())); + assert_eq!(user_model.username, Set("testuser".to_string())); + assert_eq!(user_model.first_name, Set(Some("Test".to_string()))); + assert_eq!(user_model.last_name, Set(Some("User".to_string()))); + assert_eq!(user_model.is_verified, Set(true)); + assert_eq!(user_model.is_active, Set(true)); + } + + #[test] + fn test_user_model_missing_required_fields() { + let user = UserBuilder::new() + .email("test@example.com".to_string()) + .username("testuser".to_string()) + .build(); + + assert!(user.is_err()); + assert_eq!(user.unwrap_err(), "Password hash is required"); + } +} diff --git a/imphnen-entities/src/seaorm/common/audit_log.rs b/imphnen-entities/src/seaorm/common/audit_log.rs index 4c8511f..27ad78e 100644 --- a/imphnen-entities/src/seaorm/common/audit_log.rs +++ b/imphnen-entities/src/seaorm/common/audit_log.rs @@ -1,25 +1,23 @@ -//! SeaORM Entity for AuditLog - use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)] #[sea_orm(table_name = "app_audit_log")] pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - pub user_id: Uuid, - pub user_email: String, - pub action: String, - pub resource: String, - pub resource_id: Option, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub old_data: Option, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub new_data: Option, - pub ip_address: String, - pub user_agent: Option, - pub timestamp: DateTimeWithTimeZone, + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + pub user_email: String, + pub action: String, + pub resource: String, + pub resource_id: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub old_data: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub new_data: Option, + pub ip_address: String, + pub user_agent: Option, + pub timestamp: DateTimeWithTimeZone, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/imphnen-entities/src/seaorm/common/enum_impls.rs b/imphnen-entities/src/seaorm/common/enum_impls.rs new file mode 100644 index 0000000..a5ca726 --- /dev/null +++ b/imphnen-entities/src/seaorm/common/enum_impls.rs @@ -0,0 +1,8 @@ +use super::enums::ResourceEnum; +use std::fmt; + +impl fmt::Display for ResourceEnum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/imphnen-entities/src/seaorm/common/enums.rs b/imphnen-entities/src/seaorm/common/enums.rs index e8e640a..9455aea 100644 --- a/imphnen-entities/src/seaorm/common/enums.rs +++ b/imphnen-entities/src/seaorm/common/enums.rs @@ -1,206 +1,101 @@ -//! Enum definitions for SeaORM entities -//! Provides resource type enumerations matching SurrealDB ResourceEnum - -use std::fmt; -use serde::{Deserialize, Serialize}; - -use super::types::PgUuid; - -/// Database resource enumeration for SeaORM -/// Matches the SurrealDB ResourceEnum with PostgreSQL compatibility -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ResourceEnum { - /// OTP cache table for temporary authentication codes - OtpCache, - /// User cache table for user session data - UsersCache, - /// Gacha items table - GachaItems, - /// Gacha claims table for user item claims - GachaClaims, - /// Gacha rolls table for user roll history - GachaRolls, - /// Gacha credits table for user currency - GachaCredits, - /// Users table for user accounts - Users, - /// Roles table for user roles - Roles, - /// Permissions table for system permissions - Permissions, - /// Role-permission relationships table - RolesPermissions, - /// Events table for application events - Events, - /// Testimonials table for user testimonials - Testimonials, - /// Mentors table for mentor profiles - Mentors, - /// Notifications table for user notifications - Notifications, - /// Rate limiting table for IP-based rate limiting - RateLimit, - /// Audit log table for admin action tracking - AuditLog, - /// Sessions table for mentoring sessions - Sessions, - /// Migration status tracking table - MigrationStatus, -} - -impl fmt::Display for ResourceEnum { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let table_name = match self { - ResourceEnum::Users => "app_users", - ResourceEnum::UsersCache => "app_users_cache", - ResourceEnum::OtpCache => "app_otp_cache", - ResourceEnum::Roles => "app_roles", - ResourceEnum::Permissions => "app_permissions", - ResourceEnum::RolesPermissions => "app_roles_permissions", - ResourceEnum::GachaItems => "app_gacha_items", - ResourceEnum::GachaClaims => "app_gacha_claims", - ResourceEnum::GachaRolls => "app_gacha_rolls", - ResourceEnum::GachaCredits => "app_gacha_credits", - ResourceEnum::Events => "app_events", - ResourceEnum::Testimonials => "app_testimonials", - ResourceEnum::Mentors => "app_mentors", - ResourceEnum::Notifications => "app_notifications", - ResourceEnum::RateLimit => "app_rate_limit", - ResourceEnum::AuditLog => "app_audit_log", - ResourceEnum::Sessions => "app_sessions", - ResourceEnum::MigrationStatus => "app_migration_status", - }; - write!(f, "{}", table_name) - } -} - -impl ResourceEnum { - /// Get the table name as a string slice. - /// - /// # Returns - /// The PostgreSQL table name for this resource - pub fn as_str(&self) -> &'static str { - match self { - ResourceEnum::Users => "app_users", - ResourceEnum::UsersCache => "app_users_cache", - ResourceEnum::OtpCache => "app_otp_cache", - ResourceEnum::Roles => "app_roles", - ResourceEnum::Permissions => "app_permissions", - ResourceEnum::RolesPermissions => "app_roles_permissions", - ResourceEnum::GachaItems => "app_gacha_items", - ResourceEnum::GachaClaims => "app_gacha_claims", - ResourceEnum::GachaRolls => "app_gacha_rolls", - ResourceEnum::GachaCredits => "app_gacha_credits", - ResourceEnum::Events => "app_events", - ResourceEnum::Testimonials => "app_testimonials", - ResourceEnum::Mentors => "app_mentors", - ResourceEnum::Notifications => "app_notifications", - ResourceEnum::RateLimit => "app_rate_limit", - ResourceEnum::AuditLog => "app_audit_log", - ResourceEnum::Sessions => "app_sessions", - ResourceEnum::MigrationStatus => "app_migration_status", - } - } - - /// Get the schema name for the resource - /// - /// # Returns - /// The database schema name (usually "public" for PostgreSQL) - pub fn schema(&self) -> &'static str { - "public" - } - - /// Create a SeaORM entity name from the resource enum - /// - /// # Returns - /// A string suitable for use as a SeaORM entity name - pub fn to_entity_name(&self) -> String { - self.as_str().replace("app_", "").to_pascal_case() - } - - /// Check if this resource is cache-related. - /// - /// # Returns - /// true if the resource is used for caching, false otherwise - pub fn is_cache(&self) -> bool { - matches!(self, ResourceEnum::OtpCache | ResourceEnum::UsersCache) - } - - /// Check if this resource is gacha-related. - /// - /// # Returns - /// true if the resource is part of the gacha system, false otherwise - pub fn is_gacha(&self) -> bool { - matches!( - self, - ResourceEnum::GachaItems - | ResourceEnum::GachaClaims - | ResourceEnum::GachaRolls - | ResourceEnum::GachaCredits - ) - } - - /// Check if this resource is user-related. - /// - /// # Returns - /// true if the resource contains user data, false otherwise - pub fn is_user_related(&self) -> bool { - matches!( - self, - ResourceEnum::Users | ResourceEnum::UsersCache | ResourceEnum::Mentors - ) - } - - /// Generate a reference ID for the resource - /// - /// # Returns - /// A formatted string suitable for use as a reference ID - pub fn generate_ref_id(&self, uuid: &PgUuid) -> String { - format!("{}_{}", self.as_str().replace("app_", ""), uuid.0) - } -} - -// Helper trait for string case conversion -trait ToPascalCase { - fn to_pascal_case(&self) -> String; -} - -impl ToPascalCase for str { - fn to_pascal_case(&self) -> String { - self.split('_') - .map(|s| s.chars().next().unwrap().to_uppercase().to_string() + &s[1..]) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resource_enum_table_names() { - assert_eq!(ResourceEnum::Users.as_str(), "app_users"); - assert_eq!(ResourceEnum::Roles.as_str(), "app_roles"); - assert_eq!(ResourceEnum::GachaItems.as_str(), "app_gacha_items"); - } - - #[test] - fn test_resource_enum_display() { - assert_eq!(format!("{}", ResourceEnum::Users), "app_users"); - assert_eq!(format!("{}", ResourceEnum::RolesPermissions), "app_roles_permissions"); - } - - #[test] - fn test_resource_enum_categories() { - assert!(ResourceEnum::Users.is_user_related()); - assert!(ResourceEnum::GachaItems.is_gacha()); - assert!(ResourceEnum::OtpCache.is_cache()); - } - - #[test] - fn test_resource_enum_to_entity_name() { - assert_eq!(ResourceEnum::Users.to_entity_name(), "Users"); - assert_eq!(ResourceEnum::RolesPermissions.to_entity_name(), "RolesPermissions"); - assert_eq!(ResourceEnum::GachaItems.to_entity_name(), "GachaItems"); - } -} \ No newline at end of file +use super::types::PgUuid; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ResourceEnum { + OtpCache, + UsersCache, + GachaItems, + GachaClaims, + GachaRolls, + GachaCredits, + Users, + Roles, + Permissions, + RolesPermissions, + Events, + Testimonials, + Mentors, + Notifications, + RateLimit, + AuditLog, + Sessions, + MigrationStatus, +} + +impl ResourceEnum { + pub fn as_str(&self) -> &'static str { + match self { + ResourceEnum::Users => "app_users", + ResourceEnum::UsersCache => "app_users_cache", + ResourceEnum::OtpCache => "app_otp_cache", + ResourceEnum::Roles => "app_roles", + ResourceEnum::Permissions => "app_permissions", + ResourceEnum::RolesPermissions => "app_roles_permissions", + ResourceEnum::GachaItems => "app_gacha_items", + ResourceEnum::GachaClaims => "app_gacha_claims", + ResourceEnum::GachaRolls => "app_gacha_rolls", + ResourceEnum::GachaCredits => "app_gacha_credits", + ResourceEnum::Events => "app_events", + ResourceEnum::Testimonials => "app_testimonials", + ResourceEnum::Mentors => "app_mentors", + ResourceEnum::Notifications => "app_notifications", + ResourceEnum::RateLimit => "app_rate_limit", + ResourceEnum::AuditLog => "app_audit_log", + ResourceEnum::Sessions => "app_sessions", + ResourceEnum::MigrationStatus => "app_migration_status", + } + } + + pub fn schema(&self) -> &'static str { + "public" + } + + pub fn to_entity_name(&self) -> String { + self.as_str().replace("app_", "").to_pascal_case() + } + + pub fn is_cache(&self) -> bool { + matches!(self, ResourceEnum::OtpCache | ResourceEnum::UsersCache) + } + + pub fn is_gacha(&self) -> bool { + matches!( + self, + ResourceEnum::GachaItems + | ResourceEnum::GachaClaims + | ResourceEnum::GachaRolls + | ResourceEnum::GachaCredits + ) + } + + pub fn is_user_related(&self) -> bool { + matches!( + self, + ResourceEnum::Users | ResourceEnum::UsersCache | ResourceEnum::Mentors + ) + } + + pub fn generate_ref_id(&self, uuid: &PgUuid) -> String { + format!("{}_{}", self.as_str().replace("app_", ""), uuid.0) + } +} + +trait ToPascalCase { + fn to_pascal_case(&self) -> String; +} + +impl ToPascalCase for str { + fn to_pascal_case(&self) -> String { + self + .split('_') + .map(|s| { + let mut chars = s.chars(); + chars + .next() + .map(|c| c.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + }) + .collect() + } +} diff --git a/imphnen-entities/src/seaorm/common/events.rs b/imphnen-entities/src/seaorm/common/events.rs index f92861c..3cca1ee 100644 --- a/imphnen-entities/src/seaorm/common/events.rs +++ b/imphnen-entities/src/seaorm/common/events.rs @@ -1,51 +1,49 @@ -//! SeaORM entity for Events table - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "events")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(not_null)] - pub name: String, - - #[sea_orm(not_null)] - pub description: String, - - #[sea_orm(not_null)] - pub detail_link: String, - - #[sea_orm(not_null)] - pub price: f64, - - #[sea_orm(default = "false")] - pub is_online: bool, - - #[sea_orm(default = "false")] - pub is_deleted: bool, - - #[sea_orm(nullable)] - pub location: Option, - - #[sea_orm(not_null)] - pub start_date: DateTime, - - #[sea_orm(not_null)] - pub end_date: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "events")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null)] + pub name: String, + + #[sea_orm(not_null)] + pub description: String, + + #[sea_orm(not_null)] + pub detail_link: String, + + #[sea_orm(not_null)] + pub price: f64, + + #[sea_orm(default = "false")] + pub is_online: bool, + + #[sea_orm(default = "false")] + pub is_deleted: bool, + + #[sea_orm(nullable)] + pub location: Option, + + #[sea_orm(not_null)] + pub start_date: DateTime, + + #[sea_orm(not_null)] + pub end_date: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/common/mod.rs b/imphnen-entities/src/seaorm/common/mod.rs index 02746ec..6ffc802 100644 --- a/imphnen-entities/src/seaorm/common/mod.rs +++ b/imphnen-entities/src/seaorm/common/mod.rs @@ -1,11 +1,12 @@ +pub mod audit_log; +pub mod enum_impls; pub mod enums; +pub mod events; +pub mod rate_limit; +pub mod testimonials; pub mod types; pub mod utils; -pub mod audit_log; -pub mod rate_limit; -pub mod events; -pub mod testimonials; pub use enums::ResourceEnum; pub use types::PgUuid; -pub use utils::{generate_uuid, current_timestamp}; +pub use utils::{current_timestamp, generate_uuid}; diff --git a/imphnen-entities/src/seaorm/common/rate_limit.rs b/imphnen-entities/src/seaorm/common/rate_limit.rs index 8b49d64..d29e871 100644 --- a/imphnen-entities/src/seaorm/common/rate_limit.rs +++ b/imphnen-entities/src/seaorm/common/rate_limit.rs @@ -1,18 +1,16 @@ -//! SeaORM Entity for RateLimit - use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)] #[sea_orm(table_name = "app_rate_limit")] pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: String, - pub ip_address: String, - pub request_count: u32, - pub first_request_time: DateTimeWithTimeZone, - pub last_request_time: DateTimeWithTimeZone, - pub window_duration_secs: i64, + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: String, + pub ip_address: String, + pub request_count: u32, + pub first_request_time: DateTimeWithTimeZone, + pub last_request_time: DateTimeWithTimeZone, + pub window_duration_secs: i64, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/imphnen-entities/src/seaorm/common/testimonials.rs b/imphnen-entities/src/seaorm/common/testimonials.rs index 5ea73a9..d3c3bd8 100644 --- a/imphnen-entities/src/seaorm/common/testimonials.rs +++ b/imphnen-entities/src/seaorm/common/testimonials.rs @@ -1,51 +1,49 @@ -//! SeaORM entity for Testimonials table - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; // Added Uuid import - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "testimonials")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(not_null, column_type = "Uuid")] - pub user_id: Uuid, - - #[sea_orm(not_null)] - pub role: String, - - #[sea_orm(not_null)] - pub content: String, - - #[sea_orm(default = "false")] - pub is_deleted: bool, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "crate::seaorm::auth::users::Entity", - from = "Column::UserId", - to = "crate::seaorm::auth::users::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" - )] - Users, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "testimonials")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null, column_type = "Uuid")] + pub user_id: Uuid, + + #[sea_orm(not_null)] + pub role: String, + + #[sea_orm(not_null)] + pub content: String, + + #[sea_orm(default = "false")] + pub is_deleted: bool, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "crate::seaorm::auth::users::Entity", + from = "Column::UserId", + to = "crate::seaorm::auth::users::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/common/types.rs b/imphnen-entities/src/seaorm/common/types.rs index a7f224e..34a74a7 100644 --- a/imphnen-entities/src/seaorm/common/types.rs +++ b/imphnen-entities/src/seaorm/common/types.rs @@ -1,73 +1,63 @@ -//! Shared type definitions for SeaORM entities -//! Provides PostgreSQL-compatible type aliases and custom types - use chrono::{DateTime, Utc}; use uuid::Uuid; -/// UUID type alias for PostgreSQL UUID compatibility -/// Uses `Uuid` from the `uuid` crate with SeaORM conversion traits #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct PgUuid(pub Uuid); impl From for PgUuid { - fn from(uuid: Uuid) -> Self { - Self(uuid) - } + fn from(uuid: Uuid) -> Self { + Self(uuid) + } } impl From for Uuid { - fn from(pg_uuid: PgUuid) -> Self { - pg_uuid.0 - } + fn from(pg_uuid: PgUuid) -> Self { + pg_uuid.0 + } } impl From for String { - fn from(pg_uuid: PgUuid) -> Self { - pg_uuid.0.to_string() - } + fn from(pg_uuid: PgUuid) -> Self { + pg_uuid.0.to_string() + } } -/// Timestamp type alias for PostgreSQL TIMESTAMP with time zone -/// Uses `DateTime` from the `chrono` crate #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct PgTimestamp(pub DateTime); impl From> for PgTimestamp { - fn from(timestamp: DateTime) -> Self { - Self(timestamp) - } + fn from(timestamp: DateTime) -> Self { + Self(timestamp) + } } impl From for DateTime { - fn from(pg_timestamp: PgTimestamp) -> Self { - pg_timestamp.0 - } + fn from(pg_timestamp: PgTimestamp) -> Self { + pg_timestamp.0 + } } -/// JSONB type alias for PostgreSQL JSONB compatibility #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PgJsonB(pub T); impl From for PgJsonB where - T: serde::Serialize, + T: serde::Serialize, { - fn from(value: T) -> Self { - Self(value) - } + fn from(value: T) -> Self { + Self(value) + } } -/// Common fields that should be included in all entities #[derive(Clone, Debug, PartialEq, Eq)] pub struct CommonFields { - pub id: PgUuid, - pub created_at: PgTimestamp, - pub updated_at: PgTimestamp, - pub deleted_at: Option, + pub id: PgUuid, + pub created_at: PgTimestamp, + pub updated_at: PgTimestamp, + pub deleted_at: Option, } -// Helper macros for common field definitions #[macro_export] macro_rules! common_fields { () => { @@ -86,4 +76,3 @@ macro_rules! common_fields { .default(None), }; } - diff --git a/imphnen-entities/src/seaorm/common/utils.rs b/imphnen-entities/src/seaorm/common/utils.rs index fe2c316..107be98 100644 --- a/imphnen-entities/src/seaorm/common/utils.rs +++ b/imphnen-entities/src/seaorm/common/utils.rs @@ -1,90 +1,74 @@ -//! Utility functions for SeaORM entities -//! Provides helper functions for UUID generation, timestamp handling, and resource management - -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -use super::types::{PgTimestamp, PgUuid}; - -/// Generate a new UUID for entity IDs -/// Uses cryptographically secure random UUID version 4 -pub fn generate_uuid() -> Uuid { - Uuid::new_v4() -} - -/// Generate a new timestamp for entity timestamps -/// Uses UTC timezone with millisecond precision -pub fn generate_timestamp() -> PgTimestamp { - PgTimestamp(DateTime::from_timestamp_millis(Utc::now().timestamp_millis()).unwrap()) -} - -/// Convert a string to PgUuid -/// Returns Result with error message on failure -pub fn string_to_uuid(uuid_str: &str) -> Result { - Uuid::parse_str(uuid_str) - .map(PgUuid) - .map_err(|e| format!("Invalid UUID format: {e}")) -} - -/// Convert PgUuid to string representation -pub fn uuid_to_string(uuid: &uuid::Uuid) -> String { - uuid.to_string() -} - -/// Get current timestamp as DateTime -pub fn current_timestamp() -> DateTime { - Utc::now() -} - -/// Format timestamp for display -pub fn format_timestamp(timestamp: &PgTimestamp) -> String { - timestamp.0.format("%Y-%m-%d %H:%M:%S UTC").to_string() -} - -/// Create a soft delete timestamp -pub fn create_deleted_at() -> Option> { - Some(current_timestamp()) -} - -/// Remove soft delete timestamp -pub fn remove_deleted_at() -> Option { - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_uuid() { - let uuid1 = generate_uuid(); - let uuid2 = generate_uuid(); - assert_ne!(uuid1, uuid2); - assert!(Uuid::parse_str(&uuid_to_string(&uuid1)).is_ok()); - } - - #[test] - fn test_generate_timestamp() { - let ts1 = generate_timestamp(); - let ts2 = generate_timestamp(); - // Timestamps should be close to each other - let diff = ts2.0.signed_duration_since(ts1.0).num_milliseconds(); - assert!(diff >= 0); - assert!(diff < 1000); // Should be within 1 second - } - - #[test] - fn test_string_to_uuid() { - let uuid_str = "123e4567-e89b-12d3-a456-426614174000"; - let result = string_to_uuid(uuid_str); - assert!(result.is_ok()); - let uuid = result.unwrap(); - // `uuid` is a `PgUuid`; convert to `Uuid` before comparing string representation - let uuid_plain: uuid::Uuid = uuid.into(); - assert_eq!(uuid_to_string(&uuid_plain), uuid_str); - - let invalid_uuid = "invalid-uuid"; - let result = string_to_uuid(invalid_uuid); - assert!(result.is_err()); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::types::{PgTimestamp, PgUuid}; + +pub fn generate_uuid() -> Uuid { + Uuid::new_v4() +} + +pub fn generate_timestamp() -> PgTimestamp { + PgTimestamp(Utc::now()) +} + +pub fn string_to_uuid(uuid_str: &str) -> Result { + Uuid::parse_str(uuid_str) + .map(PgUuid) + .map_err(|e| format!("Invalid UUID format: {e}")) +} + +pub fn uuid_to_string(uuid: &uuid::Uuid) -> String { + uuid.to_string() +} + +pub fn current_timestamp() -> DateTime { + Utc::now() +} + +pub fn format_timestamp(timestamp: &PgTimestamp) -> String { + timestamp.0.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +pub fn create_deleted_at() -> Option> { + Some(current_timestamp()) +} + +pub fn remove_deleted_at() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_uuid() { + let uuid1 = generate_uuid(); + let uuid2 = generate_uuid(); + assert_ne!(uuid1, uuid2); + assert!(Uuid::parse_str(&uuid_to_string(&uuid1)).is_ok()); + } + + #[test] + fn test_generate_timestamp() { + let ts1 = generate_timestamp(); + let ts2 = generate_timestamp(); + let diff = ts2.0.signed_duration_since(ts1.0).num_milliseconds(); + assert!(diff >= 0); + assert!(diff < 1000); + } + + #[test] + fn test_string_to_uuid() { + let uuid_str = "123e4567-e89b-12d3-a456-426614174000"; + let result = string_to_uuid(uuid_str); + assert!(result.is_ok()); + let uuid = result.unwrap(); + let uuid_plain: uuid::Uuid = uuid.into(); + assert_eq!(uuid_to_string(&uuid_plain), uuid_str); + + let invalid_uuid = "invalid-uuid"; + let result = string_to_uuid(invalid_uuid); + assert!(result.is_err()); + } +} diff --git a/imphnen-entities/src/seaorm/gacha/gacha_claims.rs b/imphnen-entities/src/seaorm/gacha/gacha_claims.rs index bb05d1f..98af713 100644 --- a/imphnen-entities/src/seaorm/gacha/gacha_claims.rs +++ b/imphnen-entities/src/seaorm/gacha/gacha_claims.rs @@ -1,178 +1,170 @@ -//! SeaORM entity for GachaClaims table -//! Corresponding to ResourceEnum::GachaClaims - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "app_gacha_claims")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(not_null)] - pub user_id: Uuid, - - #[sea_orm(not_null)] - pub gacha_item_id: Uuid, - - #[sea_orm(not_null)] - pub claim_id: Uuid, - - #[sea_orm(not_null)] - pub claim_type: String, - - #[sea_orm(not_null)] - pub status: String, - - #[sea_orm(default = "0")] - pub quantity: i32, - - #[sea_orm(type = "jsonb", nullable)] - pub metadata: Option, - - #[sea_orm(not_null, default = "now()")] - pub claimed_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for GachaClaim creation -#[derive(Default, Serialize, Deserialize)] -pub struct GachaClaimBuilder { - user_id: Option, - gacha_item_id: Option, - claim_type: Option, - status: Option, - quantity: Option, - metadata: Option, -} - -impl GachaClaimBuilder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn user_id(mut self, user_id: Uuid) -> Self { - self.user_id = Some(user_id); - self - } - - #[must_use] - pub fn gacha_item_id(mut self, gacha_item_id: Uuid) -> Self { - self.gacha_item_id = Some(gacha_item_id); - self - } - - #[must_use] - pub fn claim_type(mut self, claim_type: String) -> Self { - self.claim_type = Some(claim_type); - self - } - - #[must_use] - pub fn status(mut self, status: String) -> Self { - self.status = Some(status); - self - } - - #[must_use] - pub fn quantity(mut self, quantity: i32) -> Self { - self.quantity = Some(quantity); - self - } - - #[must_use] - pub fn metadata(mut self, metadata: serde_json::Value) -> Self { - self.metadata = Some(metadata); - self - } - - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let Some(user_id) = self.user_id { - active_model.user_id = Set(user_id); - } else { - return Err("User ID is required".to_string()); - } - - if let Some(gacha_item_id) = self.gacha_item_id { - active_model.gacha_item_id = Set(gacha_item_id); - } else { - return Err("Gacha Item ID is required".to_string()); - } - - if let Some(claim_type) = self.claim_type { - active_model.claim_type = Set(claim_type); - } else { - return Err("Claim type is required".to_string()); - } - - if let Some(status) = self.status { - active_model.status = Set(status); - } else { - return Err("Status is required".to_string()); - } - - if let Some(quantity) = self.quantity { - active_model.quantity = Set(quantity); - } - - if let Some(metadata) = self.metadata { - active_model.metadata = Set(Some(metadata)); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::seaorm::common::utils::generate_uuid; - - #[test] - fn test_gacha_claim_model_creation() { - let user_id = generate_uuid(); - let gacha_item_id = generate_uuid(); - - let claim = GachaClaimBuilder::new() - .user_id(user_id) - .gacha_item_id(gacha_item_id) - .claim_type("direct".to_string()) - .status("claimed".to_string()) - .quantity(1) - .build(); - - assert!(claim.is_ok()); - let claim_model = claim.unwrap(); - assert_eq!(claim_model.user_id, Set(user_id)); - assert_eq!(claim_model.gacha_item_id, Set(gacha_item_id)); - assert_eq!(claim_model.claim_type, Set("direct".to_string())); - assert_eq!(claim_model.status, Set("claimed".to_string())); - assert_eq!(claim_model.quantity, Set(1)); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "app_gacha_claims")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(not_null)] + pub user_id: Uuid, + + #[sea_orm(not_null)] + pub gacha_item_id: Uuid, + + #[sea_orm(not_null)] + pub claim_id: Uuid, + + #[sea_orm(not_null)] + pub claim_type: String, + + #[sea_orm(not_null)] + pub status: String, + + #[sea_orm(default = "0")] + pub quantity: i32, + + #[sea_orm(type = "jsonb", nullable)] + pub metadata: Option, + + #[sea_orm(not_null, default = "now()")] + pub claimed_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Default, Serialize, Deserialize)] +pub struct GachaClaimBuilder { + user_id: Option, + gacha_item_id: Option, + claim_type: Option, + status: Option, + quantity: Option, + metadata: Option, +} + +impl GachaClaimBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn user_id(mut self, user_id: Uuid) -> Self { + self.user_id = Some(user_id); + self + } + + #[must_use] + pub fn gacha_item_id(mut self, gacha_item_id: Uuid) -> Self { + self.gacha_item_id = Some(gacha_item_id); + self + } + + #[must_use] + pub fn claim_type(mut self, claim_type: String) -> Self { + self.claim_type = Some(claim_type); + self + } + + #[must_use] + pub fn status(mut self, status: String) -> Self { + self.status = Some(status); + self + } + + #[must_use] + pub fn quantity(mut self, quantity: i32) -> Self { + self.quantity = Some(quantity); + self + } + + #[must_use] + pub fn metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } + + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let Some(user_id) = self.user_id { + active_model.user_id = Set(user_id); + } else { + return Err("User ID is required".to_string()); + } + + if let Some(gacha_item_id) = self.gacha_item_id { + active_model.gacha_item_id = Set(gacha_item_id); + } else { + return Err("Gacha Item ID is required".to_string()); + } + + if let Some(claim_type) = self.claim_type { + active_model.claim_type = Set(claim_type); + } else { + return Err("Claim type is required".to_string()); + } + + if let Some(status) = self.status { + active_model.status = Set(status); + } else { + return Err("Status is required".to_string()); + } + + if let Some(quantity) = self.quantity { + active_model.quantity = Set(quantity); + } + + if let Some(metadata) = self.metadata { + active_model.metadata = Set(Some(metadata)); + } + + Ok(active_model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seaorm::common::utils::generate_uuid; + + #[test] + fn test_gacha_claim_model_creation() { + let user_id = generate_uuid(); + let gacha_item_id = generate_uuid(); + + let claim = GachaClaimBuilder::new() + .user_id(user_id) + .gacha_item_id(gacha_item_id) + .claim_type("direct".to_string()) + .status("claimed".to_string()) + .quantity(1) + .build(); + + assert!(claim.is_ok()); + let claim_model = claim.unwrap(); + assert_eq!(claim_model.user_id, Set(user_id)); + assert_eq!(claim_model.gacha_item_id, Set(gacha_item_id)); + assert_eq!(claim_model.claim_type, Set("direct".to_string())); + assert_eq!(claim_model.status, Set("claimed".to_string())); + assert_eq!(claim_model.quantity, Set(1)); + } +} diff --git a/imphnen-entities/src/seaorm/gacha/gacha_credits.rs b/imphnen-entities/src/seaorm/gacha/gacha_credits.rs index 9ac1da4..42b0a71 100644 --- a/imphnen-entities/src/seaorm/gacha/gacha_credits.rs +++ b/imphnen-entities/src/seaorm/gacha/gacha_credits.rs @@ -1,34 +1,34 @@ -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; // Added Uuid import - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "gacha_credits")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - #[sea_orm(column_type = "Uuid")] - pub user_id: Uuid, - pub available_rolls: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::super::auth::users::Entity", - from = "Column::UserId", - to = "super::super::auth::users::Column::Id" - )] - Users, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; // Added Uuid import + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "gacha_credits")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + #[sea_orm(column_type = "Uuid")] + pub user_id: Uuid, + pub available_rolls: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::super::auth::users::Entity", + from = "Column::UserId", + to = "super::super::auth::users::Column::Id" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/gacha/gacha_items.rs b/imphnen-entities/src/seaorm/gacha/gacha_items.rs index 591b256..eafecbf 100644 --- a/imphnen-entities/src/seaorm/gacha/gacha_items.rs +++ b/imphnen-entities/src/seaorm/gacha/gacha_items.rs @@ -1,256 +1,147 @@ -//! SeaORM entity for GachaItems table -//! Corresponding to ResourceEnum::GachaItems - -use chrono::{DateTime, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; -use sea_orm::ActiveValue::Set; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "app_gacha_items")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(unique, not_null)] - pub item_code: String, - - #[sea_orm(not_null)] - pub name: String, - - #[sea_orm(not_null)] - pub description: String, - - #[sea_orm(not_null)] - pub rarity: String, - - #[sea_orm(not_null)] - pub type_: String, - - #[sea_orm(not_null)] - pub category: String, - - #[sea_orm(not_null)] - pub value: i32, - - #[sea_orm(not_null)] - pub weight: f64, - - #[sea_orm(default = "0")] - pub stock: i32, - - #[sea_orm(default = "false")] - pub is_limited: bool, - - #[sea_orm(type = "jsonb", nullable)] - pub metadata: Option, - - #[sea_orm(not_null, default = "now()")] - pub created_at: DateTime, - - #[sea_orm(not_null, default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(nullable)] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation { -} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -// Builder pattern for GachaItem creation -#[derive(Default, Serialize, Deserialize)] -pub struct GachaItemBuilder { - item_code: Option, - name: Option, - description: Option, - rarity: Option, - type_: Option, - category: Option, - value: Option, - weight: Option, - stock: Option, - is_limited: Option, - metadata: Option, -} - -impl GachaItemBuilder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn item_code(mut self, item_code: String) -> Self { - self.item_code = Some(item_code); - self - } - - #[must_use] - pub fn name(mut self, name: String) -> Self { - self.name = Some(name); - self - } - - #[must_use] - pub fn description(mut self, description: String) -> Self { - self.description = Some(description); - self - } - - #[must_use] - pub fn rarity(mut self, rarity: String) -> Self { - self.rarity = Some(rarity); - self - } - - #[must_use] - pub fn type_(mut self, type_: String) -> Self { - self.type_ = Some(type_); - self - } - - #[must_use] - pub fn category(mut self, category: String) -> Self { - self.category = Some(category); - self - } - - #[must_use] - pub fn value(mut self, value: i32) -> Self { - self.value = Some(value); - self - } - - #[must_use] - pub fn weight(mut self, weight: f64) -> Self { - self.weight = Some(weight); - self - } - - #[must_use] - pub fn stock(mut self, stock: i32) -> Self { - self.stock = Some(stock); - self - } - - #[must_use] - pub fn is_limited(mut self, is_limited: bool) -> Self { - self.is_limited = Some(is_limited); - self - } - - #[must_use] - pub fn metadata(mut self, metadata: serde_json::Value) -> Self { - self.metadata = Some(metadata); - self - } - - pub fn build(self) -> Result { - let mut active_model = ::default(); - - if let Some(item_code) = self.item_code { - active_model.item_code = Set(item_code); - } else { - return Err("Item code is required".to_string()); - } - - if let Some(name) = self.name { - active_model.name = Set(name); - } else { - return Err("Name is required".to_string()); - } - - if let Some(description) = self.description { - active_model.description = Set(description); - } else { - return Err("Description is required".to_string()); - } - - if let Some(rarity) = self.rarity { - active_model.rarity = Set(rarity); - } else { - return Err("Rarity is required".to_string()); - } - - if let Some(type_) = self.type_ { - active_model.type_ = Set(type_); - } else { - return Err("Type is required".to_string()); - } - - if let Some(category) = self.category { - active_model.category = Set(category); - } else { - return Err("Category is required".to_string()); - } - - if let Some(value) = self.value { - active_model.value = Set(value); - } else { - return Err("Value is required".to_string()); - } - - if let Some(weight) = self.weight { - active_model.weight = Set(weight); - } else { - return Err("Weight is required".to_string()); - } - - if let Some(stock) = self.stock { - active_model.stock = Set(stock); - } - - if let Some(is_limited) = self.is_limited { - active_model.is_limited = Set(is_limited); - } - - if let Some(metadata) = self.metadata { - active_model.metadata = Set(Some(metadata)); - } - - Ok(active_model) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_gacha_item_model_creation() { - let item = GachaItemBuilder::new() - .item_code("SWORD_001".to_string()) - .name("Legendary Sword".to_string()) - .description("A powerful legendary sword".to_string()) - .rarity("legendary".to_string()) - .type_("weapon".to_string()) - .category("sword".to_string()) - .value(100) - .weight(0.01) - .stock(10) - .is_limited(true) - .build(); - - assert!(item.is_ok()); - let item_model = item.unwrap(); - assert_eq!(item_model.item_code, Set("SWORD_001".to_string())); - assert_eq!(item_model.name, Set("Legendary Sword".to_string())); - assert_eq!(item_model.description, Set("A powerful legendary sword".to_string())); - assert_eq!(item_model.rarity, Set("legendary".to_string())); - assert_eq!(item_model.type_, Set("weapon".to_string())); - assert_eq!(item_model.category, Set("sword".to_string())); - assert_eq!(item_model.value, Set(100)); - assert_eq!(item_model.weight, Set(0.01)); - assert_eq!(item_model.stock, Set(10)); - assert_eq!(item_model.is_limited, Set(true)); - } -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveModelBehavior, DeriveEntityModel, DeriveRelation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "app_gacha_items")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(unique, not_null)] + pub item_code: String, + + #[sea_orm(not_null)] + pub name: String, + + #[sea_orm(not_null)] + pub description: String, + + #[sea_orm(not_null)] + pub rarity: String, + + #[sea_orm(not_null)] + pub type_: String, + + #[sea_orm(not_null)] + pub category: String, + + #[sea_orm(not_null)] + pub value: i32, + + #[sea_orm(not_null)] + pub weight: f64, + + #[sea_orm(default = "0")] + pub stock: i32, + + #[sea_orm(default = "false")] + pub is_limited: bool, + + #[sea_orm(type = "jsonb", nullable)] + pub metadata: Option, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(nullable)] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Default, Serialize, Deserialize)] +pub struct GachaItemBuilder { + pub item_code: Option, + pub name: Option, + pub description: Option, + pub rarity: Option, + pub type_: Option, + pub category: Option, + pub value: Option, + pub weight: Option, + pub stock: Option, + pub is_limited: Option, + pub metadata: Option, +} + +impl GachaItemBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn item_code(mut self, item_code: String) -> Self { + self.item_code = Some(item_code); + self + } + + #[must_use] + pub fn name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + #[must_use] + pub fn description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + #[must_use] + pub fn rarity(mut self, rarity: String) -> Self { + self.rarity = Some(rarity); + self + } + + #[must_use] + pub fn type_(mut self, type_: String) -> Self { + self.type_ = Some(type_); + self + } + + #[must_use] + pub fn category(mut self, category: String) -> Self { + self.category = Some(category); + self + } + + #[must_use] + pub fn value(mut self, value: i32) -> Self { + self.value = Some(value); + self + } + + #[must_use] + pub fn weight(mut self, weight: f64) -> Self { + self.weight = Some(weight); + self + } + + #[must_use] + pub fn stock(mut self, stock: i32) -> Self { + self.stock = Some(stock); + self + } + + #[must_use] + pub fn is_limited(mut self, is_limited: bool) -> Self { + self.is_limited = Some(is_limited); + self + } + + #[must_use] + pub fn metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } +} diff --git a/imphnen-entities/src/seaorm/gacha/gacha_items_queries.rs b/imphnen-entities/src/seaorm/gacha/gacha_items_queries.rs new file mode 100644 index 0000000..ce8a86d --- /dev/null +++ b/imphnen-entities/src/seaorm/gacha/gacha_items_queries.rs @@ -0,0 +1,70 @@ +use super::gacha_items::{ActiveModel, GachaItemBuilder}; +use sea_orm::ActiveValue::Set; + +impl GachaItemBuilder { + pub fn build(self) -> Result { + let mut active_model = ::default(); + + if let Some(item_code) = self.item_code { + active_model.item_code = Set(item_code); + } else { + return Err("Item code is required".to_string()); + } + + if let Some(name) = self.name { + active_model.name = Set(name); + } else { + return Err("Name is required".to_string()); + } + + if let Some(description) = self.description { + active_model.description = Set(description); + } else { + return Err("Description is required".to_string()); + } + + if let Some(rarity) = self.rarity { + active_model.rarity = Set(rarity); + } else { + return Err("Rarity is required".to_string()); + } + + if let Some(type_) = self.type_ { + active_model.type_ = Set(type_); + } else { + return Err("Type is required".to_string()); + } + + if let Some(category) = self.category { + active_model.category = Set(category); + } else { + return Err("Category is required".to_string()); + } + + if let Some(value) = self.value { + active_model.value = Set(value); + } else { + return Err("Value is required".to_string()); + } + + if let Some(weight) = self.weight { + active_model.weight = Set(weight); + } else { + return Err("Weight is required".to_string()); + } + + if let Some(stock) = self.stock { + active_model.stock = Set(stock); + } + + if let Some(is_limited) = self.is_limited { + active_model.is_limited = Set(is_limited); + } + + if let Some(metadata) = self.metadata { + active_model.metadata = Set(Some(metadata)); + } + + Ok(active_model) + } +} diff --git a/imphnen-entities/src/seaorm/gacha/gacha_rolls.rs b/imphnen-entities/src/seaorm/gacha/gacha_rolls.rs index 82efcca..88309e9 100644 --- a/imphnen-entities/src/seaorm/gacha/gacha_rolls.rs +++ b/imphnen-entities/src/seaorm/gacha/gacha_rolls.rs @@ -1,50 +1,50 @@ -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; // Added Uuid import - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "gacha_rolls")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - #[sea_orm(column_type = "Uuid")] - pub user_id: Uuid, - pub gacha_id: String, - #[sea_orm(column_type = "Uuid")] - pub item_id: Uuid, - pub weight: f32, - pub quantity: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::gacha_items::Entity", - from = "Column::ItemId", - to = "super::gacha_items::Column::Id" - )] - GachaItems, - #[sea_orm( - belongs_to = "super::super::auth::users::Entity", - from = "Column::UserId", - to = "super::super::auth::users::Column::Id" - )] - Users, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::GachaItems.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; // Added Uuid import + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "gacha_rolls")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + #[sea_orm(column_type = "Uuid")] + pub user_id: Uuid, + pub gacha_id: String, + #[sea_orm(column_type = "Uuid")] + pub item_id: Uuid, + pub weight: f32, + pub quantity: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::gacha_items::Entity", + from = "Column::ItemId", + to = "super::gacha_items::Column::Id" + )] + GachaItems, + #[sea_orm( + belongs_to = "super::super::auth::users::Entity", + from = "Column::UserId", + to = "super::super::auth::users::Column::Id" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::GachaItems.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-entities/src/seaorm/gacha/mod.rs b/imphnen-entities/src/seaorm/gacha/mod.rs index d5f85f7..0cf1edc 100644 --- a/imphnen-entities/src/seaorm/gacha/mod.rs +++ b/imphnen-entities/src/seaorm/gacha/mod.rs @@ -1,4 +1,5 @@ -pub mod gacha_credits; -pub mod gacha_rolls; -pub mod gacha_items; -pub mod gacha_claims; \ No newline at end of file +pub mod gacha_claims; +pub mod gacha_credits; +pub mod gacha_items; +pub mod gacha_items_queries; +pub mod gacha_rolls; diff --git a/imphnen-entities/src/seaorm/lib.rs b/imphnen-entities/src/seaorm/lib.rs index 4d7bca9..e928123 100644 --- a/imphnen-entities/src/seaorm/lib.rs +++ b/imphnen-entities/src/seaorm/lib.rs @@ -1,72 +1,60 @@ -//! SeaORM entity definitions for Imphenia backend -//! Provides PostgreSQL-compatible entity definitions corresponding to SurrealDB ResourceEnum - -pub mod auth; -pub mod gacha; -pub mod common; -pub mod relationships; -pub mod schema_validation; -pub mod examples; - -// Re-export specific items from modules for better API clarity -pub use auth::{ - users, mentors, roles, permissions, roles_permissions, sessions -}; -pub use gacha::{ - gacha_items, gacha_claims, gacha_credits, gacha_rolls -}; -pub use common::{ - ResourceEnum, PgUuid, generate_uuid, current_timestamp, - audit_log, rate_limit, events, testimonials -}; -pub use relationships; -pub use schema_validation; -pub use examples; - -/// Initialize the SeaORM entity system -/// Should be called once at application startup -pub fn initialize() -> Result<(), String> { - // Perform schema validation on initialization - validate_schema_equivalence()?; - - // Initialize any global utilities or configurations - common::utils::initialize_utils(); - - Ok(()) -} - -/// Get the table name for a given ResourceEnum -/// Provides a consistent way to access table names across the application -pub fn get_table_name(resource: &common::enums::ResourceEnum) -> &str { - resource.as_str() -} - -/// Get the schema name for all entities (default: "public") -pub fn get_schema_name() -> &str { - "public" -} - -#[cfg(test)] -mod tests { - use super::*; - use common::enums::ResourceEnum; - - #[test] - fn test_table_name_resolution() { - assert_eq!(get_table_name(&ResourceEnum::Users), "app_users"); - assert_eq!(get_table_name(&ResourceEnum::Roles), "app_roles"); - assert_eq!(get_table_name(&ResourceEnum::GachaItems), "app_gacha_items"); - } - - #[test] - fn test_schema_name() { - assert_eq!(get_schema_name(), "public"); - } - - #[test] - fn test_initialize() { - // This should not panic and should return Ok(()) - let result = initialize(); - assert!(result.is_ok()); - } -} \ No newline at end of file +pub mod auth; +pub mod gacha; +pub mod common; +pub mod relationships; +pub mod schema_validation; +pub mod examples; + +pub use auth::{ + users, mentors, roles, permissions, roles_permissions, sessions +}; +pub use gacha::{ + gacha_items, gacha_claims, gacha_credits, gacha_rolls +}; +pub use common::{ + ResourceEnum, PgUuid, generate_uuid, current_timestamp, + audit_log, rate_limit, events, testimonials +}; +pub use relationships; +pub use schema_validation; +pub use examples; + +pub fn initialize() -> Result<(), String> { + validate_schema_equivalence()?; + + common::utils::initialize_utils(); + + Ok(()) +} + +pub fn get_table_name(resource: &common::enums::ResourceEnum) -> &str { + resource.as_str() +} + +pub fn get_schema_name() -> &str { + "public" +} + +#[cfg(test)] +mod tests { + use super::*; + use common::enums::ResourceEnum; + + #[test] + fn test_table_name_resolution() { + assert_eq!(get_table_name(&ResourceEnum::Users), "app_users"); + assert_eq!(get_table_name(&ResourceEnum::Roles), "app_roles"); + assert_eq!(get_table_name(&ResourceEnum::GachaItems), "app_gacha_items"); + } + + #[test] + fn test_schema_name() { + assert_eq!(get_schema_name(), "public"); + } + + #[test] + fn test_initialize() { + let result = initialize(); + assert!(result.is_ok()); + } +} diff --git a/imphnen-entities/src/seaorm/migration_status.rs b/imphnen-entities/src/seaorm/migration_status.rs index b582f4d..cf173e9 100644 --- a/imphnen-entities/src/seaorm/migration_status.rs +++ b/imphnen-entities/src/seaorm/migration_status.rs @@ -1,100 +1,90 @@ -//! Migration status tracking entity for database migration validation - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -use chrono::{Utc, DateTime}; -use uuid::Uuid; - -// PgUuid and PgTimestamp are not used in this file, but kept for potential future use -// use crate::seaorm::common::types::{PgUuid, PgTimestamp}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] -#[sea_orm(table_name = "app_migration_status")] -pub struct Model { - #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] - pub id: Uuid, - - #[sea_orm(column_type = "Text")] - pub resource_type: String, - - #[sea_orm(column_type = "Text")] - pub status: String, - - #[sea_orm(column_type = "Json", default = "null")] - pub validation_results: Option, - - #[sea_orm(column_type = "Text", default = "null")] - pub last_error: Option, - - #[sea_orm(column_type = "Integer", default = 0)] - pub total_records: i32, - - #[sea_orm(column_type = "Integer", default = 0)] - pub validated_records: i32, - - #[sea_orm(column_type = "Integer", default = 0)] - pub failed_records: i32, - - #[sea_orm(column_type = "Integer", default = 0)] - pub skipped_records: i32, - - #[sea_orm(column_type = "Text", default = "null")] - pub validation_mode: Option, - - #[sea_orm(column_type = "Timestamp", default = "now()")] - pub last_validated_at: DateTime, - - #[sea_orm(column_type = "Timestamp", default = "now()")] - pub created_at: DateTime, - - #[sea_orm(column_type = "Timestamp", default = "now()")] - pub updated_at: DateTime, - - #[sea_orm(column_type = "Timestamp", default = "null")] - pub deleted_at: Option>, -} - -#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel { - // Default implementation - SeaORM will handle timestamps automatically -} - -/// Migration status constants -pub mod status { - pub const PENDING: &str = "pending"; - pub const IN_PROGRESS: &str = "in_progress"; - pub const COMPLETED: &str = "completed"; - pub const FAILED: &str = "failed"; - pub const PARTIAL: &str = "partial"; - pub const SKIPPED: &str = "skipped"; -} - -/// Validation mode constants -pub mod validation_mode { - pub const FULL: &str = "full"; - pub const INCREMENTAL: &str = "incremental"; - pub const QUICK_CHECK: &str = "quick_check"; -} - -/// Resource type constants matching ResourceEnum -pub mod resource_type { - pub const USERS: &str = "users"; - pub const ROLES: &str = "roles"; - pub const PERMISSIONS: &str = "permissions"; - pub const ROLES_PERMISSIONS: &str = "roles_permissions"; - pub const GACHA_ITEMS: &str = "gacha_items"; - pub const GACHA_CLAIMS: &str = "gacha_claims"; - pub const GACHA_ROLLS: &str = "gacha_rolls"; - pub const GACHA_CREDITS: &str = "gacha_credits"; - pub const NOTIFICATIONS: &str = "notifications"; - pub const AUDIT_LOG: &str = "audit_log"; - pub const SESSIONS: &str = "sessions"; - pub const OTP_CACHE: &str = "otp_cache"; - pub const USERS_CACHE: &str = "users_cache"; - pub const RATE_LIMIT: &str = "rate_limit"; - pub const TESTIMONIALS: &str = "testimonials"; - pub const MENTORS: &str = "mentors"; - pub const EVENTS: &str = "events"; -} \ No newline at end of file +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "app_migration_status")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(column_type = "Text")] + pub resource_type: String, + + #[sea_orm(column_type = "Text")] + pub status: String, + + #[sea_orm(column_type = "Json", default = "null")] + pub validation_results: Option, + + #[sea_orm(column_type = "Text", default = "null")] + pub last_error: Option, + + #[sea_orm(column_type = "Integer", default = 0)] + pub total_records: i32, + + #[sea_orm(column_type = "Integer", default = 0)] + pub validated_records: i32, + + #[sea_orm(column_type = "Integer", default = 0)] + pub failed_records: i32, + + #[sea_orm(column_type = "Integer", default = 0)] + pub skipped_records: i32, + + #[sea_orm(column_type = "Text", default = "null")] + pub validation_mode: Option, + + #[sea_orm(column_type = "Timestamp", default = "now()")] + pub last_validated_at: DateTime, + + #[sea_orm(column_type = "Timestamp", default = "now()")] + pub created_at: DateTime, + + #[sea_orm(column_type = "Timestamp", default = "now()")] + pub updated_at: DateTime, + + #[sea_orm(column_type = "Timestamp", default = "null")] + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, sea_orm::EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub mod status { + pub const PENDING: &str = "pending"; + pub const IN_PROGRESS: &str = "in_progress"; + pub const COMPLETED: &str = "completed"; + pub const FAILED: &str = "failed"; + pub const PARTIAL: &str = "partial"; + pub const SKIPPED: &str = "skipped"; +} + +pub mod validation_mode { + pub const FULL: &str = "full"; + pub const INCREMENTAL: &str = "incremental"; + pub const QUICK_CHECK: &str = "quick_check"; +} + +pub mod resource_type { + pub const USERS: &str = "users"; + pub const ROLES: &str = "roles"; + pub const PERMISSIONS: &str = "permissions"; + pub const ROLES_PERMISSIONS: &str = "roles_permissions"; + pub const GACHA_ITEMS: &str = "gacha_items"; + pub const GACHA_CLAIMS: &str = "gacha_claims"; + pub const GACHA_ROLLS: &str = "gacha_rolls"; + pub const GACHA_CREDITS: &str = "gacha_credits"; + pub const NOTIFICATIONS: &str = "notifications"; + pub const AUDIT_LOG: &str = "audit_log"; + pub const SESSIONS: &str = "sessions"; + pub const OTP_CACHE: &str = "otp_cache"; + pub const USERS_CACHE: &str = "users_cache"; + pub const RATE_LIMIT: &str = "rate_limit"; + pub const TESTIMONIALS: &str = "testimonials"; + pub const MENTORS: &str = "mentors"; + pub const EVENTS: &str = "events"; +} diff --git a/imphnen-entities/src/seaorm/mod.rs b/imphnen-entities/src/seaorm/mod.rs index c415cc9..d58770a 100644 --- a/imphnen-entities/src/seaorm/mod.rs +++ b/imphnen-entities/src/seaorm/mod.rs @@ -1,8 +1,4 @@ -// SeaORM entity definitions for Imphenia backend -// This module provides PostgreSQL-compatible entity definitions -// corresponding to the SurrealDB ResourceEnum - -pub mod auth; -pub mod gacha; -pub mod common; -pub mod migration_status; \ No newline at end of file +pub mod auth; +pub mod common; +pub mod gacha; +pub mod migration_status; diff --git a/imphnen-entities/src/users.rs b/imphnen-entities/src/users.rs index 789b6af..295203c 100644 --- a/imphnen-entities/src/users.rs +++ b/imphnen-entities/src/users.rs @@ -1,169 +1,167 @@ -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use crate::permissions::{PermissionsQueryDto, PermissionsItemDto}; - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct ExperienceDto { - pub id: String, - pub company: String, - pub position: String, - pub duration: String, - pub period: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct EducationDto { - pub id: String, - pub institution: String, - pub degree: String, - pub field: String, - pub period: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] -pub struct UserProfileExtensionDto { - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_for_verification: Option, - pub gender: Option, - pub birthdate: Option, - pub domicile: Option, - pub bio: Option, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, - pub website_url: Option, - pub twitter_url: Option, - pub location: Option, - pub skills: Option>, - pub experience: Option>, - pub education: Option>, - pub career_status: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[derive(Default)] -pub struct RolesDetailQueryDto { - pub id: String, - pub name: String, - pub permissions: Option>>, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, -} - - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] -pub struct RolesDetailItemDto { - pub id: String, - pub name: String, - pub is_deleted: bool, - pub permissions: Vec, - pub created_at: Option, - pub updated_at: Option, -} - -impl RolesDetailItemDto { - pub fn from(dto: &RolesDetailQueryDto) -> Self { - Self { - id: dto.id.clone(), - name: dto.name.clone(), - is_deleted: dto.is_deleted, - permissions: dto - .permissions - .as_ref() - .unwrap_or(&vec![]) - .iter() - .filter_map(|p| p.as_ref()) - .map(PermissionsItemDto::from) - .collect(), - created_at: dto.created_at.clone(), - updated_at: dto.updated_at.clone(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct UsersDetailQueryDto { - pub id: String, - pub fullname: String, - pub legal_name: Option, - pub email: String, - pub avatar: Option, - pub is_active: bool, - pub is_deleted: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_extension: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_for_verification: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub gender: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub domicile: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bio: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_education: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub linkedin_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub github_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cv_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub portfolio_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub website_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub twitter_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub location: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub skills: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub experience: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub education: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub career_status: Option, - pub password: String, - pub role: RolesDetailQueryDto, - pub created_at: String, - pub updated_at: String, - pub mentor_id: Option, -} - -impl UsersDetailQueryDto { - pub fn from(self) -> Self { - self - } -} - -impl UsersDetailQueryDto { - pub fn from_profile_extension(mut self) -> Self { - if let Some(ext) = &self.profile_extension { - self.phone_number = ext.phone_number.clone(); - self.phone_for_verification = ext.phone_for_verification.clone(); - self.gender = ext.gender.clone(); - self.domicile = ext.domicile.clone(); - self.bio = ext.bio.clone(); - self.last_education = ext.last_education.clone(); - self.linkedin_url = ext.linkedin_url.clone(); - self.github_url = ext.github_url.clone(); - self.cv_url = ext.cv_url.clone(); - self.portfolio_url = ext.portfolio_url.clone(); - } - self - } -} - -impl std::fmt::Display for UsersDetailQueryDto { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.id) - } -} \ No newline at end of file +use crate::permissions::{PermissionsItemDto, PermissionsQueryDto}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ExperienceDto { + pub id: String, + pub company: String, + pub position: String, + pub duration: String, + pub period: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct EducationDto { + pub id: String, + pub institution: String, + pub degree: String, + pub field: String, + pub period: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] +pub struct UserProfileExtensionDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, + pub gender: Option, + pub birthdate: Option, + pub domicile: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub website_url: Option, + pub twitter_url: Option, + pub location: Option, + pub skills: Option>, + pub experience: Option>, + pub education: Option>, + pub career_status: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct RolesDetailQueryDto { + pub id: String, + pub name: String, + pub permissions: Option>>, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] +pub struct RolesDetailItemDto { + pub id: String, + pub name: String, + pub is_deleted: bool, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +impl RolesDetailItemDto { + pub fn from(dto: &RolesDetailQueryDto) -> Self { + Self { + id: dto.id.clone(), + name: dto.name.clone(), + is_deleted: dto.is_deleted, + permissions: dto + .permissions + .as_ref() + .unwrap_or(&vec![]) + .iter() + .filter_map(|p| p.as_ref()) + .map(PermissionsItemDto::from) + .collect(), + created_at: dto.created_at.clone(), + updated_at: dto.updated_at.clone(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct UsersDetailQueryDto { + pub id: String, + pub fullname: String, + pub legal_name: Option, + pub email: String, + pub avatar: Option, + pub is_active: bool, + pub is_deleted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_extension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domicile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_education: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub linkedin_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cv_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub portfolio_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub twitter_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub experience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub education: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub career_status: Option, + pub password: String, + pub role: RolesDetailQueryDto, + pub created_at: String, + pub updated_at: String, + pub mentor_id: Option, +} + +impl UsersDetailQueryDto { + pub fn from(self) -> Self { + self + } +} + +impl UsersDetailQueryDto { + pub fn from_profile_extension(mut self) -> Self { + if let Some(ext) = &self.profile_extension { + self.phone_number = ext.phone_number.clone(); + self.phone_for_verification = ext.phone_for_verification.clone(); + self.gender = ext.gender.clone(); + self.domicile = ext.domicile.clone(); + self.bio = ext.bio.clone(); + self.last_education = ext.last_education.clone(); + self.linkedin_url = ext.linkedin_url.clone(); + self.github_url = ext.github_url.clone(); + self.cv_url = ext.cv_url.clone(); + self.portfolio_url = ext.portfolio_url.clone(); + } + self + } +} + +impl std::fmt::Display for UsersDetailQueryDto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id) + } +} diff --git a/imphnen-gacha/Cargo.toml b/imphnen-gacha/Cargo.toml index 64eadb8..413a9b8 100644 --- a/imphnen-gacha/Cargo.toml +++ b/imphnen-gacha/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-gacha" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/imphnen-gacha/src/gacha_claims/application/gacha_claim_service.rs b/imphnen-gacha/src/gacha_claims/application/gacha_claim_service.rs index 738b6ab..fc8853b 100644 --- a/imphnen-gacha/src/gacha_claims/application/gacha_claim_service.rs +++ b/imphnen-gacha/src/gacha_claims/application/gacha_claim_service.rs @@ -1,28 +1,28 @@ -use std::sync::Arc; -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use crate::gacha_claims::domain::{ - GachaClaimDetail, GachaClaimEntity, GachaClaimRepository, GachaClaimService, + GachaClaimDetail, GachaClaimEntity, GachaClaimRepository, GachaClaimService, }; +use async_trait::async_trait; +use imphnen_utils::AppError; +use std::sync::Arc; +use uuid::Uuid; pub struct GachaClaimServiceImpl { - repo: Arc, + repo: Arc, } impl GachaClaimServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl GachaClaimService for GachaClaimServiceImpl { - async fn get_claim(&self, id: Uuid) -> Result { - self.repo.find_by_id(id).await - } + async fn get_claim(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } - async fn create_claim(&self, entity: GachaClaimEntity) -> Result<(), AppError> { - self.repo.create(entity).await - } + async fn create_claim(&self, entity: GachaClaimEntity) -> Result<(), AppError> { + self.repo.create(entity).await + } } diff --git a/imphnen-gacha/src/gacha_claims/domain/gacha_claim.rs b/imphnen-gacha/src/gacha_claims/domain/gacha_claim.rs index ba4faa8..f83ed0a 100644 --- a/imphnen-gacha/src/gacha_claims/domain/gacha_claim.rs +++ b/imphnen-gacha/src/gacha_claims/domain/gacha_claim.rs @@ -1,33 +1,32 @@ +use crate::gacha_items::domain::gacha_item::GachaItemEntity; use chrono::{DateTime, Utc}; +use imphnen_entities::UsersDetailQueryDto; use serde_json::Value; use uuid::Uuid; -use imphnen_entities::UsersDetailQueryDto; -use crate::gacha_items::domain::gacha_item::GachaItemEntity; #[derive(Clone, Debug)] pub struct GachaClaimEntity { - pub id: Uuid, - pub user_id: Uuid, - pub gacha_item_id: Uuid, - pub claim_id: Uuid, - pub claim_type: String, - pub status: String, - pub quantity: i32, - pub metadata: Option, - pub is_deleted: bool, - pub claimed_at: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, + pub id: Uuid, + pub user_id: Uuid, + pub gacha_item_id: Uuid, + pub claim_id: Uuid, + pub claim_type: String, + pub status: String, + pub quantity: i32, + pub metadata: Option, + pub is_deleted: bool, + pub claimed_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, } -/// Denormalized struct for claim detail responses with nested user and item data. #[derive(Clone, Debug)] pub struct GachaClaimDetail { - pub id: Uuid, - pub user: UsersDetailQueryDto, - pub item: GachaItemEntity, - pub is_deleted: bool, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub user: UsersDetailQueryDto, + pub item: GachaItemEntity, + pub is_deleted: bool, + pub created_at: DateTime, + pub updated_at: DateTime, } diff --git a/imphnen-gacha/src/gacha_claims/domain/repository.rs b/imphnen-gacha/src/gacha_claims/domain/repository.rs index 5552665..e6951e6 100644 --- a/imphnen-gacha/src/gacha_claims/domain/repository.rs +++ b/imphnen-gacha/src/gacha_claims/domain/repository.rs @@ -1,10 +1,10 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_claim::{GachaClaimDetail, GachaClaimEntity}; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaClaimRepository: Send + Sync { - async fn find_by_id(&self, id: Uuid) -> Result; - async fn create(&self, entity: GachaClaimEntity) -> Result<(), AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn create(&self, entity: GachaClaimEntity) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_claims/domain/service.rs b/imphnen-gacha/src/gacha_claims/domain/service.rs index 6554a8f..2bd728e 100644 --- a/imphnen-gacha/src/gacha_claims/domain/service.rs +++ b/imphnen-gacha/src/gacha_claims/domain/service.rs @@ -1,10 +1,10 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_claim::{GachaClaimDetail, GachaClaimEntity}; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaClaimService: Send + Sync { - async fn get_claim(&self, id: Uuid) -> Result; - async fn create_claim(&self, entity: GachaClaimEntity) -> Result<(), AppError>; + async fn get_claim(&self, id: Uuid) -> Result; + async fn create_claim(&self, entity: GachaClaimEntity) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_claims/infrastructure/http/dto.rs b/imphnen-gacha/src/gacha_claims/infrastructure/http/dto.rs index 64d9ff1..02614b6 100644 --- a/imphnen-gacha/src/gacha_claims/infrastructure/http/dto.rs +++ b/imphnen-gacha/src/gacha_claims/infrastructure/http/dto.rs @@ -1,41 +1,41 @@ -use imphnen_libs::ZodValidate; -use imphnen_iam::users::infrastructure::http::dto::UsersDetailItemDto; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; use crate::gacha_claims::domain::gacha_claim::GachaClaimDetail; use crate::gacha_items::infrastructure::http::dto::GachaItemDto; +use imphnen_iam::users::infrastructure::http::dto::UsersDetailItemDto; +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaClaimCreateRequestDto { - pub user_id: String, - pub item_id: String, + pub user_id: String, + pub item_id: String, } impl ZodValidate for GachaClaimCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaClaimDetailDto { - pub id: String, - pub user: UsersDetailItemDto, - pub item: GachaItemDto, - pub is_deleted: bool, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub user: UsersDetailItemDto, + pub item: GachaItemDto, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, } impl From for GachaClaimDetailDto { - fn from(detail: GachaClaimDetail) -> Self { - GachaClaimDetailDto { - id: detail.id.to_string(), - user: UsersDetailItemDto::from(&detail.user), - item: GachaItemDto::from(detail.item), - is_deleted: detail.is_deleted, - created_at: detail.created_at.to_rfc3339(), - updated_at: detail.updated_at.to_rfc3339(), - } - } + fn from(detail: GachaClaimDetail) -> Self { + GachaClaimDetailDto { + id: detail.id.to_string(), + user: UsersDetailItemDto::from(&detail.user), + item: GachaItemDto::from(detail.item), + is_deleted: detail.is_deleted, + created_at: detail.created_at.to_rfc3339(), + updated_at: detail.updated_at.to_rfc3339(), + } + } } diff --git a/imphnen-gacha/src/gacha_claims/infrastructure/http/handlers.rs b/imphnen-gacha/src/gacha_claims/infrastructure/http/handlers.rs index 29d12b9..4c23c1f 100644 --- a/imphnen-gacha/src/gacha_claims/infrastructure/http/handlers.rs +++ b/imphnen-gacha/src/gacha_claims/infrastructure/http/handlers.rs @@ -1,13 +1,13 @@ -use std::sync::Arc; -use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiMessage}; -use imphnen_entities::ResponseSuccessDto; -use imphnen_iam::{PermissionsEnum, require_permissions}; -use imphnen_utils::AppError; -use uuid::Uuid; use super::dto::{GachaClaimCreateRequestDto, GachaClaimDetailDto}; use crate::gacha_claims::domain::{GachaClaimEntity, GachaClaimService}; +use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -22,17 +22,17 @@ use crate::gacha_claims::domain::{GachaClaimEntity, GachaClaimService}; tag = "Gacha" )] pub async fn get_gacha_claim_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaClaims], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let detail = service.get_claim(uuid).await?; - Ok(ApiSuccess(GachaClaimDetailDto::from(detail))) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaClaims], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let detail = service.get_claim(uuid).await?; + Ok(ApiSuccess(GachaClaimDetailDto::from(detail))) + }) } #[utoipa::path( @@ -46,32 +46,34 @@ pub async fn get_gacha_claim_by_id( tag = "Gacha" )] pub async fn post_create_gacha_claim( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::CreateGachaClaims], { - let user_id = Uuid::parse_str(&payload.user_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid user_id UUID: {e}")))?; - let item_id = Uuid::parse_str(&payload.item_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid item_id UUID: {e}")))?; - let entity = GachaClaimEntity { - id: Uuid::new_v4(), - user_id, - gacha_item_id: item_id, - claim_id: Uuid::new_v4(), - claim_type: "standard".to_string(), - status: "claimed".to_string(), - quantity: 1, - metadata: None, - is_deleted: false, - claimed_at: chrono::Utc::now(), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - }; - service.create_claim(entity).await?; - Ok(ApiMessage::created("Gacha claim created")) - }) + require_permissions!(headers, state, [PermissionsEnum::CreateGachaClaims], { + let user_id = Uuid::parse_str(&payload.user_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid user_id UUID: {e}")) + })?; + let item_id = Uuid::parse_str(&payload.item_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid item_id UUID: {e}")) + })?; + let entity = GachaClaimEntity { + id: Uuid::new_v4(), + user_id, + gacha_item_id: item_id, + claim_id: Uuid::new_v4(), + claim_type: "standard".to_string(), + status: "claimed".to_string(), + quantity: 1, + metadata: None, + is_deleted: false, + claimed_at: chrono::Utc::now(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + }; + service.create_claim(entity).await?; + Ok(ApiMessage::created("Gacha claim created")) + }) } diff --git a/imphnen-gacha/src/gacha_claims/infrastructure/http/routes.rs b/imphnen-gacha/src/gacha_claims/infrastructure/http/routes.rs index 2d2b596..80a2e3d 100644 --- a/imphnen-gacha/src/gacha_claims/infrastructure/http/routes.rs +++ b/imphnen-gacha/src/gacha_claims/infrastructure/http/routes.rs @@ -1,20 +1,29 @@ -use std::sync::Arc; -use axum::{Router, routing::{get, post}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{get_gacha_claim_by_id, post_create_gacha_claim}; use crate::gacha_claims::application::GachaClaimServiceImpl; use crate::gacha_claims::domain::GachaClaimService; use crate::gacha_claims::infrastructure::persistence::PostgresGachaClaimRepository; -use super::handlers::{get_gacha_claim_by_id, post_create_gacha_claim}; +use axum::{ + Extension, Router, + routing::{get, post}, +}; +use sea_orm::DatabaseConnection; +use std::sync::Arc; -fn build_service(db: DatabaseConnection, state: std::sync::Arc) -> Arc { - let repo = Arc::new(PostgresGachaClaimRepository::new(db, state)); - Arc::new(GachaClaimServiceImpl::new(repo)) +fn build_service( + db: DatabaseConnection, + state: std::sync::Arc, +) -> Arc { + let repo = Arc::new(PostgresGachaClaimRepository::new(db, state)); + Arc::new(GachaClaimServiceImpl::new(repo)) } -pub fn gacha_claim_router(db: DatabaseConnection, state: std::sync::Arc) -> Router { - let service = build_service(db, state); - Router::new() - .route("/detail/{id}", get(get_gacha_claim_by_id)) - .route("/create", post(post_create_gacha_claim)) - .layer(Extension(service)) +pub fn gacha_claim_router( + db: DatabaseConnection, + state: std::sync::Arc, +) -> Router { + let service = build_service(db, state); + Router::new() + .route("/detail/{id}", get(get_gacha_claim_by_id)) + .route("/create", post(post_create_gacha_claim)) + .layer(Extension(service)) } diff --git a/imphnen-gacha/src/gacha_claims/infrastructure/persistence/postgres_gacha_claim_repository.rs b/imphnen-gacha/src/gacha_claims/infrastructure/persistence/postgres_gacha_claim_repository.rs index 5d5ca56..0491e5d 100644 --- a/imphnen-gacha/src/gacha_claims/infrastructure/persistence/postgres_gacha_claim_repository.rs +++ b/imphnen-gacha/src/gacha_claims/infrastructure/persistence/postgres_gacha_claim_repository.rs @@ -1,105 +1,109 @@ -use std::sync::Arc; +use crate::gacha_claims::domain::{ + gacha_claim::{GachaClaimDetail, GachaClaimEntity}, + repository::GachaClaimRepository, +}; +use crate::gacha_items::domain::gacha_item::GachaItemEntity; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::ActiveValue; -use uuid::Uuid; -use imphnen_utils::AppError; use imphnen_entities::seaorm::gacha::gacha_claims::{ - Entity as GachaClaimsEntity, ActiveModel as GachaClaimsActiveModel, + ActiveModel as GachaClaimsActiveModel, Entity as GachaClaimsEntity, }; use imphnen_entities::seaorm::gacha::gacha_items::Entity as GachaItemsEntity; use imphnen_libs::AppState; -use crate::gacha_claims::domain::{ - gacha_claim::{GachaClaimDetail, GachaClaimEntity}, - repository::GachaClaimRepository, -}; -use crate::gacha_items::domain::gacha_item::GachaItemEntity; +use imphnen_utils::AppError; +use sea_orm::ActiveValue; +use sea_orm::prelude::*; +use std::sync::Arc; +use uuid::Uuid; pub struct PostgresGachaClaimRepository { - db: Arc, - state: Arc, + db: Arc, + state: Arc, } impl PostgresGachaClaimRepository { - pub fn new(db: DatabaseConnection, state: Arc) -> Self { - Self { - db: Arc::new(db), - state, - } - } + pub fn new(db: DatabaseConnection, state: Arc) -> Self { + Self { + db: Arc::new(db), + state, + } + } } #[async_trait] impl GachaClaimRepository for PostgresGachaClaimRepository { - async fn find_by_id(&self, id: Uuid) -> Result { - let claim = GachaClaimsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha claim not found".to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result { + let claim = GachaClaimsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha claim not found".to_string()))?; - let user = self.state.user_lookup_service - .get_user_by_id(claim.user_id, self.state.as_ref()) - .await - .map(|info| info.basic_info) - .map_err(|e| AppError::InternalServerError(format!("Failed to fetch user: {e}")))?; + let user = self + .state + .user_lookup_service + .get_user_by_id(claim.user_id, self.state.as_ref()) + .await + .map(|info| info.basic_info) + .map_err(|e| { + AppError::InternalServerError(format!("Failed to fetch user: {e}")) + })?; - let item_model = GachaItemsEntity::find_by_id(claim.gacha_item_id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))?; + let item_model = GachaItemsEntity::find_by_id(claim.gacha_item_id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))?; - let item = GachaItemEntity { - id: item_model.id, - item_code: item_model.item_code, - name: item_model.name, - description: item_model.description, - rarity: item_model.rarity, - type_: item_model.type_, - category: item_model.category, - value: item_model.value, - weight: item_model.weight, - stock: item_model.stock, - is_limited: item_model.is_limited, - metadata: item_model.metadata, - is_deleted: item_model.deleted_at.is_some(), - created_at: item_model.created_at, - updated_at: item_model.updated_at, - deleted_at: item_model.deleted_at, - }; + let item = GachaItemEntity { + id: item_model.id, + item_code: item_model.item_code, + name: item_model.name, + description: item_model.description, + rarity: item_model.rarity, + type_: item_model.type_, + category: item_model.category, + value: item_model.value, + weight: item_model.weight, + stock: item_model.stock, + is_limited: item_model.is_limited, + metadata: item_model.metadata, + is_deleted: item_model.deleted_at.is_some(), + created_at: item_model.created_at, + updated_at: item_model.updated_at, + deleted_at: item_model.deleted_at, + }; - Ok(GachaClaimDetail { - id: claim.id, - user, - item, - is_deleted: claim.deleted_at.is_some(), - created_at: claim.created_at, - updated_at: claim.updated_at, - }) - } + Ok(GachaClaimDetail { + id: claim.id, + user, + item, + is_deleted: claim.deleted_at.is_some(), + created_at: claim.created_at, + updated_at: claim.updated_at, + }) + } - async fn create(&self, entity: GachaClaimEntity) -> Result<(), AppError> { - let active_model = GachaClaimsActiveModel { - id: ActiveValue::Set(entity.id), - user_id: ActiveValue::Set(entity.user_id), - gacha_item_id: ActiveValue::Set(entity.gacha_item_id), - claim_id: ActiveValue::Set(entity.claim_id), - claim_type: ActiveValue::Set(entity.claim_type), - status: ActiveValue::Set(entity.status), - quantity: ActiveValue::Set(entity.quantity), - metadata: ActiveValue::Set(entity.metadata), - created_at: ActiveValue::Set(entity.created_at), - updated_at: ActiveValue::Set(entity.updated_at), - deleted_at: ActiveValue::Set(entity.deleted_at), - claimed_at: ActiveValue::Set(entity.claimed_at), - }; + async fn create(&self, entity: GachaClaimEntity) -> Result<(), AppError> { + let active_model = GachaClaimsActiveModel { + id: ActiveValue::Set(entity.id), + user_id: ActiveValue::Set(entity.user_id), + gacha_item_id: ActiveValue::Set(entity.gacha_item_id), + claim_id: ActiveValue::Set(entity.claim_id), + claim_type: ActiveValue::Set(entity.claim_type), + status: ActiveValue::Set(entity.status), + quantity: ActiveValue::Set(entity.quantity), + metadata: ActiveValue::Set(entity.metadata), + created_at: ActiveValue::Set(entity.created_at), + updated_at: ActiveValue::Set(entity.updated_at), + deleted_at: ActiveValue::Set(entity.deleted_at), + claimed_at: ActiveValue::Set(entity.claimed_at), + }; - GachaClaimsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + GachaClaimsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } } diff --git a/imphnen-gacha/src/gacha_credits/application/gacha_credit_service.rs b/imphnen-gacha/src/gacha_credits/application/gacha_credit_service.rs index 0cc8139..fb76862 100644 --- a/imphnen-gacha/src/gacha_credits/application/gacha_credit_service.rs +++ b/imphnen-gacha/src/gacha_credits/application/gacha_credit_service.rs @@ -1,30 +1,35 @@ -use std::sync::Arc; +use crate::gacha_credits::domain::{ + GachaCreditEntity, GachaCreditRepository, GachaCreditService, +}; use async_trait::async_trait; -use uuid::Uuid; use imphnen_utils::AppError; -use crate::gacha_credits::domain::{GachaCreditEntity, GachaCreditRepository, GachaCreditService}; +use std::sync::Arc; +use uuid::Uuid; pub struct GachaCreditServiceImpl { - repo: Arc, + repo: Arc, } impl GachaCreditServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl GachaCreditService for GachaCreditServiceImpl { - async fn get_credits(&self, user_id: Uuid) -> Result, AppError> { - self.repo.find_by_user_id(user_id).await - } + async fn get_credits( + &self, + user_id: Uuid, + ) -> Result, AppError> { + self.repo.find_by_user_id(user_id).await + } - async fn add_credits(&self, user_id: Uuid, amount: i32) -> Result<(), AppError> { - self.repo.add_credit(user_id, amount).await - } + async fn add_credits(&self, user_id: Uuid, amount: i32) -> Result<(), AppError> { + self.repo.add_credit(user_id, amount).await + } - async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError> { - self.repo.consume_credit(user_id).await - } + async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError> { + self.repo.consume_credit(user_id).await + } } diff --git a/imphnen-gacha/src/gacha_credits/domain/gacha_credit.rs b/imphnen-gacha/src/gacha_credits/domain/gacha_credit.rs index c207b6d..cfc0bc6 100644 --- a/imphnen-gacha/src/gacha_credits/domain/gacha_credit.rs +++ b/imphnen-gacha/src/gacha_credits/domain/gacha_credit.rs @@ -3,10 +3,10 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct GachaCreditEntity { - pub id: Uuid, - pub user_id: Uuid, - pub available_rolls: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: Uuid, + pub user_id: Uuid, + pub available_rolls: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } diff --git a/imphnen-gacha/src/gacha_credits/domain/repository.rs b/imphnen-gacha/src/gacha_credits/domain/repository.rs index ce6be0d..6d0ac2e 100644 --- a/imphnen-gacha/src/gacha_credits/domain/repository.rs +++ b/imphnen-gacha/src/gacha_credits/domain/repository.rs @@ -1,11 +1,14 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_credit::GachaCreditEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaCreditRepository: Send + Sync { - async fn find_by_user_id(&self, user_id: Uuid) -> Result, AppError>; - async fn add_credit(&self, user_id: Uuid, amount: i32) -> Result<(), AppError>; - async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError>; + async fn find_by_user_id( + &self, + user_id: Uuid, + ) -> Result, AppError>; + async fn add_credit(&self, user_id: Uuid, amount: i32) -> Result<(), AppError>; + async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_credits/domain/service.rs b/imphnen-gacha/src/gacha_credits/domain/service.rs index bd8f65e..201f7d1 100644 --- a/imphnen-gacha/src/gacha_credits/domain/service.rs +++ b/imphnen-gacha/src/gacha_credits/domain/service.rs @@ -1,11 +1,14 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_credit::GachaCreditEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaCreditService: Send + Sync { - async fn get_credits(&self, user_id: Uuid) -> Result, AppError>; - async fn add_credits(&self, user_id: Uuid, amount: i32) -> Result<(), AppError>; - async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError>; + async fn get_credits( + &self, + user_id: Uuid, + ) -> Result, AppError>; + async fn add_credits(&self, user_id: Uuid, amount: i32) -> Result<(), AppError>; + async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_credits/infrastructure/http/dto.rs b/imphnen-gacha/src/gacha_credits/infrastructure/http/dto.rs index e0bc606..bbb1b18 100644 --- a/imphnen-gacha/src/gacha_credits/infrastructure/http/dto.rs +++ b/imphnen-gacha/src/gacha_credits/infrastructure/http/dto.rs @@ -1,38 +1,38 @@ +use crate::gacha_credits::domain::gacha_credit::GachaCreditEntity; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::gacha_credits::domain::gacha_credit::GachaCreditEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaCreditAddRequestDto { - pub amount: i32, + pub amount: i32, } impl ZodValidate for GachaCreditAddRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaCreditDto { - pub id: String, - pub user_id: String, - pub available_rolls: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub user_id: String, + pub available_rolls: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } impl From for GachaCreditDto { - fn from(e: GachaCreditEntity) -> Self { - GachaCreditDto { - id: e.id.to_string(), - user_id: e.user_id.to_string(), - available_rolls: e.available_rolls, - is_deleted: e.is_deleted, - created_at: e.created_at.map(|d| d.to_string()), - updated_at: e.updated_at.map(|d| d.to_string()), - } - } + fn from(e: GachaCreditEntity) -> Self { + GachaCreditDto { + id: e.id.to_string(), + user_id: e.user_id.to_string(), + available_rolls: e.available_rolls, + is_deleted: e.is_deleted, + created_at: e.created_at.map(|d| d.to_string()), + updated_at: e.updated_at.map(|d| d.to_string()), + } + } } diff --git a/imphnen-gacha/src/gacha_credits/infrastructure/http/handlers.rs b/imphnen-gacha/src/gacha_credits/infrastructure/http/handlers.rs index 995a28f..512ee3d 100644 --- a/imphnen-gacha/src/gacha_credits/infrastructure/http/handlers.rs +++ b/imphnen-gacha/src/gacha_credits/infrastructure/http/handlers.rs @@ -1,13 +1,13 @@ -use std::sync::Arc; -use axum::{Extension, http::HeaderMap, response::IntoResponse}; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiMessage, extract_email}; -use imphnen_entities::ResponseSuccessDto; -use imphnen_iam::{PermissionsEnum, require_permissions}; -use imphnen_utils::AppError; -use uuid::Uuid; use super::dto::{GachaCreditAddRequestDto, GachaCreditDto}; use crate::gacha_credits::domain::GachaCreditService; +use axum::{Extension, http::HeaderMap, response::IntoResponse}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiSuccess, extract_email}; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -19,30 +19,38 @@ use crate::gacha_credits::domain::GachaCreditService; tag = "Gacha" )] pub async fn get_user_credits( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, ) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::ReadDetailGachaItems], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user = user_info.basic_info; - let user_id = Uuid::parse_str(&user.id) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - match service.get_credits(user_id).await? { - Some(credit) => Ok(ApiSuccess(GachaCreditDto::from(credit))), - None => Ok(ApiSuccess(GachaCreditDto { - id: "".to_string(), - user_id: user.id, - available_rolls: 0, - is_deleted: false, - created_at: None, - updated_at: None, - })), - } - }) + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::ReadDetailGachaItems], + { + let email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user = user_info.basic_info; + let user_id = Uuid::parse_str(&user.id) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + match service.get_credits(user_id).await? { + Some(credit) => Ok(ApiSuccess(GachaCreditDto::from(credit))), + None => Ok(ApiSuccess(GachaCreditDto { + id: "".to_string(), + user_id: user.id, + available_rolls: 0, + is_deleted: false, + created_at: None, + updated_at: None, + })), + } + } + ) } #[utoipa::path( @@ -56,21 +64,32 @@ pub async fn get_user_credits( tag = "Gacha" )] pub async fn post_add_credits( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::CreateGachaItems], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user_id = Uuid::parse_str(&user_info.basic_info.id) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - service.add_credits(user_id, payload.amount).await?; - Ok(ApiMessage::ok(format!("Added {} credits successfully", payload.amount))) - }) + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::CreateGachaItems], + { + let email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user_id = Uuid::parse_str(&user_info.basic_info.id) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + service.add_credits(user_id, payload.amount).await?; + Ok(ApiMessage::ok(format!( + "Added {} credits successfully", + payload.amount + ))) + } + ) } #[utoipa::path( @@ -83,18 +102,26 @@ pub async fn post_add_credits( tag = "Gacha" )] pub async fn post_consume_credit( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, ) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::UpdateGachaItems], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user_id = Uuid::parse_str(&user_info.basic_info.id) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - service.consume_credit(user_id).await?; - Ok(ApiMessage::ok("Consumed 1 credit successfully")) - }) + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::UpdateGachaItems], + { + let email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user_id = Uuid::parse_str(&user_info.basic_info.id) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + service.consume_credit(user_id).await?; + Ok(ApiMessage::ok("Consumed 1 credit successfully")) + } + ) } diff --git a/imphnen-gacha/src/gacha_credits/infrastructure/http/routes.rs b/imphnen-gacha/src/gacha_credits/infrastructure/http/routes.rs index 0d70ead..6c5e83d 100644 --- a/imphnen-gacha/src/gacha_credits/infrastructure/http/routes.rs +++ b/imphnen-gacha/src/gacha_credits/infrastructure/http/routes.rs @@ -1,21 +1,24 @@ -use std::sync::Arc; -use axum::{Router, routing::{get, post}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{get_user_credits, post_add_credits, post_consume_credit}; use crate::gacha_credits::application::GachaCreditServiceImpl; use crate::gacha_credits::domain::GachaCreditService; use crate::gacha_credits::infrastructure::persistence::PostgresGachaCreditRepository; -use super::handlers::{get_user_credits, post_add_credits, post_consume_credit}; +use axum::{ + Extension, Router, + routing::{get, post}, +}; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresGachaCreditRepository::new(db)); - Arc::new(GachaCreditServiceImpl::new(repo)) + let repo = Arc::new(PostgresGachaCreditRepository::new(db)); + Arc::new(GachaCreditServiceImpl::new(repo)) } pub fn gacha_credit_router(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/", get(get_user_credits)) - .route("/add", post(post_add_credits)) - .route("/consume", post(post_consume_credit)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/", get(get_user_credits)) + .route("/add", post(post_add_credits)) + .route("/consume", post(post_consume_credit)) + .layer(Extension(service)) } diff --git a/imphnen-gacha/src/gacha_credits/infrastructure/persistence/postgres_gacha_credit_repository.rs b/imphnen-gacha/src/gacha_credits/infrastructure/persistence/postgres_gacha_credit_repository.rs index 508ab90..89a0c65 100644 --- a/imphnen-gacha/src/gacha_credits/infrastructure/persistence/postgres_gacha_credit_repository.rs +++ b/imphnen-gacha/src/gacha_credits/infrastructure/persistence/postgres_gacha_credit_repository.rs @@ -1,105 +1,116 @@ -use std::sync::Arc; -use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::ActiveValue; -use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::gacha::gacha_credits::{ - self, Entity as GachaCreditsEntity, Column as GachaCreditsColumn, - ActiveModel as GachaCreditsActiveModel, +use crate::gacha_credits::domain::{ + gacha_credit::GachaCreditEntity, repository::GachaCreditRepository, }; -use crate::gacha_credits::domain::{gacha_credit::GachaCreditEntity, repository::GachaCreditRepository}; +use async_trait::async_trait; +use imphnen_entities::seaorm::gacha::gacha_credits::{ + self, ActiveModel as GachaCreditsActiveModel, Column as GachaCreditsColumn, + Entity as GachaCreditsEntity, +}; +use imphnen_utils::AppError; +use sea_orm::ActiveValue; +use sea_orm::prelude::*; +use std::sync::Arc; +use uuid::Uuid; fn to_entity(model: gacha_credits::Model) -> GachaCreditEntity { - GachaCreditEntity { - id: model.id, - user_id: model.user_id, - available_rolls: model.available_rolls, - is_deleted: model.is_deleted, - created_at: model.created_at, - updated_at: model.updated_at, - } + GachaCreditEntity { + id: model.id, + user_id: model.user_id, + available_rolls: model.available_rolls, + is_deleted: model.is_deleted, + created_at: model.created_at, + updated_at: model.updated_at, + } } pub struct PostgresGachaCreditRepository { - db: Arc, + db: Arc, } impl PostgresGachaCreditRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl GachaCreditRepository for PostgresGachaCreditRepository { - async fn find_by_user_id(&self, user_id: Uuid) -> Result, AppError> { - let result = GachaCreditsEntity::find() - .filter(GachaCreditsColumn::UserId.eq(user_id)) - .filter(GachaCreditsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn find_by_user_id( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let result = GachaCreditsEntity::find() + .filter(GachaCreditsColumn::UserId.eq(user_id)) + .filter(GachaCreditsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(result.map(to_entity)) - } + Ok(result.map(to_entity)) + } - async fn add_credit(&self, user_id: Uuid, amount: i32) -> Result<(), AppError> { - let existing = GachaCreditsEntity::find() - .filter(GachaCreditsColumn::UserId.eq(user_id)) - .filter(GachaCreditsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn add_credit(&self, user_id: Uuid, amount: i32) -> Result<(), AppError> { + let existing = GachaCreditsEntity::find() + .filter(GachaCreditsColumn::UserId.eq(user_id)) + .filter(GachaCreditsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - if let Some(credit) = existing { - let mut active_model: GachaCreditsActiveModel = credit.clone().into(); - active_model.available_rolls = ActiveValue::Set(credit.available_rolls + amount); - active_model.updated_at = ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); - GachaCreditsEntity::update(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - } else { - let active_model = GachaCreditsActiveModel { - id: ActiveValue::Set(Uuid::new_v4()), - user_id: ActiveValue::Set(user_id), - available_rolls: ActiveValue::Set(amount), - is_deleted: ActiveValue::Set(false), - created_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), - updated_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), - }; - GachaCreditsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - } + if let Some(credit) = existing { + let mut active_model: GachaCreditsActiveModel = credit.clone().into(); + active_model.available_rolls = + ActiveValue::Set(credit.available_rolls + amount); + active_model.updated_at = + ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); + GachaCreditsEntity::update(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + } else { + let active_model = GachaCreditsActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + user_id: ActiveValue::Set(user_id), + available_rolls: ActiveValue::Set(amount), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), + updated_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), + }; + GachaCreditsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + } - Ok(()) - } + Ok(()) + } - async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError> { - let credit = GachaCreditsEntity::find() - .filter(GachaCreditsColumn::UserId.eq(user_id)) - .filter(GachaCreditsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("No credit record found".to_string()))?; + async fn consume_credit(&self, user_id: Uuid) -> Result<(), AppError> { + let credit = GachaCreditsEntity::find() + .filter(GachaCreditsColumn::UserId.eq(user_id)) + .filter(GachaCreditsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| { + AppError::NotFoundError("No credit record found".to_string()) + })?; - if credit.available_rolls <= 0 { - return Err(AppError::BadRequestError("No extra roll credits remaining".to_string())); - } + if credit.available_rolls <= 0 { + return Err(AppError::BadRequestError( + "No extra roll credits remaining".to_string(), + )); + } - let mut active_model: GachaCreditsActiveModel = credit.clone().into(); - active_model.available_rolls = ActiveValue::Set(credit.available_rolls - 1); - active_model.updated_at = ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); + let mut active_model: GachaCreditsActiveModel = credit.clone().into(); + active_model.available_rolls = ActiveValue::Set(credit.available_rolls - 1); + active_model.updated_at = ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); - GachaCreditsEntity::update(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + GachaCreditsEntity::update(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } } diff --git a/imphnen-gacha/src/gacha_items/application/gacha_item_service.rs b/imphnen-gacha/src/gacha_items/application/gacha_item_service.rs index 8472205..3d917b0 100644 --- a/imphnen-gacha/src/gacha_items/application/gacha_item_service.rs +++ b/imphnen-gacha/src/gacha_items/application/gacha_item_service.rs @@ -1,40 +1,45 @@ -use std::sync::Arc; +use crate::gacha_items::domain::{ + GachaItemEntity, GachaItemRepository, GachaItemService, +}; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use crate::gacha_items::domain::{GachaItemEntity, GachaItemRepository, GachaItemService}; pub struct GachaItemServiceImpl { - repo: Arc, + repo: Arc, } impl GachaItemServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl GachaItemService for GachaItemServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: Uuid) -> Result { - self.repo.find_by_id(id).await - } + async fn get(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } - async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError> { - self.repo.create(entity).await - } + async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError> { + self.repo.create(entity).await + } - async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError> { - self.repo.update(entity).await - } + async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError> { + self.repo.update(entity).await + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.delete(id).await - } + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } } diff --git a/imphnen-gacha/src/gacha_items/domain/gacha_item.rs b/imphnen-gacha/src/gacha_items/domain/gacha_item.rs index ab142de..af0201c 100644 --- a/imphnen-gacha/src/gacha_items/domain/gacha_item.rs +++ b/imphnen-gacha/src/gacha_items/domain/gacha_item.rs @@ -4,20 +4,20 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct GachaItemEntity { - pub id: Uuid, - pub item_code: String, - pub name: String, - pub description: String, - pub rarity: String, - pub type_: String, - pub category: String, - pub value: i32, - pub weight: f64, - pub stock: i32, - pub is_limited: bool, - pub metadata: Option, - pub is_deleted: bool, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, + pub id: Uuid, + pub item_code: String, + pub name: String, + pub description: String, + pub rarity: String, + pub type_: String, + pub category: String, + pub value: i32, + pub weight: f64, + pub stock: i32, + pub is_limited: bool, + pub metadata: Option, + pub is_deleted: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, } diff --git a/imphnen-gacha/src/gacha_items/domain/repository.rs b/imphnen-gacha/src/gacha_items/domain/repository.rs index cccd868..eb98a2a 100644 --- a/imphnen-gacha/src/gacha_items/domain/repository.rs +++ b/imphnen-gacha/src/gacha_items/domain/repository.rs @@ -1,15 +1,18 @@ +use super::gacha_item::GachaItemEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::gacha_item::GachaItemEntity; #[async_trait] pub trait GachaItemRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: Uuid) -> Result; - async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError>; - async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError>; + async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_items/domain/service.rs b/imphnen-gacha/src/gacha_items/domain/service.rs index 6343936..b38d0db 100644 --- a/imphnen-gacha/src/gacha_items/domain/service.rs +++ b/imphnen-gacha/src/gacha_items/domain/service.rs @@ -1,15 +1,18 @@ +use super::gacha_item::GachaItemEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; use uuid::Uuid; -use imphnen_utils::AppError; -use super::gacha_item::GachaItemEntity; #[async_trait] pub trait GachaItemService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: Uuid) -> Result; - async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError>; - async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: Uuid) -> Result; + async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError>; + async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_items/infrastructure/http/dto.rs b/imphnen-gacha/src/gacha_items/infrastructure/http/dto.rs index 986095a..27e21db 100644 --- a/imphnen-gacha/src/gacha_items/infrastructure/http/dto.rs +++ b/imphnen-gacha/src/gacha_items/infrastructure/http/dto.rs @@ -1,92 +1,92 @@ +use crate::gacha_items::domain::gacha_item::GachaItemEntity; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use serde_json::Value; use utoipa::ToSchema; use uuid::Uuid; -use crate::gacha_items::domain::gacha_item::GachaItemEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaItemCreateRequestDto { - pub item_code: String, - pub name: String, - pub description: String, - pub rarity: String, - pub type_: String, - pub category: String, - pub value: i32, - pub weight: f64, - pub stock: i32, - pub is_limited: bool, - pub metadata: Option, + pub item_code: String, + pub name: String, + pub description: String, + pub rarity: String, + pub type_: String, + pub category: String, + pub value: i32, + pub weight: f64, + pub stock: i32, + pub is_limited: bool, + pub metadata: Option, } impl ZodValidate for GachaItemCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } impl From for GachaItemEntity { - fn from(dto: GachaItemCreateRequestDto) -> Self { - GachaItemEntity { - id: Uuid::new_v4(), - item_code: dto.item_code, - name: dto.name, - description: dto.description, - rarity: dto.rarity, - type_: dto.type_, - category: dto.category, - value: dto.value, - weight: dto.weight, - stock: dto.stock, - is_limited: dto.is_limited, - metadata: dto.metadata, - is_deleted: false, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - } - } + fn from(dto: GachaItemCreateRequestDto) -> Self { + GachaItemEntity { + id: Uuid::new_v4(), + item_code: dto.item_code, + name: dto.name, + description: dto.description, + rarity: dto.rarity, + type_: dto.type_, + category: dto.category, + value: dto.value, + weight: dto.weight, + stock: dto.stock, + is_limited: dto.is_limited, + metadata: dto.metadata, + is_deleted: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaItemUpdateRequestDto { - pub item_code: String, - pub name: String, - pub description: String, - pub rarity: String, - pub type_: String, - pub category: String, - pub value: i32, - pub weight: f64, - pub stock: i32, - pub is_limited: bool, - pub metadata: Option, + pub item_code: String, + pub name: String, + pub description: String, + pub rarity: String, + pub type_: String, + pub category: String, + pub value: i32, + pub weight: f64, + pub stock: i32, + pub is_limited: bool, + pub metadata: Option, } impl ZodValidate for GachaItemUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaItemDto { - pub id: String, - pub name: String, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub name: String, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } impl From for GachaItemDto { - fn from(e: GachaItemEntity) -> Self { - GachaItemDto { - id: e.id.to_string(), - name: e.name, - is_deleted: e.is_deleted, - created_at: Some(e.created_at.to_rfc3339()), - updated_at: Some(e.updated_at.to_rfc3339()), - } - } + fn from(e: GachaItemEntity) -> Self { + GachaItemDto { + id: e.id.to_string(), + name: e.name, + is_deleted: e.is_deleted, + created_at: Some(e.created_at.to_rfc3339()), + updated_at: Some(e.updated_at.to_rfc3339()), + } + } } diff --git a/imphnen-gacha/src/gacha_items/infrastructure/http/handlers.rs b/imphnen-gacha/src/gacha_items/infrastructure/http/handlers.rs index fa0c200..1baa19e 100644 --- a/imphnen-gacha/src/gacha_items/infrastructure/http/handlers.rs +++ b/imphnen-gacha/src/gacha_items/infrastructure/http/handlers.rs @@ -1,15 +1,17 @@ -use std::sync::Arc; +use super::dto::{ + GachaItemCreateRequestDto, GachaItemDto, GachaItemUpdateRequestDto, +}; +use crate::gacha_items::domain::{GachaItemEntity, GachaItemService}; use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; -use paginator_axum::PaginationQuery; -use paginator_utils::PaginatorResponse; -use uuid::Uuid; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage}; use imphnen_entities::ResponseSuccessDto; use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; use imphnen_utils::AppError; -use super::dto::{GachaItemCreateRequestDto, GachaItemDto, GachaItemUpdateRequestDto}; -use crate::gacha_items::domain::{GachaItemEntity, GachaItemService}; +use imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -28,19 +30,23 @@ use crate::gacha_items::domain::{GachaItemEntity, GachaItemService}; tag = "Gacha" )] pub async fn get_gacha_item_list( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadListGachaItems], { - let result = service.list(params).await?; - let mapped = PaginatorResponse { - data: result.data.into_iter().map(GachaItemDto::from).collect::>(), - meta: result.meta, - }; - Ok(ApiPaginated(mapped)) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadListGachaItems], { + let result = service.list(params).await?; + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(GachaItemDto::from) + .collect::>(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) + }) } #[utoipa::path( @@ -56,17 +62,17 @@ pub async fn get_gacha_item_list( tag = "Gacha" )] pub async fn get_gacha_item_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaItems], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let item = service.get(uuid).await?; - Ok(ApiSuccess(GachaItemDto::from(item))) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaItems], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let item = service.get(uuid).await?; + Ok(ApiSuccess(GachaItemDto::from(item))) + }) } #[utoipa::path( @@ -80,16 +86,16 @@ pub async fn get_gacha_item_by_id( tag = "Gacha" )] pub async fn post_create_gacha_item( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::CreateGachaItems], { - let entity: GachaItemEntity = payload.into(); - service.create(entity).await?; - Ok(ApiMessage::created("Gacha item created")) - }) + require_permissions!(headers, state, [PermissionsEnum::CreateGachaItems], { + let entity: GachaItemEntity = payload.into(); + service.create(entity).await?; + Ok(ApiMessage::created("Gacha item created")) + }) } #[utoipa::path( @@ -106,37 +112,37 @@ pub async fn post_create_gacha_item( tag = "Gacha" )] pub async fn put_update_gacha_item( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::UpdateGachaItems], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let existing = service.get(uuid).await?; - let entity = GachaItemEntity { - id: existing.id, - item_code: payload.item_code, - name: payload.name, - description: payload.description, - rarity: payload.rarity, - type_: payload.type_, - category: payload.category, - value: payload.value, - weight: payload.weight, - stock: payload.stock, - is_limited: payload.is_limited, - metadata: payload.metadata, - is_deleted: existing.is_deleted, - created_at: existing.created_at, - updated_at: chrono::Utc::now(), - deleted_at: existing.deleted_at, - }; - service.update(entity).await?; - Ok(ApiMessage::ok("Gacha item updated")) - }) + require_permissions!(headers, state, [PermissionsEnum::UpdateGachaItems], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let existing = service.get(uuid).await?; + let entity = GachaItemEntity { + id: existing.id, + item_code: payload.item_code, + name: payload.name, + description: payload.description, + rarity: payload.rarity, + type_: payload.type_, + category: payload.category, + value: payload.value, + weight: payload.weight, + stock: payload.stock, + is_limited: payload.is_limited, + metadata: payload.metadata, + is_deleted: existing.is_deleted, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + deleted_at: existing.deleted_at, + }; + service.update(entity).await?; + Ok(ApiMessage::ok("Gacha item updated")) + }) } #[utoipa::path( @@ -152,15 +158,15 @@ pub async fn put_update_gacha_item( tag = "Gacha" )] pub async fn delete_gacha_item( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::DeleteGachaItems], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - service.delete(uuid).await?; - Ok(ApiMessage::ok("Gacha item deleted")) - }) + require_permissions!(headers, state, [PermissionsEnum::DeleteGachaItems], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + service.delete(uuid).await?; + Ok(ApiMessage::ok("Gacha item deleted")) + }) } diff --git a/imphnen-gacha/src/gacha_items/infrastructure/http/routes.rs b/imphnen-gacha/src/gacha_items/infrastructure/http/routes.rs index bbe1a9d..9398380 100644 --- a/imphnen-gacha/src/gacha_items/infrastructure/http/routes.rs +++ b/imphnen-gacha/src/gacha_items/infrastructure/http/routes.rs @@ -1,26 +1,29 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, post, put}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{ + delete_gacha_item, get_gacha_item_by_id, get_gacha_item_list, + post_create_gacha_item, put_update_gacha_item, +}; use crate::gacha_items::application::GachaItemServiceImpl; use crate::gacha_items::domain::GachaItemService; use crate::gacha_items::infrastructure::persistence::PostgresGachaItemRepository; -use super::handlers::{ - delete_gacha_item, get_gacha_item_by_id, get_gacha_item_list, - post_create_gacha_item, put_update_gacha_item, +use axum::{ + Extension, Router, + routing::{delete, get, post, put}, }; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresGachaItemRepository::new(db)); - Arc::new(GachaItemServiceImpl::new(repo)) + let repo = Arc::new(PostgresGachaItemRepository::new(db)); + Arc::new(GachaItemServiceImpl::new(repo)) } pub fn gacha_item_router(db: DatabaseConnection) -> Router { - let service = build_service(db); - Router::new() - .route("/", get(get_gacha_item_list)) - .route("/detail/{id}", get(get_gacha_item_by_id)) - .route("/create", post(post_create_gacha_item)) - .route("/update/{id}", put(put_update_gacha_item)) - .route("/delete/{id}", delete(delete_gacha_item)) - .layer(Extension(service)) + let service = build_service(db); + Router::new() + .route("/", get(get_gacha_item_list)) + .route("/detail/{id}", get(get_gacha_item_by_id)) + .route("/create", post(post_create_gacha_item)) + .route("/update/{id}", put(put_update_gacha_item)) + .route("/delete/{id}", delete(delete_gacha_item)) + .layer(Extension(service)) } diff --git a/imphnen-gacha/src/gacha_items/infrastructure/persistence/postgres_gacha_item_repository.rs b/imphnen-gacha/src/gacha_items/infrastructure/persistence/postgres_gacha_item_repository.rs index ac51e66..8e88e37 100644 --- a/imphnen-gacha/src/gacha_items/infrastructure/persistence/postgres_gacha_item_repository.rs +++ b/imphnen-gacha/src/gacha_items/infrastructure/persistence/postgres_gacha_item_repository.rs @@ -1,170 +1,180 @@ -use std::sync::Arc; +use crate::gacha_items::domain::{ + gacha_item::GachaItemEntity, repository::GachaItemRepository, +}; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait}; +use imphnen_entities::seaorm::gacha::gacha_items::{ + ActiveModel as GachaItemsActiveModel, Column as GachaItemsColumn, + Entity as GachaItemsEntity, Model as GachaItemsModel, +}; +use imphnen_utils::AppError; use paginator_rs::{PaginationParams, SortDirection}; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::gacha::gacha_items::{ - Entity as GachaItemsEntity, Column as GachaItemsColumn, - ActiveModel as GachaItemsActiveModel, Model as GachaItemsModel, -}; -use crate::gacha_items::domain::{gacha_item::GachaItemEntity, repository::GachaItemRepository}; fn to_entity(model: GachaItemsModel) -> GachaItemEntity { - GachaItemEntity { - id: model.id, - item_code: model.item_code, - name: model.name, - description: model.description, - rarity: model.rarity, - type_: model.type_, - category: model.category, - value: model.value, - weight: model.weight, - stock: model.stock, - is_limited: model.is_limited, - metadata: model.metadata, - is_deleted: model.deleted_at.is_some(), - created_at: model.created_at, - updated_at: model.updated_at, - deleted_at: model.deleted_at, - } + GachaItemEntity { + id: model.id, + item_code: model.item_code, + name: model.name, + description: model.description, + rarity: model.rarity, + type_: model.type_, + category: model.category, + value: model.value, + weight: model.weight, + stock: model.stock, + is_limited: model.is_limited, + metadata: model.metadata, + is_deleted: model.deleted_at.is_some(), + created_at: model.created_at, + updated_at: model.updated_at, + deleted_at: model.deleted_at, + } } pub struct PostgresGachaItemRepository { - db: Arc, + db: Arc, } impl PostgresGachaItemRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl GachaItemRepository for PostgresGachaItemRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); - let mut query = GachaItemsEntity::find() - .filter(GachaItemsColumn::DeletedAt.is_null()); + let mut query = + GachaItemsEntity::find().filter(GachaItemsColumn::DeletedAt.is_null()); - if let Some(ref search) = params.search { - query = query.filter(GachaItemsColumn::Name.contains(&search.query)); - } + if let Some(ref search) = params.search { + query = query.filter(GachaItemsColumn::Name.contains(&search.query)); + } - query = match params.sort_by.as_deref() { - Some("name") => match params.sort_direction { - Some(SortDirection::Desc) => query.order_by(GachaItemsColumn::Name, Order::Desc), - _ => query.order_by(GachaItemsColumn::Name, Order::Asc), - }, - _ => match params.sort_direction { - Some(SortDirection::Asc) => query.order_by(GachaItemsColumn::CreatedAt, Order::Asc), - _ => query.order_by(GachaItemsColumn::CreatedAt, Order::Desc), - }, - }; + query = match params.sort_by.as_deref() { + Some("name") => match params.sort_direction { + Some(SortDirection::Desc) => { + query.order_by(GachaItemsColumn::Name, Order::Desc) + } + _ => query.order_by(GachaItemsColumn::Name, Order::Asc), + }, + _ => match params.sort_direction { + Some(SortDirection::Asc) => { + query.order_by(GachaItemsColumn::CreatedAt, Order::Asc) + } + _ => query.order_by(GachaItemsColumn::CreatedAt, Order::Desc), + }, + }; - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let total = paginator - .num_items() - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let items = paginator - .fetch_page((page - 1) as u64) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let paginator = query.paginate(self.db.as_ref(), per_page as u64); + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let items = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let data = items.into_iter().map(to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } + let data = items.into_iter().map(to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) + } - async fn find_by_id(&self, id: Uuid) -> Result { - let item = GachaItemsEntity::find_by_id(id) - .filter(GachaItemsColumn::DeletedAt.is_null()) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result { + let item = GachaItemsEntity::find_by_id(id) + .filter(GachaItemsColumn::DeletedAt.is_null()) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))?; - Ok(to_entity(item)) - } + Ok(to_entity(item)) + } - async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError> { - let active_model = GachaItemsActiveModel { - id: ActiveValue::Set(entity.id), - item_code: ActiveValue::Set(entity.item_code), - name: ActiveValue::Set(entity.name), - description: ActiveValue::Set(entity.description), - rarity: ActiveValue::Set(entity.rarity), - type_: ActiveValue::Set(entity.type_), - category: ActiveValue::Set(entity.category), - value: ActiveValue::Set(entity.value), - weight: ActiveValue::Set(entity.weight), - stock: ActiveValue::Set(entity.stock), - is_limited: ActiveValue::Set(entity.is_limited), - metadata: ActiveValue::Set(entity.metadata), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - deleted_at: ActiveValue::Set(None), - }; + async fn create(&self, entity: GachaItemEntity) -> Result<(), AppError> { + let active_model = GachaItemsActiveModel { + id: ActiveValue::Set(entity.id), + item_code: ActiveValue::Set(entity.item_code), + name: ActiveValue::Set(entity.name), + description: ActiveValue::Set(entity.description), + rarity: ActiveValue::Set(entity.rarity), + type_: ActiveValue::Set(entity.type_), + category: ActiveValue::Set(entity.category), + value: ActiveValue::Set(entity.value), + weight: ActiveValue::Set(entity.weight), + stock: ActiveValue::Set(entity.stock), + is_limited: ActiveValue::Set(entity.is_limited), + metadata: ActiveValue::Set(entity.metadata), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + deleted_at: ActiveValue::Set(None), + }; - GachaItemsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + GachaItemsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError> { - let mut active_model: GachaItemsActiveModel = GachaItemsEntity::find_by_id(entity.id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))? - .into(); + async fn update(&self, entity: GachaItemEntity) -> Result<(), AppError> { + let mut active_model: GachaItemsActiveModel = + GachaItemsEntity::find_by_id(entity.id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))? + .into(); - active_model.item_code = ActiveValue::Set(entity.item_code); - active_model.name = ActiveValue::Set(entity.name); - active_model.description = ActiveValue::Set(entity.description); - active_model.rarity = ActiveValue::Set(entity.rarity); - active_model.type_ = ActiveValue::Set(entity.type_); - active_model.category = ActiveValue::Set(entity.category); - active_model.value = ActiveValue::Set(entity.value); - active_model.weight = ActiveValue::Set(entity.weight); - active_model.stock = ActiveValue::Set(entity.stock); - active_model.is_limited = ActiveValue::Set(entity.is_limited); - active_model.metadata = ActiveValue::Set(entity.metadata); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model.item_code = ActiveValue::Set(entity.item_code); + active_model.name = ActiveValue::Set(entity.name); + active_model.description = ActiveValue::Set(entity.description); + active_model.rarity = ActiveValue::Set(entity.rarity); + active_model.type_ = ActiveValue::Set(entity.type_); + active_model.category = ActiveValue::Set(entity.category); + active_model.value = ActiveValue::Set(entity.value); + active_model.weight = ActiveValue::Set(entity.weight); + active_model.stock = ActiveValue::Set(entity.stock); + active_model.is_limited = ActiveValue::Set(entity.is_limited); + active_model.metadata = ActiveValue::Set(entity.metadata); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model - .update(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - let mut active_model: GachaItemsActiveModel = GachaItemsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))? - .into(); + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let mut active_model: GachaItemsActiveModel = GachaItemsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha item not found".to_string()))? + .into(); - active_model.deleted_at = ActiveValue::Set(Some(chrono::Utc::now())); - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + active_model.deleted_at = ActiveValue::Set(Some(chrono::Utc::now())); + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model - .update(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } } diff --git a/imphnen-gacha/src/gacha_rolls/application/gacha_roll_service.rs b/imphnen-gacha/src/gacha_rolls/application/gacha_roll_service.rs index faf1bb2..044f970 100644 --- a/imphnen-gacha/src/gacha_rolls/application/gacha_roll_service.rs +++ b/imphnen-gacha/src/gacha_rolls/application/gacha_roll_service.rs @@ -1,141 +1,139 @@ -use std::sync::Arc; -use async_trait::async_trait; -use rand::prelude::*; -use uuid::Uuid; -use imphnen_utils::AppError; use crate::gacha_claims::domain::{GachaClaimEntity, GachaClaimRepository}; use crate::gacha_credits::domain::GachaCreditRepository; -use crate::gacha_rolls::domain::{GachaRollEntity, GachaRollRepository, GachaRollService}; +use crate::gacha_rolls::domain::{ + GachaRollEntity, GachaRollRepository, GachaRollService, +}; +use async_trait::async_trait; +use imphnen_utils::AppError; +use rand::prelude::*; +use std::sync::Arc; +use uuid::Uuid; pub struct GachaRollServiceImpl { - roll_repo: Arc, - credit_repo: Arc, - claim_repo: Arc, + roll_repo: Arc, + credit_repo: Arc, + claim_repo: Arc, } impl GachaRollServiceImpl { - pub fn new( - roll_repo: Arc, - credit_repo: Arc, - claim_repo: Arc, - ) -> Self { - Self { - roll_repo, - credit_repo, - claim_repo, - } - } + pub fn new( + roll_repo: Arc, + credit_repo: Arc, + claim_repo: Arc, + ) -> Self { + Self { + roll_repo, + credit_repo, + claim_repo, + } + } - fn roll_once(rolls: &[GachaRollEntity]) -> Option { - let filtered: Vec<&GachaRollEntity> = rolls - .iter() - .filter(|r| !r.is_deleted && r.quantity > 0) - .collect(); + fn roll_once(rolls: &[GachaRollEntity]) -> Option { + let filtered: Vec<&GachaRollEntity> = rolls + .iter() + .filter(|r| !r.is_deleted && r.quantity > 0) + .collect(); - if filtered.is_empty() { - return None; - } + if filtered.is_empty() { + return None; + } - let total_weight: f64 = filtered - .iter() - .map(|r| f64::from(r.weight) * f64::from(r.quantity)) - .sum(); + let total_weight: f64 = filtered + .iter() + .map(|r| f64::from(r.weight) * f64::from(r.quantity)) + .sum(); - if total_weight <= 0.0 { - let mut rng = rand::rngs::ThreadRng::default(); - let index = rng.random_range(0..filtered.len()); - return Some(filtered[index].clone()); - } + if total_weight <= 0.0 { + let mut rng = rand::rngs::ThreadRng::default(); + let index = rng.random_range(0..filtered.len()); + return Some(filtered[index].clone()); + } - let mut rng = rand::rngs::ThreadRng::default(); - let random_value = rng.random_range(0.0..total_weight); + let mut rng = rand::rngs::ThreadRng::default(); + let random_value = rng.random_range(0.0..total_weight); - let mut cumulative_weight = 0.0; - for roll in &filtered { - cumulative_weight += f64::from(roll.weight) * f64::from(roll.quantity); - if random_value <= cumulative_weight { - return Some((*roll).clone()); - } - } + let mut cumulative_weight = 0.0; + for roll in &filtered { + cumulative_weight += f64::from(roll.weight) * f64::from(roll.quantity); + if random_value <= cumulative_weight { + return Some((*roll).clone()); + } + } - Some(filtered[0].clone()) - } + Some(filtered[0].clone()) + } } #[async_trait] impl GachaRollService for GachaRollServiceImpl { - async fn get_roll(&self, id: Uuid) -> Result { - self.roll_repo.find_by_id(id).await - } + async fn get_roll(&self, id: Uuid) -> Result { + self.roll_repo.find_by_id(id).await + } - async fn create_roll(&self, entity: GachaRollEntity) -> Result<(), AppError> { - self.roll_repo.create(entity).await - } + async fn create_roll(&self, entity: GachaRollEntity) -> Result<(), AppError> { + self.roll_repo.create(entity).await + } - async fn execute_roll(&self, user_id: Uuid) -> Result { - // 1. Check user has credits - let credit = self - .credit_repo - .find_by_user_id(user_id) - .await? - .ok_or_else(|| AppError::BadRequestError("No credit record found".to_string()))?; + async fn execute_roll(&self, user_id: Uuid) -> Result { + let credit = self + .credit_repo + .find_by_user_id(user_id) + .await? + .ok_or_else(|| { + AppError::BadRequestError("No credit record found".to_string()) + })?; - if credit.available_rolls <= 0 { - return Err(AppError::BadRequestError( - "Not enough credits to perform this action".to_string(), - )); - } + if credit.available_rolls <= 0 { + return Err(AppError::BadRequestError( + "Not enough credits to perform this action".to_string(), + )); + } - // 2. Consume 1 credit - self.credit_repo.consume_credit(user_id).await?; + self.credit_repo.consume_credit(user_id).await?; - // 3. Get all active rolls - let rolls = self.roll_repo.find_all_active().await.map_err(|e| { - AppError::InternalServerError(e.to_string()) - })?; + let rolls = self + .roll_repo + .find_all_active() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - // 4. Weighted random selection - let selected = Self::roll_once(&rolls).ok_or_else(|| { - AppError::NotFoundError("No rollable item available".to_string()) - }); + let selected = Self::roll_once(&rolls).ok_or_else(|| { + AppError::NotFoundError("No rollable item available".to_string()) + }); - let selected = match selected { - Ok(r) => r, - Err(e) => { - // Refund credit on failure - let _ = self.credit_repo.add_credit(user_id, 1).await; - return Err(e); - } - }; + let selected = match selected { + Ok(r) => r, + Err(e) => { + let _ = self.credit_repo.add_credit(user_id, 1).await; + return Err(e); + } + }; - // 5. Create claim - let claim_entity = GachaClaimEntity { - id: Uuid::new_v4(), - user_id, - gacha_item_id: selected.item_id, - claim_id: Uuid::new_v4(), - claim_type: "roll".to_string(), - status: "claimed".to_string(), - quantity: 1, - metadata: None, - is_deleted: false, - claimed_at: chrono::Utc::now(), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - }; + let claim_entity = GachaClaimEntity { + id: Uuid::new_v4(), + user_id, + gacha_item_id: selected.item_id, + claim_id: Uuid::new_v4(), + claim_type: "roll".to_string(), + status: "claimed".to_string(), + quantity: 1, + metadata: None, + is_deleted: false, + claimed_at: chrono::Utc::now(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + }; - if let Err(e) = self.claim_repo.create(claim_entity).await { - // 6. Refund credit on claim creation failure - let _ = self.credit_repo.add_credit(user_id, 1).await; - return Err(e); - } + if let Err(e) = self.claim_repo.create(claim_entity).await { + let _ = self.credit_repo.add_credit(user_id, 1).await; + return Err(e); + } - // 7. Return selected roll entity - Ok(selected) - } + Ok(selected) + } - async fn delete_roll(&self, id: Uuid) -> Result<(), AppError> { - self.roll_repo.delete(id).await - } + async fn delete_roll(&self, id: Uuid) -> Result<(), AppError> { + self.roll_repo.delete(id).await + } } diff --git a/imphnen-gacha/src/gacha_rolls/domain/gacha_roll.rs b/imphnen-gacha/src/gacha_rolls/domain/gacha_roll.rs index 8a9e348..2326a5e 100644 --- a/imphnen-gacha/src/gacha_rolls/domain/gacha_roll.rs +++ b/imphnen-gacha/src/gacha_rolls/domain/gacha_roll.rs @@ -3,13 +3,13 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct GachaRollEntity { - pub id: Uuid, - pub user_id: Uuid, - pub gacha_id: String, - pub item_id: Uuid, - pub weight: f32, - pub quantity: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: Uuid, + pub user_id: Uuid, + pub gacha_id: String, + pub item_id: Uuid, + pub weight: f32, + pub quantity: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } diff --git a/imphnen-gacha/src/gacha_rolls/domain/repository.rs b/imphnen-gacha/src/gacha_rolls/domain/repository.rs index 9db2366..2673fbe 100644 --- a/imphnen-gacha/src/gacha_rolls/domain/repository.rs +++ b/imphnen-gacha/src/gacha_rolls/domain/repository.rs @@ -1,12 +1,12 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_roll::GachaRollEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaRollRepository: Send + Sync { - async fn find_by_id(&self, id: Uuid) -> Result; - async fn find_all_active(&self) -> Result, AppError>; - async fn create(&self, entity: GachaRollEntity) -> Result<(), AppError>; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn find_all_active(&self) -> Result, AppError>; + async fn create(&self, entity: GachaRollEntity) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_rolls/domain/service.rs b/imphnen-gacha/src/gacha_rolls/domain/service.rs index bd29606..bbd5c09 100644 --- a/imphnen-gacha/src/gacha_rolls/domain/service.rs +++ b/imphnen-gacha/src/gacha_rolls/domain/service.rs @@ -1,12 +1,12 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::AppError; use super::gacha_roll::GachaRollEntity; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; #[async_trait] pub trait GachaRollService: Send + Sync { - async fn get_roll(&self, id: Uuid) -> Result; - async fn create_roll(&self, entity: GachaRollEntity) -> Result<(), AppError>; - async fn execute_roll(&self, user_id: Uuid) -> Result; - async fn delete_roll(&self, id: Uuid) -> Result<(), AppError>; + async fn get_roll(&self, id: Uuid) -> Result; + async fn create_roll(&self, entity: GachaRollEntity) -> Result<(), AppError>; + async fn execute_roll(&self, user_id: Uuid) -> Result; + async fn delete_roll(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-gacha/src/gacha_rolls/infrastructure/http/dto.rs b/imphnen-gacha/src/gacha_rolls/infrastructure/http/dto.rs index 93ef72b..75c9c3c 100644 --- a/imphnen-gacha/src/gacha_rolls/infrastructure/http/dto.rs +++ b/imphnen-gacha/src/gacha_rolls/infrastructure/http/dto.rs @@ -1,46 +1,46 @@ +use crate::gacha_rolls::domain::gacha_roll::GachaRollEntity; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::gacha_rolls::domain::gacha_roll::GachaRollEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaRollCreateRequestDto { - pub item_id: String, - pub weight: f32, - pub quantity: i32, + pub item_id: String, + pub weight: f32, + pub quantity: i32, } impl ZodValidate for GachaRollCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaRollItemDto { - pub id: String, - pub user_id: String, - pub gacha_id: String, - pub item_id: String, - pub weight: f32, - pub quantity: i32, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub user_id: String, + pub gacha_id: String, + pub item_id: String, + pub weight: f32, + pub quantity: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } impl From<&GachaRollEntity> for GachaRollItemDto { - fn from(e: &GachaRollEntity) -> Self { - GachaRollItemDto { - id: e.id.to_string(), - user_id: e.user_id.to_string(), - gacha_id: e.gacha_id.clone(), - item_id: e.item_id.to_string(), - weight: e.weight, - quantity: e.quantity, - is_deleted: e.is_deleted, - created_at: e.created_at.map(|d| d.to_string()), - updated_at: e.updated_at.map(|d| d.to_string()), - } - } + fn from(e: &GachaRollEntity) -> Self { + GachaRollItemDto { + id: e.id.to_string(), + user_id: e.user_id.to_string(), + gacha_id: e.gacha_id.clone(), + item_id: e.item_id.to_string(), + weight: e.weight, + quantity: e.quantity, + is_deleted: e.is_deleted, + created_at: e.created_at.map(|d| d.to_string()), + updated_at: e.updated_at.map(|d| d.to_string()), + } + } } diff --git a/imphnen-gacha/src/gacha_rolls/infrastructure/http/handlers.rs b/imphnen-gacha/src/gacha_rolls/infrastructure/http/handlers.rs index 7c403d9..5691ccc 100644 --- a/imphnen-gacha/src/gacha_rolls/infrastructure/http/handlers.rs +++ b/imphnen-gacha/src/gacha_rolls/infrastructure/http/handlers.rs @@ -1,13 +1,13 @@ -use std::sync::Arc; -use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; -use imphnen_libs::{AppState, ValidatedJson}; -use imphnen_utils::{ApiSuccess, ApiMessage, extract_email}; -use imphnen_entities::ResponseSuccessDto; -use imphnen_iam::{PermissionsEnum, require_permissions}; -use imphnen_utils::AppError; -use uuid::Uuid; use super::dto::{GachaRollCreateRequestDto, GachaRollItemDto}; use crate::gacha_rolls::domain::{GachaRollEntity, GachaRollService}; +use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiSuccess, extract_email}; +use std::sync::Arc; +use uuid::Uuid; #[utoipa::path( get, @@ -22,17 +22,17 @@ use crate::gacha_rolls::domain::{GachaRollEntity, GachaRollService}; tag = "Gacha" )] pub async fn get_gacha_roll_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaRolls], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - let roll = service.get_roll(uuid).await?; - Ok(ApiSuccess(GachaRollItemDto::from(&roll))) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaRolls], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + let roll = service.get_roll(uuid).await?; + Ok(ApiSuccess(GachaRollItemDto::from(&roll))) + }) } #[utoipa::path( @@ -46,34 +46,43 @@ pub async fn get_gacha_roll_by_id( tag = "Gacha" )] pub async fn post_create_gacha_roll( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::CreateGachaRolls], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user_id = Uuid::parse_str(&user_info.basic_info.id) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - let item_id = Uuid::parse_str(&payload.item_id) - .map_err(|e| AppError::BadRequestError(format!("Invalid item_id UUID: {e}")))?; - let entity = GachaRollEntity { - id: Uuid::new_v4(), - user_id, - gacha_id: "default".to_string(), - item_id, - weight: payload.weight, - quantity: payload.quantity, - is_deleted: false, - created_at: Some(chrono::Utc::now().naive_utc()), - updated_at: Some(chrono::Utc::now().naive_utc()), - }; - service.create_roll(entity).await?; - Ok(ApiMessage::created("Gacha roll created")) - }) + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::CreateGachaRolls], + { + let email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user_id = Uuid::parse_str(&user_info.basic_info.id) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + let item_id = Uuid::parse_str(&payload.item_id).map_err(|e| { + AppError::BadRequestError(format!("Invalid item_id UUID: {e}")) + })?; + let entity = GachaRollEntity { + id: Uuid::new_v4(), + user_id, + gacha_id: "default".to_string(), + item_id, + weight: payload.weight, + quantity: payload.quantity, + is_deleted: false, + created_at: Some(chrono::Utc::now().naive_utc()), + updated_at: Some(chrono::Utc::now().naive_utc()), + }; + service.create_roll(entity).await?; + Ok(ApiMessage::created("Gacha roll created")) + } + ) } #[utoipa::path( @@ -86,20 +95,28 @@ pub async fn post_create_gacha_roll( tag = "Gacha" )] pub async fn post_execute_gacha_roll( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, ) -> Result { - require_permissions!(headers.clone(), state, [PermissionsEnum::ExecuteGachaRolls], { - let email = extract_email(&headers) - .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; - let user_info = state.user_lookup_service.get_user_by_email(&email, &state).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - let user_id = Uuid::parse_str(&user_info.basic_info.id) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - let roll = service.execute_roll(user_id).await?; - Ok(ApiSuccess(GachaRollItemDto::from(&roll))) - }) + require_permissions!( + headers.clone(), + state, + [PermissionsEnum::ExecuteGachaRolls], + { + let email = extract_email(&headers) + .ok_or_else(|| AppError::AuthenticationError("Unauthorized".to_string()))?; + let user_info = state + .user_lookup_service + .get_user_by_email(&email, &state) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let user_id = Uuid::parse_str(&user_info.basic_info.id) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + let roll = service.execute_roll(user_id).await?; + Ok(ApiSuccess(GachaRollItemDto::from(&roll))) + } + ) } #[utoipa::path( @@ -115,15 +132,15 @@ pub async fn post_execute_gacha_roll( tag = "Gacha" )] pub async fn delete_gacha_roll( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::DeleteGachaRolls], { - let uuid = Uuid::parse_str(&id) - .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; - service.delete_roll(uuid).await?; - Ok(ApiMessage::ok("Gacha roll deleted")) - }) + require_permissions!(headers, state, [PermissionsEnum::DeleteGachaRolls], { + let uuid = Uuid::parse_str(&id) + .map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?; + service.delete_roll(uuid).await?; + Ok(ApiMessage::ok("Gacha roll deleted")) + }) } diff --git a/imphnen-gacha/src/gacha_rolls/infrastructure/http/routes.rs b/imphnen-gacha/src/gacha_rolls/infrastructure/http/routes.rs index 664e2b9..24171e6 100644 --- a/imphnen-gacha/src/gacha_rolls/infrastructure/http/routes.rs +++ b/imphnen-gacha/src/gacha_rolls/infrastructure/http/routes.rs @@ -1,31 +1,42 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, post}, Extension}; -use sea_orm::DatabaseConnection; +use super::handlers::{ + delete_gacha_roll, get_gacha_roll_by_id, post_create_gacha_roll, + post_execute_gacha_roll, +}; use crate::gacha_claims::infrastructure::persistence::PostgresGachaClaimRepository; use crate::gacha_credits::infrastructure::persistence::PostgresGachaCreditRepository; use crate::gacha_rolls::application::GachaRollServiceImpl; use crate::gacha_rolls::domain::GachaRollService; use crate::gacha_rolls::infrastructure::persistence::PostgresGachaRollRepository; -use super::handlers::{ - delete_gacha_roll, get_gacha_roll_by_id, post_create_gacha_roll, post_execute_gacha_roll, +use axum::{ + Extension, Router, + routing::{delete, get, post}, }; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service( - db: DatabaseConnection, - state: Arc, + db: DatabaseConnection, + state: Arc, ) -> Arc { - let roll_repo = Arc::new(PostgresGachaRollRepository::new(db.clone())); - let credit_repo = Arc::new(PostgresGachaCreditRepository::new(db.clone())); - let claim_repo = Arc::new(PostgresGachaClaimRepository::new(db, state)); - Arc::new(GachaRollServiceImpl::new(roll_repo, credit_repo, claim_repo)) + let roll_repo = Arc::new(PostgresGachaRollRepository::new(db.clone())); + let credit_repo = Arc::new(PostgresGachaCreditRepository::new(db.clone())); + let claim_repo = Arc::new(PostgresGachaClaimRepository::new(db, state)); + Arc::new(GachaRollServiceImpl::new( + roll_repo, + credit_repo, + claim_repo, + )) } -pub fn gacha_roll_router(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db, state); - Router::new() - .route("/detail/{id}", get(get_gacha_roll_by_id)) - .route("/create", post(post_create_gacha_roll)) - .route("/execute", post(post_execute_gacha_roll)) - .route("/delete/{id}", delete(delete_gacha_roll)) - .layer(Extension(service)) +pub fn gacha_roll_router( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db, state); + Router::new() + .route("/detail/{id}", get(get_gacha_roll_by_id)) + .route("/create", post(post_create_gacha_roll)) + .route("/execute", post(post_execute_gacha_roll)) + .route("/delete/{id}", delete(delete_gacha_roll)) + .layer(Extension(service)) } diff --git a/imphnen-gacha/src/gacha_rolls/infrastructure/persistence/postgres_gacha_roll_repository.rs b/imphnen-gacha/src/gacha_rolls/infrastructure/persistence/postgres_gacha_roll_repository.rs index 31e4bc9..f6eee69 100644 --- a/imphnen-gacha/src/gacha_rolls/infrastructure/persistence/postgres_gacha_roll_repository.rs +++ b/imphnen-gacha/src/gacha_rolls/infrastructure/persistence/postgres_gacha_roll_repository.rs @@ -1,100 +1,102 @@ -use std::sync::Arc; +use crate::gacha_rolls::domain::{ + gacha_roll::GachaRollEntity, repository::GachaRollRepository, +}; use async_trait::async_trait; +use imphnen_entities::seaorm::gacha::gacha_rolls::{ + ActiveModel as GachaRollActiveModel, Column as GachaRollColumn, + Entity as GachaRollsEntity, Model as GachaRollModel, +}; +use imphnen_utils::AppError; use sea_orm::prelude::*; use sea_orm::{ActiveValue, QueryFilter}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::gacha::gacha_rolls::{ - Entity as GachaRollsEntity, Column as GachaRollColumn, - ActiveModel as GachaRollActiveModel, Model as GachaRollModel, -}; -use crate::gacha_rolls::domain::{gacha_roll::GachaRollEntity, repository::GachaRollRepository}; fn to_entity(model: GachaRollModel) -> GachaRollEntity { - GachaRollEntity { - id: model.id, - user_id: model.user_id, - gacha_id: model.gacha_id, - item_id: model.item_id, - weight: model.weight, - quantity: model.quantity, - is_deleted: model.is_deleted, - created_at: model.created_at, - updated_at: model.updated_at, - } + GachaRollEntity { + id: model.id, + user_id: model.user_id, + gacha_id: model.gacha_id, + item_id: model.item_id, + weight: model.weight, + quantity: model.quantity, + is_deleted: model.is_deleted, + created_at: model.created_at, + updated_at: model.updated_at, + } } pub struct PostgresGachaRollRepository { - db: Arc, + db: Arc, } impl PostgresGachaRollRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl GachaRollRepository for PostgresGachaRollRepository { - async fn find_by_id(&self, id: Uuid) -> Result { - let roll = GachaRollsEntity::find_by_id(id) - .filter(GachaRollColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha roll not found".to_string()))?; + async fn find_by_id(&self, id: Uuid) -> Result { + let roll = GachaRollsEntity::find_by_id(id) + .filter(GachaRollColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha roll not found".to_string()))?; - Ok(to_entity(roll)) - } + Ok(to_entity(roll)) + } - async fn find_all_active(&self) -> Result, AppError> { - let rolls = GachaRollsEntity::find() - .filter(GachaRollColumn::IsDeleted.eq(false)) - .filter(GachaRollColumn::Quantity.gt(0)) - .all(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn find_all_active(&self) -> Result, AppError> { + let rolls = GachaRollsEntity::find() + .filter(GachaRollColumn::IsDeleted.eq(false)) + .filter(GachaRollColumn::Quantity.gt(0)) + .all(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rolls.into_iter().map(to_entity).collect()) - } + Ok(rolls.into_iter().map(to_entity).collect()) + } - async fn create(&self, entity: GachaRollEntity) -> Result<(), AppError> { - let active_model = GachaRollActiveModel { - id: ActiveValue::Set(entity.id), - user_id: ActiveValue::Set(entity.user_id), - gacha_id: ActiveValue::Set(entity.gacha_id), - item_id: ActiveValue::Set(entity.item_id), - weight: ActiveValue::Set(entity.weight), - quantity: ActiveValue::Set(entity.quantity), - is_deleted: ActiveValue::Set(false), - created_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), - updated_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), - }; + async fn create(&self, entity: GachaRollEntity) -> Result<(), AppError> { + let active_model = GachaRollActiveModel { + id: ActiveValue::Set(entity.id), + user_id: ActiveValue::Set(entity.user_id), + gacha_id: ActiveValue::Set(entity.gacha_id), + item_id: ActiveValue::Set(entity.item_id), + weight: ActiveValue::Set(entity.weight), + quantity: ActiveValue::Set(entity.quantity), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), + updated_at: ActiveValue::Set(Some(chrono::Utc::now().naive_utc())), + }; - GachaRollsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + GachaRollsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - let mut active_model: GachaRollActiveModel = GachaRollsEntity::find_by_id(id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Gacha roll not found".to_string()))? - .into(); + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + let mut active_model: GachaRollActiveModel = GachaRollsEntity::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Gacha roll not found".to_string()))? + .into(); - active_model.is_deleted = ActiveValue::Set(true); - active_model.updated_at = ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); + active_model.is_deleted = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(Some(chrono::Utc::now().naive_utc())); - active_model - .update(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } } diff --git a/imphnen-gacha/src/lib.rs b/imphnen-gacha/src/lib.rs index de10c0e..83ce2af 100644 --- a/imphnen-gacha/src/lib.rs +++ b/imphnen-gacha/src/lib.rs @@ -1,37 +1,44 @@ -pub mod gacha_items; -pub mod gacha_credits; pub mod gacha_claims; +pub mod gacha_credits; +pub mod gacha_items; pub mod gacha_rolls; -pub use imphnen_libs::AppState; pub use imphnen_entities::{ - ResponseListSuccessDto, - ResponseSuccessDto, - MessageResponseDto, - PermissionsEnum, + MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, }; +pub use imphnen_libs::AppState; -use std::sync::Arc; use axum::Router; -use sea_orm::DatabaseConnection; -use gacha_items::gacha_item_router; -use gacha_credits::gacha_credit_router; -use gacha_rolls::gacha_roll_router; use gacha_claims::gacha_claim_router; +use gacha_credits::gacha_credit_router; +use gacha_items::gacha_item_router; +use gacha_rolls::gacha_roll_router; +use sea_orm::DatabaseConnection; +use std::sync::Arc; pub fn gacha_router(db: DatabaseConnection, state: Arc) -> Router { - let mut router = Router::new(); - router = router.nest("/credits", gacha_credit_router(db.clone())); - router = router.nest("/items", gacha_item_router(db.clone())); - router = router.nest("/rolls", gacha_roll_router(db.clone(), state.clone())); - router = router.nest("/claims", gacha_claim_router(db.clone(), state)); - router = router.nest("/admin", Router::new().route( - "/", - axum::routing::get(gacha_items::infrastructure::http::handlers::get_gacha_item_list), - ).layer(axum::Extension(Arc::new( - gacha_items::application::GachaItemServiceImpl::new( - Arc::new(gacha_items::infrastructure::persistence::PostgresGachaItemRepository::new(db)) - ) - ) as Arc))); - router + let mut router = Router::new(); + router = router.nest("/credits", gacha_credit_router(db.clone())); + router = router.nest("/items", gacha_item_router(db.clone())); + router = router.nest("/rolls", gacha_roll_router(db.clone(), state.clone())); + router = router.nest("/claims", gacha_claim_router(db.clone(), state)); + router = router.nest( + "/admin", + Router::new() + .route( + "/", + axum::routing::get( + gacha_items::infrastructure::http::handlers::get_gacha_item_list, + ), + ) + .layer(axum::Extension(Arc::new( + gacha_items::application::GachaItemServiceImpl::new(Arc::new( + gacha_items::infrastructure::persistence::PostgresGachaItemRepository::new( + db, + ), + )), + ) + as Arc)), + ); + router } diff --git a/imphnen-gateway/Cargo.toml b/imphnen-gateway/Cargo.toml index 4d600a7..91e2a48 100644 --- a/imphnen-gateway/Cargo.toml +++ b/imphnen-gateway/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "imphnen-gateway" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] imphnen-hackathon.workspace = true -imphnen-qr.workspace = true sqlx.workspace = true imphnen-iam.workspace = true imphnen-libs.workspace = true +imphnen-storage.workspace = true imphnen-utils.workspace = true imphnen-gacha.workspace = true imphnen-entities.workspace = true diff --git a/imphnen-gateway/src/docs.rs b/imphnen-gateway/src/docs.rs deleted file mode 100644 index 402bbfb..0000000 --- a/imphnen-gateway/src/docs.rs +++ /dev/null @@ -1,264 +0,0 @@ -use imphnen_cms::events::infrastructure::http::handlers as events_controller; -use imphnen_cms::events::infrastructure::http::dto::{EventsDetailItemDto, EventsListItemDto}; -use imphnen_cms::testimonials::infrastructure::http::handlers as testimonials_controller; -use imphnen_cms::testimonials::infrastructure::http::dto::{ - TestimonialsCreateRequestDto, TestimonialsDetailItemDto, - TestimonialsListItemDto, TestimonialsUpdateRequestDto, -}; -use imphnen_dimentorin::mentors::infrastructure::http::handlers as mentors_controller; -use imphnen_dimentorin::mentors::infrastructure::http::dto::{ - IdentityAndVerification, MentorDetailResponseDto, MentorListResponseDto, - MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto, - MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto, - MentoringLogistics, MentoringRate, ProfessionalProfile, -}; -use imphnen_dimentorin::sessions::infrastructure::http::handlers as sessions_controller; -use imphnen_dimentorin::sessions::infrastructure::http::dto::{ - BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, - SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto, - SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, - AvailabilitySlotDto, -}; -use imphnen_gacha::gacha_claims::infrastructure::http::handlers as gacha_claims_controller; -use imphnen_gacha::gacha_claims::infrastructure::http::dto::{GachaClaimDetailDto, GachaClaimCreateRequestDto}; -use imphnen_gacha::gacha_items::infrastructure::http::handlers as gacha_items_controller; -use imphnen_gacha::gacha_items::infrastructure::http::dto::{GachaItemDto, GachaItemCreateRequestDto}; -use imphnen_gacha::gacha_rolls::infrastructure::http::handlers as gacha_rolls_controller; -use imphnen_gacha::gacha_rolls::infrastructure::http::dto::{GachaRollItemDto, GachaRollCreateRequestDto}; -use imphnen_entities::{MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto}; -use imphnen_iam::auth::infrastructure::http::dto::{AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto}; -use imphnen_iam::permissions::infrastructure::http::handlers as permissions_controller; -use imphnen_iam::permissions::infrastructure::http::dto::{PermissionsCreateRequestDto, PermissionsItemDto}; -use imphnen_iam::roles::infrastructure::http::handlers as roles_controller; -use imphnen_iam::roles::infrastructure::http::dto::{RolesDetailItemDto, RolesListItemDto, RolesCreateRequestDto, RolesUpdateRequestDto}; -use imphnen_iam::users::infrastructure::http::handlers as users_controller; -use imphnen_iam::users::infrastructure::http::dto::{UsersDetailItemDto, UsersCreateRequestDto, UsersListItemDto, UsersUpdateRequestDto, FileUploadSchema}; -use imphnen_iam::auth::infrastructure::http::handlers as auth_controller; -use utoipa::{ - Modify, OpenApi, - openapi::security::{Http, HttpAuthScheme, SecurityScheme, SecurityRequirement}, -}; - -#[derive(OpenApi)] -#[openapi( - paths( - auth_controller::post_login, - auth_controller::post_login_mentor, - auth_controller::post_register, - auth_controller::post_verify_email, - auth_controller::post_resend_otp, - auth_controller::post_refresh_token, - auth_controller::post_forgot_password, - auth_controller::post_new_password, - users_controller::post_create_user, - users_controller::put_update_user, - users_controller::put_update_user_me, - users_controller::patch_user_active_status, - users_controller::delete_user, - users_controller::get_user_by_id, - users_controller::get_user_me, - users_controller::get_user_list, - users_controller::upload_file, - roles_controller::get_role_list, - roles_controller::get_role_by_id, - roles_controller::post_create_role, - roles_controller::put_update_role, - roles_controller::delete_role, - permissions_controller::get_permission_list, - permissions_controller::get_permission_by_id, - permissions_controller::post_create_permission, - permissions_controller::put_update_permission, - permissions_controller::delete_permission, - gacha_claims_controller::get_gacha_claim_by_id, - gacha_claims_controller::post_create_gacha_claim, - gacha_items_controller::get_gacha_item_list, - gacha_items_controller::get_gacha_item_by_id, - gacha_items_controller::post_create_gacha_item, - gacha_items_controller::put_update_gacha_item, - gacha_items_controller::delete_gacha_item, - gacha_rolls_controller::get_gacha_roll_by_id, - gacha_rolls_controller::post_create_gacha_roll, - gacha_rolls_controller::post_execute_gacha_roll, - gacha_rolls_controller::delete_gacha_roll, - events_controller::get_event_list, - events_controller::get_event_by_id, - events_controller::post_create_event, - events_controller::patch_update_event, - events_controller::delete_event, - testimonials_controller::get_testimonial_list, - testimonials_controller::get_testimonial_by_id, - testimonials_controller::post_create_testimonial, - testimonials_controller::patch_update_testimonial, - testimonials_controller::delete_testimonial, - mentors_controller::get_mentor_list, - mentors_controller::get_mentor_by_id, - mentors_controller::post_register_mentor, - mentors_controller::get_mentor_me, - mentors_controller::put_update_mentor_me, - mentors_controller::get_mentor_status, - mentors_controller::put_update_mentor, - mentors_controller::put_verify_mentor, - mentors_controller::delete_mentor, - sessions_controller::post_book_session, - sessions_controller::get_mentor_sessions, - sessions_controller::get_mentor_availability, - sessions_controller::put_update_session_status, - sessions_controller::post_submit_feedback, - sessions_controller::get_my_sessions, - ), - components( - schemas( - MessageResponseDto, - AuthLoginRequestDto, - AuthLoginResponsetDto, - AuthVerifyEmailRequestDto, - AuthResendOtpRequestDto, - AuthNewPasswordRequestDto, - AuthRefreshTokenRequestDto, - ResponseSuccessDto, - RolesListItemDto, - RolesDetailItemDto, - RolesCreateRequestDto, - RolesUpdateRequestDto, - PermissionsCreateRequestDto, - PermissionsItemDto, - UsersDetailItemDto, - UsersListItemDto, - UsersUpdateRequestDto, - UsersCreateRequestDto, - FileUploadSchema, - GachaClaimDetailDto, - GachaClaimCreateRequestDto, - GachaItemDto, - GachaItemCreateRequestDto, - GachaRollItemDto, - GachaRollCreateRequestDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, - TestimonialsCreateRequestDto, - TestimonialsUpdateRequestDto, - MentorUserRegisterRequestDto, - MentorRegisterFromTokenRequestDto, - MentorRegisterResponseDto, - MentorListResponseDto, - MentorDetailResponseDto, - MentorUpdateRequestDto, - MentorVerifyRequestDto, - IdentityAndVerification, - ProfessionalProfile, - MentoringLogistics, - MentoringRate, - ResponseListSuccessDto>, - ResponseSuccessDto, - ResponseSuccessDto, - BookSessionRequestDto, - BookSessionResponseDto, - SessionListResponseDto, - SessionListItemDto, - MentorAvailabilityDto, - AvailabilitySlotDto, - UpdateSessionStatusRequestDto, - UpdateSessionStatusResponseDto, - SessionFeedbackRequestDto, - SessionFeedbackResponseDto, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseSuccessDto, - ResponseSuccessDto, - ) - ), - info( - title = "IMPHNEN Backend Service", - description = "IMPHNEN Backend Service for Provide Gacha, Dimentorin and Backoffice Web App", - version = "0.1.0", - contact( - name = "Maulana Sodiqin", - url = "" - ), - license( - name = "MIT", - url = "https://opensource.org/licenses/MIT" - ) - ), - modifiers(&SecurityAddon), - tags( - (name = "Authentication", description = "List of Authentication Endpoints"), - (name = "Users", description = "User Management Endpoints"), - (name = "Roles", description = "Role Management Endpoints"), - (name = "Permissions", description = "Permission Management Endpoints"), - (name = "Events", description = "Event Management Endpoints"), - (name = "Testimonials", description = "Testimonial Management Endpoints"), - (name = "Mentors", description = "Mentor Management Endpoints"), - (name = "Mentors - Admin", description = "Mentor Admin Management Endpoints (Admin Access Required)"), - (name = "sessions", description = "Mentoring Sessions Management API"), - (name = "Gacha", description = "Gacha System Endpoints"), - ) -)] - pub struct ApiDoc; - -pub struct SecurityAddon; - -impl Modify for SecurityAddon { - fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - if let Some(components) = openapi.components.as_mut() { - components.add_security_scheme( - "Bearer", - SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), - ); - } - - // Walk all paths and add a Bearer security requirement to any operation - // that declares 401 or 403 responses. This helps ensure protected - // endpoints are shown with the Bearer lock in the generated docs - // without having to annotate every controller manually. - let paths = &mut openapi.paths; - for (_path, path_item) in paths.paths.iter_mut() { - // helper to process each possible operation on the path - let process_op = |op: &mut Option| { - if let Some(operation) = op.as_mut() { - let mut has_auth_response = false; - let responses = &operation.responses.responses; - for status in responses.keys() { - if status == "401" || status == "403" { - has_auth_response = true; - break; - } - } - if has_auth_response { - // assign security requirement for Bearer if not already present - if operation.security.is_none() { - operation.security = Some(vec![SecurityRequirement::new::<&str, Vec<&str>, &str>("Bearer", vec![])]); - } - } - } - }; - - process_op(&mut path_item.get); - process_op(&mut path_item.post); - process_op(&mut path_item.put); - process_op(&mut path_item.patch); - process_op(&mut path_item.delete); - process_op(&mut path_item.options); - process_op(&mut path_item.head); - process_op(&mut path_item.trace); - } - } -} - -pub fn docs_router() -> utoipa::openapi::OpenApi { - ApiDoc::openapi() -} diff --git a/imphnen-gateway/src/docs/mod.rs b/imphnen-gateway/src/docs/mod.rs new file mode 100644 index 0000000..461fb33 --- /dev/null +++ b/imphnen-gateway/src/docs/mod.rs @@ -0,0 +1,5 @@ +pub mod openapi; +pub mod security; + +pub use openapi::{ApiDoc, docs_router}; +pub use security::SecurityAddon; diff --git a/imphnen-gateway/src/docs/openapi.rs b/imphnen-gateway/src/docs/openapi.rs new file mode 100644 index 0000000..e46d1d0 --- /dev/null +++ b/imphnen-gateway/src/docs/openapi.rs @@ -0,0 +1,156 @@ +use super::security::SecurityAddon; +use imphnen_cms::events::infrastructure::http::dto::{ + EventsDetailItemDto, EventsListItemDto, +}; +use imphnen_cms::events::infrastructure::http::handlers as events_controller; +use imphnen_cms::testimonials::infrastructure::http::dto::{ + TestimonialsCreateRequestDto, TestimonialsDetailItemDto, TestimonialsListItemDto, + TestimonialsUpdateRequestDto, +}; +use imphnen_cms::testimonials::infrastructure::http::handlers as testimonials_controller; +use imphnen_dimentorin::mentors::infrastructure::http::dto::{ + IdentityAndVerification, MentorDetailResponseDto, MentorListResponseDto, + MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto, + MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto, + MentoringLogistics, MentoringRate, ProfessionalProfile, +}; +use imphnen_dimentorin::mentors::infrastructure::http::handlers as mentors_controller; +use imphnen_dimentorin::sessions::infrastructure::http::dto::{ + AvailabilitySlotDto, BookSessionRequestDto, BookSessionResponseDto, + MentorAvailabilityDto, SessionFeedbackRequestDto, SessionFeedbackResponseDto, + SessionListItemDto, SessionListResponseDto, UpdateSessionStatusRequestDto, + UpdateSessionStatusResponseDto, +}; +use imphnen_dimentorin::sessions::infrastructure::http::handlers as sessions_controller; +use imphnen_entities::{ + MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto, +}; +use imphnen_gacha::gacha_claims::infrastructure::http::dto::{ + GachaClaimCreateRequestDto, GachaClaimDetailDto, +}; +use imphnen_gacha::gacha_claims::infrastructure::http::handlers as gacha_claims_controller; +use imphnen_gacha::gacha_items::infrastructure::http::dto::{ + GachaItemCreateRequestDto, GachaItemDto, +}; +use imphnen_gacha::gacha_items::infrastructure::http::handlers as gacha_items_controller; +use imphnen_gacha::gacha_rolls::infrastructure::http::dto::{ + GachaRollCreateRequestDto, GachaRollItemDto, +}; +use imphnen_gacha::gacha_rolls::infrastructure::http::handlers as gacha_rolls_controller; +use imphnen_iam::auth::infrastructure::http::dto::{ + AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, + AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, + TokenDto, +}; +use imphnen_iam::auth::infrastructure::http::handlers as auth_controller; +use imphnen_iam::permissions::infrastructure::http::dto::{ + PermissionsCreateRequestDto, PermissionsItemDto, +}; +use imphnen_iam::permissions::infrastructure::http::handlers as permissions_controller; +use imphnen_iam::roles::infrastructure::http::dto::{ + RolesCreateRequestDto, RolesDetailItemDto, RolesListItemDto, RolesUpdateRequestDto, +}; +use imphnen_iam::roles::infrastructure::http::handlers as roles_controller; +use imphnen_iam::users::infrastructure::http::dto::{ + FileUploadSchema, UsersCreateRequestDto, UsersDetailItemDto, UsersListItemDto, + UsersUpdateRequestDto, +}; +use imphnen_iam::users::infrastructure::http::handlers as users_controller; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + auth_controller::post_login, auth_controller::post_login_mentor, + auth_controller::post_register, auth_controller::post_verify_email, + auth_controller::post_resend_otp, auth_controller::post_refresh_token, + auth_controller::post_forgot_password, auth_controller::post_new_password, + users_controller::mutation_handlers::post_create_user, users_controller::mutation_handlers::put_update_user, + users_controller::profile_handlers::put_update_user_me, users_controller::mutation_handlers::patch_user_active_status, + users_controller::mutation_handlers::delete_user, users_controller::get_handlers::get_user_by_id, + users_controller::get_handlers::get_user_me, users_controller::get_handlers::get_user_list, + users_controller::profile_handlers::upload_file, + roles_controller::get_role_list, roles_controller::get_role_by_id, + roles_controller::post_create_role, roles_controller::put_update_role, + roles_controller::delete_role, + permissions_controller::get_permission_list, permissions_controller::get_permission_by_id, + permissions_controller::post_create_permission, permissions_controller::put_update_permission, + permissions_controller::delete_permission, + gacha_claims_controller::get_gacha_claim_by_id, gacha_claims_controller::post_create_gacha_claim, + gacha_items_controller::get_gacha_item_list, gacha_items_controller::get_gacha_item_by_id, + gacha_items_controller::post_create_gacha_item, gacha_items_controller::put_update_gacha_item, + gacha_items_controller::delete_gacha_item, + gacha_rolls_controller::get_gacha_roll_by_id, gacha_rolls_controller::post_create_gacha_roll, + gacha_rolls_controller::post_execute_gacha_roll, gacha_rolls_controller::delete_gacha_roll, + events_controller::get_event_list, events_controller::get_event_by_id, + events_controller::post_create_event, events_controller::patch_update_event, + events_controller::delete_event, + testimonials_controller::get_testimonial_list, testimonials_controller::get_testimonial_by_id, + testimonials_controller::post_create_testimonial, testimonials_controller::patch_update_testimonial, + testimonials_controller::delete_testimonial, + mentors_controller::query_handlers::get_mentor_list, mentors_controller::query_handlers::get_mentor_by_id, + mentors_controller::mutation_handlers::post_register_mentor, mentors_controller::query_handlers::get_mentor_me, + mentors_controller::mutation_handlers::put_update_mentor_me, mentors_controller::query_handlers::get_mentor_status, + mentors_controller::mutation_handlers::put_update_mentor, mentors_controller::mutation_handlers::put_verify_mentor, + mentors_controller::mutation_handlers::delete_mentor, + sessions_controller::mutation_handlers::post_book_session, sessions_controller::query_handlers::get_mentor_sessions, + sessions_controller::query_handlers::get_mentor_availability, sessions_controller::mutation_handlers::put_update_session_status, + sessions_controller::mutation_handlers::post_submit_feedback, sessions_controller::query_handlers::get_my_sessions, + ), + components(schemas( + MessageResponseDto, AuthLoginRequestDto, AuthLoginResponsetDto, + AuthVerifyEmailRequestDto, AuthResendOtpRequestDto, AuthNewPasswordRequestDto, + AuthRefreshTokenRequestDto, ResponseSuccessDto, + RolesListItemDto, RolesDetailItemDto, RolesCreateRequestDto, RolesUpdateRequestDto, + PermissionsCreateRequestDto, PermissionsItemDto, + UsersDetailItemDto, UsersListItemDto, UsersUpdateRequestDto, UsersCreateRequestDto, FileUploadSchema, + GachaClaimDetailDto, GachaClaimCreateRequestDto, GachaItemDto, GachaItemCreateRequestDto, + GachaRollItemDto, GachaRollCreateRequestDto, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseSuccessDto, ResponseSuccessDto, + ResponseSuccessDto, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseListSuccessDto>, ResponseSuccessDto, + TestimonialsCreateRequestDto, TestimonialsUpdateRequestDto, + MentorUserRegisterRequestDto, MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto, + MentorListResponseDto, MentorDetailResponseDto, MentorUpdateRequestDto, MentorVerifyRequestDto, + IdentityAndVerification, ProfessionalProfile, MentoringLogistics, MentoringRate, + ResponseListSuccessDto>, ResponseSuccessDto, + ResponseSuccessDto, + BookSessionRequestDto, BookSessionResponseDto, SessionListResponseDto, SessionListItemDto, + MentorAvailabilityDto, AvailabilitySlotDto, + UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, + SessionFeedbackRequestDto, SessionFeedbackResponseDto, + ResponseSuccessDto, ResponseSuccessDto, + ResponseSuccessDto, ResponseSuccessDto, + ResponseSuccessDto, + )), + info( + title = "IMPHNEN Backend Service", + description = "IMPHNEN Backend Service for Provide Gacha, Dimentorin and Backoffice Web App", + version = "0.1.0", + contact(name = "Maulana Sodiqin", url = ""), + license(name = "MIT", url = "https://opensource.org/licenses/MIT") + ), + modifiers(&SecurityAddon), + tags( + (name = "Authentication", description = "List of Authentication Endpoints"), + (name = "Users", description = "User Management Endpoints"), + (name = "Roles", description = "Role Management Endpoints"), + (name = "Permissions", description = "Permission Management Endpoints"), + (name = "Events", description = "Event Management Endpoints"), + (name = "Testimonials", description = "Testimonial Management Endpoints"), + (name = "Mentors", description = "Mentor Management Endpoints"), + (name = "Mentors - Admin", description = "Mentor Admin Management Endpoints (Admin Access Required)"), + (name = "sessions", description = "Mentoring Sessions Management API"), + (name = "Gacha", description = "Gacha System Endpoints"), + ) +)] +pub struct ApiDoc; + +pub fn docs_router() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} diff --git a/imphnen-gateway/src/docs/security.rs b/imphnen-gateway/src/docs/security.rs new file mode 100644 index 0000000..aa0cabb --- /dev/null +++ b/imphnen-gateway/src/docs/security.rs @@ -0,0 +1,49 @@ +use utoipa::{ + Modify, + openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme}, +}; + +pub struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "Bearer", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } + + let paths = &mut openapi.paths; + for (_path, path_item) in paths.paths.iter_mut() { + let process_op = |op: &mut Option| { + if let Some(operation) = op.as_mut() { + let mut has_auth_response = false; + let responses = &operation.responses.responses; + for status in responses.keys() { + if status == "401" || status == "403" { + has_auth_response = true; + break; + } + } + if has_auth_response && operation.security.is_none() { + operation.security = Some(vec![SecurityRequirement::new::< + &str, + Vec<&str>, + &str, + >("Bearer", vec![])]); + } + } + }; + + process_op(&mut path_item.get); + process_op(&mut path_item.post); + process_op(&mut path_item.put); + process_op(&mut path_item.patch); + process_op(&mut path_item.delete); + process_op(&mut path_item.options); + process_op(&mut path_item.head); + process_op(&mut path_item.trace); + } + } +} diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 028f08a..05a2ca4 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -1,90 +1,87 @@ use axum::{ - Extension, - Router, - middleware::from_fn, - response::Redirect, - routing::get, + Extension, Router, middleware::from_fn, response::Redirect, routing::get, }; use imphnen_cms::{ - events_protected_routes, - events_public_routes, - testimonials_protected_routes, - testimonials_public_routes, + events_protected_routes, events_public_routes, qr_router, testimonials_protected_routes, + testimonials_public_routes, }; use imphnen_dimentorin::{ - mentors_public_routes, mentors_protected_routes, - sessions_public_routes, sessions_protected_routes, + mentors_protected_routes, mentors_public_routes, sessions_protected_routes, + sessions_public_routes, }; use imphnen_gacha::gacha_router; -use imphnen_hackathon::{hackathon_router, HackathonConfig}; -use imphnen_qr::qr_router; -use imphnen_libs::{MinioConfig, create_minio_service_from_config}; +use imphnen_hackathon::hackathon_router; use imphnen_iam::{ - auth_public_routes, - permissions_protected_routes, - roles_protected_routes, - users_protected_routes, + auth_public_routes, permissions_protected_routes, roles_protected_routes, + users_protected_routes, }; -use imphnen_libs::services::PostgresAuthRepository as AuthRepoImpl; use imphnen_libs::PostgresUserLookupService; +use imphnen_libs::services::PostgresAuthRepository as AuthRepoImpl; use imphnen_libs::{AppState, axum::PostgresClients}; -use imphnen_middleware::{auth_middleware, cors_middleware, rate_limiting_middleware, security_headers_middleware}; +use imphnen_middleware::{ + auth_middleware, cors_middleware, rate_limiting_middleware, + security_headers_middleware, +}; +use imphnen_storage::{MinioConfig, create_minio_service_from_config}; use std::sync::Arc; use utoipa_swagger_ui::SwaggerUi; pub mod docs; pub use docs::{ApiDoc, SecurityAddon, docs_router}; -pub async fn gateway_service( - postgres_clients: PostgresClients, -) -> Router { - let state = AppState { - postgres_connection: postgres_clients.main.clone(), - user_lookup_service: Arc::new(PostgresUserLookupService::new()), - auth_repository: Arc::new(AuthRepoImpl::new()), - }; +pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { + let state = AppState { + postgres_connection: postgres_clients.main.clone(), + user_lookup_service: Arc::new(PostgresUserLookupService::new()), + auth_repository: Arc::new(AuthRepoImpl::new()), + }; - let db = state.postgres_connection.conn.clone(); - let state_arc = Arc::new(state.clone()); - let hackathon_config = Arc::new(HackathonConfig::from_env()); - let minio = Arc::new( - create_minio_service_from_config(MinioConfig::from_env().expect("MinIO config required")) - .await - .expect("Failed to create MinIO service"), - ); - let qr_pool = Arc::new( - sqlx::PgPool::connect( - &std::env::var("QR_DATABASE_URL").expect("QR_DATABASE_URL must be set"), - ) - .await - .expect("Failed to connect to QR database"), - ); + let db = state.postgres_connection.conn.clone(); + let state_arc = Arc::new(state.clone()); + let minio = Arc::new( + create_minio_service_from_config( + MinioConfig::from_env().expect("MinIO config required"), + ) + .await + .expect("Failed to create MinIO service"), + ); + let public_routes = Router::new() + .merge( + auth_public_routes(db.clone(), Arc::clone(&state_arc)) + .layer(from_fn(rate_limiting_middleware)), + ) + .merge(testimonials_public_routes(db.clone())) + .merge(events_public_routes(db.clone())) + .merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc))) + .merge(sessions_public_routes(db.clone())); - let public_routes = Router::new() - .merge(auth_public_routes(db.clone(), Arc::clone(&state_arc)).layer(from_fn(rate_limiting_middleware))) - .merge(testimonials_public_routes(db.clone())) - .merge(events_public_routes(db.clone())) - .merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc))) - .merge(sessions_public_routes(db.clone())); + let protected_routes = Router::new() + .merge(users_protected_routes(db.clone(), Arc::clone(&state_arc))) + .merge(roles_protected_routes(db.clone(), Arc::clone(&state_arc))) + .merge(permissions_protected_routes( + db.clone(), + Arc::clone(&state_arc), + )) + .merge(events_protected_routes(db.clone())) + .merge(testimonials_protected_routes(db.clone())) + .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) + .merge(sessions_protected_routes( + db.clone(), + Arc::clone(&state_arc), + )) + .nest("/gacha", gacha_router(db.clone(), Arc::clone(&state_arc))) + .layer(from_fn(auth_middleware)); - let protected_routes = Router::new() - .merge(users_protected_routes(db.clone(), Arc::clone(&state_arc))) - .merge(roles_protected_routes(db.clone(), Arc::clone(&state_arc))) - .merge(permissions_protected_routes(db.clone(), Arc::clone(&state_arc))) - .merge(events_protected_routes(db.clone())) - .merge(testimonials_protected_routes(db.clone())) - .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) - .merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc))) - .nest("/gacha", gacha_router(db.clone(), Arc::clone(&state_arc))) - .layer(from_fn(auth_middleware)); - - Router::new() - .route("/", get(Redirect::to("/docs"))) - .nest("/v1", public_routes.merge(protected_routes)) - .nest("/v1/hackathon", hackathon_router(db.clone(), hackathon_config, minio)) - .nest("/v1/qr", qr_router(qr_pool)) - .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router())) - .layer(cors_middleware()) - .layer(from_fn(security_headers_middleware)) - .layer(Extension(state)) + Router::new() + .route("/", get(Redirect::to("/docs"))) + .nest("/v1", public_routes.merge(protected_routes)) + .nest( + "/v1/hackathon", + hackathon_router(db.clone(), minio), + ) + .nest("/v1/qr", qr_router(db.clone())) + .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router())) + .layer(cors_middleware()) + .layer(from_fn(security_headers_middleware)) + .layer(Extension(state)) } diff --git a/imphnen-hackathon/Cargo.toml b/imphnen-hackathon/Cargo.toml index 5699f95..3391cfa 100644 --- a/imphnen-hackathon/Cargo.toml +++ b/imphnen-hackathon/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "imphnen-hackathon" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] imphnen-utils.workspace = true imphnen-libs.workspace = true +imphnen-storage.workspace = true axum.workspace = true sea-orm.workspace = true sqlx.workspace = true @@ -17,7 +18,6 @@ chrono.workspace = true uuid.workspace = true tokio.workspace = true reqwest.workspace = true -lettre.workspace = true base64.workspace = true tracing.workspace = true thiserror.workspace = true diff --git a/imphnen-hackathon/src/admin/application/admin_service.rs b/imphnen-hackathon/src/admin/application/admin_service.rs new file mode 100644 index 0000000..1c9bb2f --- /dev/null +++ b/imphnen-hackathon/src/admin/application/admin_service.rs @@ -0,0 +1,84 @@ +use crate::admin::domain::entity::*; +use crate::admin::domain::repository::AdminRepository; +use crate::admin::domain::service::AdminService; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct AdminServiceImpl { + repo: Arc, +} + +impl AdminServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl AdminService for AdminServiceImpl { + async fn list_users( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError> { + self.repo.list_users(page, limit, search).await + } + + async fn get_user(&self, user_id: Uuid) -> Result { + self + .repo + .get_user(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) + } + + async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> { + self.repo.set_admin(user_id, is_admin).await + } + + async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> { + self.repo.delete_user(user_id).await + } + + async fn list_teams( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError> { + self.repo.list_teams(page, limit, search).await + } + + async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> { + self.repo.delete_team(team_id).await + } + + async fn list_submissions( + &self, + page: i64, + limit: i64, + status: Option, + ) -> Result<(Vec, i64), AppError> { + self.repo.list_submissions(page, limit, status).await + } + + async fn set_winner( + &self, + team_id: Uuid, + rank: i32, + prize: Option, + ) -> Result<(), AppError> { + self.repo.set_winner(team_id, rank, prize).await + } + + async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> { + self.repo.remove_winner(team_id).await + } + + async fn list_winners(&self) -> Result, AppError> { + self.repo.list_winners().await + } +} diff --git a/imphnen-hackathon/src/admin/application/mod.rs b/imphnen-hackathon/src/admin/application/mod.rs new file mode 100644 index 0000000..1458dd1 --- /dev/null +++ b/imphnen-hackathon/src/admin/application/mod.rs @@ -0,0 +1 @@ +pub mod admin_service; diff --git a/imphnen-hackathon/src/admin/domain/entity.rs b/imphnen-hackathon/src/admin/domain/entity.rs new file mode 100644 index 0000000..b58b5ce --- /dev/null +++ b/imphnen-hackathon/src/admin/domain/entity.rs @@ -0,0 +1,44 @@ +use serde::Serialize; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, ToSchema, FromRow)] +pub struct AdminUserRow { + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub is_active: Option, + pub is_admin: Option, + pub created_at: Option>, +} + +#[derive(Debug, Serialize, ToSchema, FromRow)] +pub struct AdminTeamRow { + pub id: Uuid, + pub name: String, + pub city: String, + pub visibility: String, + pub leader_id: Uuid, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize, ToSchema, FromRow)] +pub struct AdminSubmissionRow { + pub id: Uuid, + pub team_id: Uuid, + pub project_name: String, + pub status: String, + pub submitted_at: Option>, + pub created_at: Option>, +} + +#[derive(Debug, Serialize, ToSchema, FromRow)] +pub struct WinnerRow { + pub id: Uuid, + pub team_id: Uuid, + pub rank: i32, + pub prize: Option, + pub created_at: Option>, +} diff --git a/imphnen-hackathon/src/admin/domain/mod.rs b/imphnen-hackathon/src/admin/domain/mod.rs new file mode 100644 index 0000000..228c84e --- /dev/null +++ b/imphnen-hackathon/src/admin/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod repository; +pub mod service; diff --git a/imphnen-hackathon/src/admin/domain/repository.rs b/imphnen-hackathon/src/admin/domain/repository.rs new file mode 100644 index 0000000..d3068ac --- /dev/null +++ b/imphnen-hackathon/src/admin/domain/repository.rs @@ -0,0 +1,38 @@ +use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait AdminRepository: Send + Sync { + async fn list_users( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError>; + async fn get_user(&self, user_id: Uuid) -> Result, AppError>; + async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>; + async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>; + async fn list_teams( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError>; + async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>; + async fn list_submissions( + &self, + page: i64, + limit: i64, + status: Option, + ) -> Result<(Vec, i64), AppError>; + async fn set_winner( + &self, + team_id: Uuid, + rank: i32, + prize: Option, + ) -> Result<(), AppError>; + async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>; + async fn list_winners(&self) -> Result, AppError>; +} diff --git a/imphnen-hackathon/src/admin/domain/service.rs b/imphnen-hackathon/src/admin/domain/service.rs new file mode 100644 index 0000000..3c2c9be --- /dev/null +++ b/imphnen-hackathon/src/admin/domain/service.rs @@ -0,0 +1,38 @@ +use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait AdminService: Send + Sync { + async fn list_users( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError>; + async fn get_user(&self, user_id: Uuid) -> Result; + async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>; + async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>; + async fn list_teams( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError>; + async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>; + async fn list_submissions( + &self, + page: i64, + limit: i64, + status: Option, + ) -> Result<(Vec, i64), AppError>; + async fn set_winner( + &self, + team_id: Uuid, + rank: i32, + prize: Option, + ) -> Result<(), AppError>; + async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>; + async fn list_winners(&self) -> Result, AppError>; +} diff --git a/imphnen-hackathon/src/admin/infrastructure/http/dto.rs b/imphnen-hackathon/src/admin/infrastructure/http/dto.rs new file mode 100644 index 0000000..32bca6a --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/http/dto.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct PageQuery { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_limit")] + pub limit: i64, + pub search: Option, + pub status: Option, +} + +fn default_page() -> i64 { + 1 +} +fn default_limit() -> i64 { + 20 +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct PagedResponse { + pub data: Vec, + pub total: i64, + pub page: i64, + pub limit: i64, +} + +#[derive(Deserialize, ToSchema)] +pub struct SetAdminRequest { + pub is_admin: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetWinnerRequest { + pub team_id: Uuid, + pub rank: i32, + pub prize: Option, +} diff --git a/imphnen-hackathon/src/admin/infrastructure/http/handlers.rs b/imphnen-hackathon/src/admin/infrastructure/http/handlers.rs new file mode 100644 index 0000000..802a9ff --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/http/handlers.rs @@ -0,0 +1,119 @@ +use super::dto::*; +use crate::admin::domain::service::AdminService; +use axum::{ + Extension, Json, + extract::{Path, Query}, + response::IntoResponse, +}; +use imphnen_utils::{ + errors::AppError, + response_format::{ApiMessage, ApiSuccess}, +}; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn admin_list_users( + Extension(service): Extension>, + Query(q): Query, +) -> Result { + let (users, total) = service.list_users(q.page, q.limit, q.search).await?; + Ok( + ApiSuccess(PagedResponse { + data: users, + total, + page: q.page, + limit: q.limit, + }) + .into_response(), + ) +} + +pub async fn admin_get_user( + Extension(service): Extension>, + Path(user_id): Path, +) -> Result { + let user = service.get_user(user_id).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn admin_set_admin( + Extension(service): Extension>, + Path(user_id): Path, + Json(body): Json, +) -> Result { + service.set_admin(user_id, body.is_admin).await?; + Ok(ApiMessage::ok("User admin status updated")) +} + +pub async fn admin_delete_user( + Extension(service): Extension>, + Path(user_id): Path, +) -> Result { + service.delete_user(user_id).await?; + Ok(ApiMessage::ok("User deleted")) +} + +pub async fn admin_list_teams( + Extension(service): Extension>, + Query(q): Query, +) -> Result { + let (teams, total) = service.list_teams(q.page, q.limit, q.search).await?; + Ok( + ApiSuccess(PagedResponse { + data: teams, + total, + page: q.page, + limit: q.limit, + }) + .into_response(), + ) +} + +pub async fn admin_delete_team( + Extension(service): Extension>, + Path(team_id): Path, +) -> Result { + service.delete_team(team_id).await?; + Ok(ApiMessage::ok("Team deleted")) +} + +pub async fn admin_list_submissions( + Extension(service): Extension>, + Query(q): Query, +) -> Result { + let (subs, total) = service.list_submissions(q.page, q.limit, q.status).await?; + Ok( + ApiSuccess(PagedResponse { + data: subs, + total, + page: q.page, + limit: q.limit, + }) + .into_response(), + ) +} + +pub async fn admin_set_winner( + Extension(service): Extension>, + Json(body): Json, +) -> Result { + service + .set_winner(body.team_id, body.rank, body.prize) + .await?; + Ok(ApiMessage::ok("Winner set")) +} + +pub async fn admin_remove_winner( + Extension(service): Extension>, + Path(team_id): Path, +) -> Result { + service.remove_winner(team_id).await?; + Ok(ApiMessage::ok("Winner removed")) +} + +pub async fn admin_list_winners( + Extension(service): Extension>, +) -> Result { + let rows = service.list_winners().await?; + Ok(ApiSuccess(rows).into_response()) +} diff --git a/imphnen-hackathon/src/admin/infrastructure/http/mod.rs b/imphnen-hackathon/src/admin/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-hackathon/src/admin/infrastructure/http/routes.rs b/imphnen-hackathon/src/admin/infrastructure/http/routes.rs new file mode 100644 index 0000000..437b35d --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/http/routes.rs @@ -0,0 +1,40 @@ +use super::handlers::*; +use crate::admin::application::admin_service::AdminServiceImpl; +use crate::admin::domain::service::AdminService; +use crate::admin::infrastructure::persistence::PostgresAdminRepository; +use crate::middleware::{ + admin_only::admin_only, hackathon_auth::hackathon_auth_middleware, +}; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{delete, get, post}, +}; +use sqlx::PgPool; +use std::sync::Arc; + +pub fn hackathon_admin_routes(pool: Arc) -> Router { + let service: Arc = Arc::new(AdminServiceImpl::new(Arc::new( + PostgresAdminRepository::new(pool.clone()), + ))); + Router::new() + .route("/admin/users", get(admin_list_users)) + .route( + "/admin/users/:user_id", + get(admin_get_user).delete(admin_delete_user), + ) + .route("/admin/users/:user_id/set-admin", post(admin_set_admin)) + .route("/admin/teams", get(admin_list_teams)) + .route("/admin/teams/:team_id", delete(admin_delete_team)) + .route("/admin/submissions", get(admin_list_submissions)) + .route( + "/admin/winners", + get(admin_list_winners).post(admin_set_winner), + ) + .route("/admin/winners/:team_id", delete(admin_remove_winner)) + .layer(Extension(service)) + .layer(Extension(pool.clone())) + .layer(from_fn(admin_only)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) +} diff --git a/imphnen-hackathon/src/admin/infrastructure/mod.rs b/imphnen-hackathon/src/admin/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-hackathon/src/admin/infrastructure/persistence/mod.rs b/imphnen-hackathon/src/admin/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..8345bfb --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod postgres_admin_repository; +pub use postgres_admin_repository::PostgresAdminRepository; diff --git a/imphnen-hackathon/src/admin/infrastructure/persistence/postgres_admin_repository.rs b/imphnen-hackathon/src/admin/infrastructure/persistence/postgres_admin_repository.rs new file mode 100644 index 0000000..415d5e3 --- /dev/null +++ b/imphnen-hackathon/src/admin/infrastructure/persistence/postgres_admin_repository.rs @@ -0,0 +1,131 @@ +use crate::admin::domain::entity::*; +use crate::admin::domain::repository::AdminRepository; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +pub struct PostgresAdminRepository { + pool: Arc, +} + +impl PostgresAdminRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AdminRepository for PostgresAdminRepository { + async fn list_users( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError> { + let offset = (page - 1) * limit; + let pattern = search.as_deref().map(|s| format!("%{}%", s)); + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1)") + .bind(&pattern).fetch_one(self.pool.as_ref()).await.unwrap_or(0); + let users: Vec = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") + .bind(&pattern).bind(limit).bind(offset) + .fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok((users, total)) + } + + async fn get_user(&self, user_id: Uuid) -> Result, AppError> { + sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE id = $1") + .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2") + .bind(is_admin) + .bind(user_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM hackathon_users WHERE id = $1") + .bind(user_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn list_teams( + &self, + page: i64, + limit: i64, + search: Option, + ) -> Result<(Vec, i64), AppError> { + let offset = (page - 1) * limit; + let pattern = search.as_deref().map(|s| format!("%{}%", s)); + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1)", + ) + .bind(&pattern) + .fetch_one(self.pool.as_ref()) + .await + .unwrap_or(0); + let teams: Vec = sqlx::query_as("SELECT id, name, city, visibility, leader_id, created_at FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") + .bind(&pattern).bind(limit).bind(offset) + .fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok((teams, total)) + } + + async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM hackathon_teams WHERE id = $1") + .bind(team_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn list_submissions( + &self, + page: i64, + limit: i64, + status: Option, + ) -> Result<(Vec, i64), AppError> { + let offset = (page - 1) * limit; + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)") + .bind(&status).fetch_one(self.pool.as_ref()).await.unwrap_or(0); + let subs: Vec = sqlx::query_as("SELECT id, team_id, project_name, status, submitted_at, created_at FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") + .bind(&status).bind(limit).bind(offset) + .fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok((subs, total)) + } + + async fn set_winner( + &self, + team_id: Uuid, + rank: i32, + prize: Option, + ) -> Result<(), AppError> { + sqlx::query("INSERT INTO hackathon_winners (id, team_id, rank, prize, announced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) ON CONFLICT (team_id) DO UPDATE SET rank = $3, prize = $4, updated_at = NOW()") + .bind(Uuid::new_v4()).bind(team_id).bind(rank).bind(prize) + .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1") + .bind(team_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + async fn list_winners(&self) -> Result, AppError> { + sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC") + .fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) + } +} diff --git a/imphnen-hackathon/src/admin/mod.rs b/imphnen-hackathon/src/admin/mod.rs index 50063ef..fdeb939 100644 --- a/imphnen-hackathon/src/admin/mod.rs +++ b/imphnen-hackathon/src/admin/mod.rs @@ -1,2 +1,5 @@ -pub mod routes; -pub use routes::hackathon_admin_routes; +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use infrastructure::http::routes::hackathon_admin_routes; diff --git a/imphnen-hackathon/src/admin/routes.rs b/imphnen-hackathon/src/admin/routes.rs deleted file mode 100644 index 588dae3..0000000 --- a/imphnen-hackathon/src/admin/routes.rs +++ /dev/null @@ -1,189 +0,0 @@ -use axum::{ - extract::{Path, Query}, - middleware::from_fn, - response::IntoResponse, - routing::{delete, get, post}, - Extension, Json, Router, -}; -use sqlx::{PgPool, FromRow}; -use std::sync::Arc; -use uuid::Uuid; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}}; -use crate::middleware::{admin_only::admin_only, hackathon_auth::hackathon_auth_middleware}; - -#[derive(Deserialize)] -struct PageQuery { - #[serde(default = "default_page")] - page: i64, - #[serde(default = "default_limit")] - limit: i64, - search: Option, - status: Option, -} -fn default_page() -> i64 { 1 } -fn default_limit() -> i64 { 20 } - -#[derive(Debug, Serialize, ToSchema, FromRow)] -struct AdminUserRow { - id: Uuid, - email: String, - fullname: String, - avatar: Option, - is_active: Option, - is_admin: Option, - created_at: Option>, -} - -#[derive(Debug, Serialize, ToSchema)] -struct PagedResponse { - data: Vec, - total: i64, - page: i64, - limit: i64, -} - -#[derive(Deserialize, ToSchema)] -struct SetAdminRequest { is_admin: bool } - -async fn admin_list_users( - Extension(pool): Extension>, - Query(q): Query, -) -> Result { - let offset = (q.page - 1) * q.limit; - let pattern = q.search.as_deref().map(|s| format!("%{}%", s)); - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1)") - .bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0); - let users: Vec = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") - .bind(&pattern).bind(q.limit).bind(offset) - .fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiSuccess(PagedResponse { data: users, total, page: q.page, limit: q.limit }).into_response()) -} - -async fn admin_get_user( - Extension(pool): Extension>, - Path(user_id): Path, -) -> Result { - let user: AdminUserRow = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE id = $1") - .bind(user_id).fetch_optional(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; - Ok(ApiSuccess(user).into_response()) -} - -async fn admin_set_admin( - Extension(pool): Extension>, - Path(user_id): Path, - Json(body): Json, -) -> Result { - sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2") - .bind(body.is_admin).bind(user_id) - .execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiMessage::ok("User admin status updated")) -} - -async fn admin_delete_user( - Extension(pool): Extension>, - Path(user_id): Path, -) -> Result { - sqlx::query("DELETE FROM hackathon_users WHERE id = $1") - .bind(user_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiMessage::ok("User deleted")) -} - -#[derive(Debug, Serialize, ToSchema, FromRow)] -struct AdminTeamRow { - id: Uuid, name: String, city: String, visibility: String, - leader_id: Uuid, created_at: chrono::DateTime, -} - -async fn admin_list_teams( - Extension(pool): Extension>, - Query(q): Query, -) -> Result { - let offset = (q.page - 1) * q.limit; - let pattern = q.search.as_deref().map(|s| format!("%{}%", s)); - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1)") - .bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0); - let teams: Vec = sqlx::query_as("SELECT id, name, city, visibility, leader_id, created_at FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") - .bind(&pattern).bind(q.limit).bind(offset) - .fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiSuccess(PagedResponse { data: teams, total, page: q.page, limit: q.limit }).into_response()) -} - -async fn admin_delete_team( - Extension(pool): Extension>, - Path(team_id): Path, -) -> Result { - sqlx::query("DELETE FROM hackathon_teams WHERE id = $1") - .bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiMessage::ok("Team deleted")) -} - -#[derive(Debug, Serialize, ToSchema, FromRow)] -struct AdminSubmissionRow { - id: Uuid, team_id: Uuid, project_name: String, status: String, - submitted_at: Option>, created_at: Option>, -} - -async fn admin_list_submissions( - Extension(pool): Extension>, - Query(q): Query, -) -> Result { - let offset = (q.page - 1) * q.limit; - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)") - .bind(&q.status).fetch_one(pool.as_ref()).await.unwrap_or(0); - let subs: Vec = sqlx::query_as("SELECT id, team_id, project_name, status, submitted_at, created_at FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3") - .bind(&q.status).bind(q.limit).bind(offset) - .fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiSuccess(PagedResponse { data: subs, total, page: q.page, limit: q.limit }).into_response()) -} - -#[derive(Debug, Deserialize, ToSchema)] -struct SetWinnerRequest { team_id: Uuid, rank: i32, prize: Option } - -#[derive(Debug, Serialize, ToSchema, FromRow)] -struct WinnerRow { id: Uuid, team_id: Uuid, rank: i32, prize: Option, created_at: Option> } - -async fn admin_set_winner( - Extension(pool): Extension>, - Json(body): Json, -) -> Result { - sqlx::query("INSERT INTO hackathon_winners (id, team_id, rank, prize, announced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) ON CONFLICT (team_id) DO UPDATE SET rank = $3, prize = $4, updated_at = NOW()") - .bind(Uuid::new_v4()).bind(body.team_id).bind(body.rank).bind(body.prize) - .execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiMessage::ok("Winner set")) -} - -async fn admin_remove_winner( - Extension(pool): Extension>, - Path(team_id): Path, -) -> Result { - sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1") - .bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiMessage::ok("Winner removed")) -} - -async fn admin_list_winners( - Extension(pool): Extension>, -) -> Result { - let rows: Vec = sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC") - .fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiSuccess(rows).into_response()) -} - -pub fn hackathon_admin_routes(pool: Arc) -> Router { - Router::new() - .route("/admin/users", get(admin_list_users)) - .route("/admin/users/:user_id", get(admin_get_user).delete(admin_delete_user)) - .route("/admin/users/:user_id/set-admin", post(admin_set_admin)) - .route("/admin/teams", get(admin_list_teams)) - .route("/admin/teams/:team_id", delete(admin_delete_team)) - .route("/admin/submissions", get(admin_list_submissions)) - .route("/admin/winners", get(admin_list_winners).post(admin_set_winner)) - .route("/admin/winners/:team_id", delete(admin_remove_winner)) - .layer(Extension(pool.clone())) - .layer(from_fn(admin_only)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) -} diff --git a/imphnen-hackathon/src/certificates/application/certificate_service.rs b/imphnen-hackathon/src/certificates/application/certificate_service.rs new file mode 100644 index 0000000..7ae6bc5 --- /dev/null +++ b/imphnen-hackathon/src/certificates/application/certificate_service.rs @@ -0,0 +1,31 @@ +use crate::certificates::domain::entity::CertificateData; +use crate::certificates::domain::repository::CertificateRepository; +use crate::certificates::domain::service::CertificateService; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct CertificateServiceImpl { + repo: Arc, +} + +impl CertificateServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl CertificateService for CertificateServiceImpl { + async fn get_certificate( + &self, + user_id: Uuid, + ) -> Result { + self + .repo + .find_by_user_id(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) + } +} diff --git a/imphnen-hackathon/src/certificates/application/mod.rs b/imphnen-hackathon/src/certificates/application/mod.rs new file mode 100644 index 0000000..55a846a --- /dev/null +++ b/imphnen-hackathon/src/certificates/application/mod.rs @@ -0,0 +1 @@ +pub mod certificate_service; diff --git a/imphnen-hackathon/src/certificates/domain/entity.rs b/imphnen-hackathon/src/certificates/domain/entity.rs new file mode 100644 index 0000000..e8fee78 --- /dev/null +++ b/imphnen-hackathon/src/certificates/domain/entity.rs @@ -0,0 +1,16 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct CertificateData { + pub user_id: Uuid, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub team_id: Option, + pub team_name: Option, + pub is_leader: Option, + pub project_name: Option, + pub submission_status: Option, + pub winner_rank: Option, + pub winner_prize: Option, +} diff --git a/imphnen-hackathon/src/certificates/domain/mod.rs b/imphnen-hackathon/src/certificates/domain/mod.rs new file mode 100644 index 0000000..228c84e --- /dev/null +++ b/imphnen-hackathon/src/certificates/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod repository; +pub mod service; diff --git a/imphnen-hackathon/src/certificates/domain/repository.rs b/imphnen-hackathon/src/certificates/domain/repository.rs new file mode 100644 index 0000000..68f00aa --- /dev/null +++ b/imphnen-hackathon/src/certificates/domain/repository.rs @@ -0,0 +1,12 @@ +use super::entity::CertificateData; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait CertificateRepository: Send + Sync { + async fn find_by_user_id( + &self, + user_id: Uuid, + ) -> Result, AppError>; +} diff --git a/imphnen-hackathon/src/certificates/domain/service.rs b/imphnen-hackathon/src/certificates/domain/service.rs new file mode 100644 index 0000000..7dfcc43 --- /dev/null +++ b/imphnen-hackathon/src/certificates/domain/service.rs @@ -0,0 +1,12 @@ +use super::entity::CertificateData; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait CertificateService: Send + Sync { + async fn get_certificate( + &self, + user_id: Uuid, + ) -> Result; +} diff --git a/imphnen-hackathon/src/certificates/infrastructure/http/dto.rs b/imphnen-hackathon/src/certificates/infrastructure/http/dto.rs new file mode 100644 index 0000000..8284ca3 --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/http/dto.rs @@ -0,0 +1,37 @@ +use crate::certificates::domain::entity::CertificateData; +use serde::Serialize; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, ToSchema)] +pub struct CertificateResponse { + pub user_id: Uuid, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub team_id: Option, + pub team_name: Option, + pub is_leader: Option, + pub project_name: Option, + pub submission_status: Option, + pub winner_rank: Option, + pub winner_prize: Option, +} + +impl From for CertificateResponse { + fn from(d: CertificateData) -> Self { + Self { + user_id: d.user_id, + fullname: d.fullname, + email: d.email, + avatar: d.avatar, + team_id: d.team_id, + team_name: d.team_name, + is_leader: d.is_leader, + project_name: d.project_name, + submission_status: d.submission_status, + winner_rank: d.winner_rank, + winner_prize: d.winner_prize, + } + } +} diff --git a/imphnen-hackathon/src/certificates/infrastructure/http/handlers.rs b/imphnen-hackathon/src/certificates/infrastructure/http/handlers.rs new file mode 100644 index 0000000..38e0cec --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/http/handlers.rs @@ -0,0 +1,14 @@ +use super::dto::CertificateResponse; +use crate::certificates::domain::service::CertificateService; +use axum::{Extension, extract::Path, response::IntoResponse}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn get_certificate_handler( + Extension(service): Extension>, + Path(user_id): Path, +) -> Result { + let cert = service.get_certificate(user_id).await?; + Ok(ApiSuccess(CertificateResponse::from(cert)).into_response()) +} diff --git a/imphnen-hackathon/src/certificates/infrastructure/http/mod.rs b/imphnen-hackathon/src/certificates/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-hackathon/src/certificates/infrastructure/http/routes.rs b/imphnen-hackathon/src/certificates/infrastructure/http/routes.rs new file mode 100644 index 0000000..e99a778 --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/http/routes.rs @@ -0,0 +1,17 @@ +use super::handlers::get_certificate_handler; +use crate::certificates::application::certificate_service::CertificateServiceImpl; +use crate::certificates::domain::service::CertificateService; +use crate::certificates::infrastructure::persistence::PostgresCertificateRepository; +use axum::{Extension, Router, routing::get}; +use sqlx::PgPool; +use std::sync::Arc; + +pub fn hackathon_certificates_routes(pool: Arc) -> Router { + let service: Arc = Arc::new(CertificateServiceImpl::new( + Arc::new(PostgresCertificateRepository::new(pool.clone())), + )); + Router::new() + .route("/certificates/:user_id", get(get_certificate_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) +} diff --git a/imphnen-hackathon/src/certificates/infrastructure/mod.rs b/imphnen-hackathon/src/certificates/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-hackathon/src/certificates/infrastructure/persistence/mod.rs b/imphnen-hackathon/src/certificates/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..319280e --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod postgres_certificate_repository; +pub use postgres_certificate_repository::PostgresCertificateRepository; diff --git a/imphnen-hackathon/src/certificates/infrastructure/persistence/postgres_certificate_repository.rs b/imphnen-hackathon/src/certificates/infrastructure/persistence/postgres_certificate_repository.rs new file mode 100644 index 0000000..75a1f48 --- /dev/null +++ b/imphnen-hackathon/src/certificates/infrastructure/persistence/postgres_certificate_repository.rs @@ -0,0 +1,67 @@ +use crate::certificates::domain::entity::CertificateData; +use crate::certificates::domain::repository::CertificateRepository; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(FromRow)] +struct CertificateRow { + user_id: Uuid, + fullname: String, + email: String, + avatar: Option, + team_id: Option, + team_name: Option, + is_leader: Option, + project_name: Option, + submission_status: Option, + winner_rank: Option, + winner_prize: Option, +} + +impl From for CertificateData { + fn from(r: CertificateRow) -> Self { + Self { + user_id: r.user_id, + fullname: r.fullname, + email: r.email, + avatar: r.avatar, + team_id: r.team_id, + team_name: r.team_name, + is_leader: r.is_leader, + project_name: r.project_name, + submission_status: r.submission_status, + winner_rank: r.winner_rank, + winner_prize: r.winner_prize, + } + } +} + +pub struct PostgresCertificateRepository { + pool: Arc, +} + +impl PostgresCertificateRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl CertificateRepository for PostgresCertificateRepository { + async fn find_by_user_id( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( + "SELECT u.id as user_id, u.fullname, u.email, u.avatar, t.id as team_id, t.name as team_name, (t.leader_id = u.id) as is_leader, ps.project_name, ps.status as submission_status, w.rank as winner_rank, w.prize as winner_prize FROM hackathon_users u LEFT JOIN hackathon_team_members tm ON tm.user_id = u.id AND tm.status = 'active' LEFT JOIN hackathon_teams t ON t.id = tm.team_id LEFT JOIN hackathon_project_submissions ps ON ps.team_id = t.id LEFT JOIN hackathon_winners w ON w.team_id = t.id WHERE u.id = $1 LIMIT 1" + ) + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(row.map(Into::into)) + } +} diff --git a/imphnen-hackathon/src/certificates/mod.rs b/imphnen-hackathon/src/certificates/mod.rs index 235df70..2a7b611 100644 --- a/imphnen-hackathon/src/certificates/mod.rs +++ b/imphnen-hackathon/src/certificates/mod.rs @@ -1,2 +1,5 @@ -pub mod routes; -pub use routes::hackathon_certificates_routes; +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use infrastructure::http::routes::hackathon_certificates_routes; diff --git a/imphnen-hackathon/src/certificates/routes.rs b/imphnen-hackathon/src/certificates/routes.rs deleted file mode 100644 index a1787c5..0000000 --- a/imphnen-hackathon/src/certificates/routes.rs +++ /dev/null @@ -1,44 +0,0 @@ -use axum::{extract::Path, response::IntoResponse, routing::get, Extension, Router}; -use sqlx::{PgPool, FromRow}; -use std::sync::Arc; -use uuid::Uuid; -use serde::Serialize; -use utoipa::ToSchema; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; - -#[derive(Debug, Serialize, ToSchema, FromRow)] -pub struct CertificateResponse { - pub user_id: Uuid, - pub fullname: String, - pub email: String, - pub avatar: Option, - pub team_id: Option, - pub team_name: Option, - pub is_leader: Option, - pub project_name: Option, - pub submission_status: Option, - pub winner_rank: Option, - pub winner_prize: Option, -} - -async fn get_certificate_handler( - Extension(pool): Extension>, - Path(user_id): Path, -) -> Result { - let row: Option = sqlx::query_as( - "SELECT u.id as user_id, u.fullname, u.email, u.avatar, t.id as team_id, t.name as team_name, (t.leader_id = u.id) as is_leader, ps.project_name, ps.status as submission_status, w.rank as winner_rank, w.prize as winner_prize FROM hackathon_users u LEFT JOIN hackathon_team_members tm ON tm.user_id = u.id AND tm.status = 'active' LEFT JOIN hackathon_teams t ON t.id = tm.team_id LEFT JOIN hackathon_project_submissions ps ON ps.team_id = t.id LEFT JOIN hackathon_winners w ON w.team_id = t.id WHERE u.id = $1 LIMIT 1" - ) - .bind(user_id) - .fetch_optional(pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let cert = row.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; - Ok(ApiSuccess(cert).into_response()) -} - -pub fn hackathon_certificates_routes(pool: Arc) -> Router { - Router::new() - .route("/certificates/:user_id", get(get_certificate_handler)) - .layer(Extension(pool)) -} diff --git a/imphnen-hackathon/src/chat/application/chat_service.rs b/imphnen-hackathon/src/chat/application/chat_service.rs index b8bdf3a..cfb9326 100644 --- a/imphnen-hackathon/src/chat/application/chat_service.rs +++ b/imphnen-hackathon/src/chat/application/chat_service.rs @@ -1,70 +1,96 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use imphnen_utils::errors::AppError; use crate::chat::domain::entity::*; use crate::chat::domain::repository::ChatRepository; use crate::chat::domain::service::ChatService; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; pub struct ChatServiceImpl { - repo: Arc, + repo: Arc, } impl ChatServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl ChatService for ChatServiceImpl { - async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result, AppError> { - if !self.repo.is_team_member(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team members can view messages".to_string())); - } - self.repo.find_team_messages(team_id).await - } + async fn get_team_messages( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + if !self.repo.is_team_member(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team members can view messages".to_string(), + )); + } + self.repo.find_team_messages(team_id).await + } - async fn send_message( - &self, - team_id: Uuid, - user_id: Uuid, - input: SendMessageInput, - ) -> Result { - if input.message.trim().is_empty() { - return Err(AppError::BadRequestError("Message cannot be empty".to_string())); - } - if !self.repo.is_team_member(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team members can send messages".to_string())); - } - let user_info = self.repo.get_user_info(user_id).await? - .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; - let id = Uuid::new_v4(); - let entity = self.repo.create_message(id, team_id, user_id, &input.message).await?; - Ok(MessageWithUser { - id: entity.id, - team_id: entity.team_id, - user_id: entity.user_id, - user_fullname: user_info.0, - user_avatar: user_info.1, - message: entity.message, - created_at: entity.created_at, - updated_at: entity.updated_at, - }) - } + async fn send_message( + &self, + team_id: Uuid, + user_id: Uuid, + input: SendMessageInput, + ) -> Result { + if input.message.trim().is_empty() { + return Err(AppError::BadRequestError( + "Message cannot be empty".to_string(), + )); + } + if !self.repo.is_team_member(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team members can send messages".to_string(), + )); + } + let user_info = self + .repo + .get_user_info(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; + let id = Uuid::new_v4(); + let entity = self + .repo + .create_message(id, team_id, user_id, &input.message) + .await?; + Ok(MessageWithUser { + id: entity.id, + team_id: entity.team_id, + user_id: entity.user_id, + user_fullname: user_info.0, + user_avatar: user_info.1, + message: entity.message, + created_at: entity.created_at, + updated_at: entity.updated_at, + }) + } - async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - let message = self.repo.find_message_by_id(message_id).await? - .ok_or_else(|| AppError::NotFoundError("Message not found".to_string()))?; - let is_author = message.user_id == user_id; - let is_leader = self.repo.is_team_leader(message.team_id, user_id).await?; - if !is_author && !is_leader { - return Err(AppError::ForbiddenError("You can only delete your own messages or messages as team leader".to_string())); - } - let deleted = self.repo.delete_message(message_id).await?; - if !deleted { - return Err(AppError::NotFoundError("Message not found".to_string())); - } - Ok(()) - } + async fn delete_message( + &self, + message_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + let message = self + .repo + .find_message_by_id(message_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Message not found".to_string()))?; + let is_author = message.user_id == user_id; + let is_leader = self.repo.is_team_leader(message.team_id, user_id).await?; + if !is_author && !is_leader { + return Err(AppError::ForbiddenError( + "You can only delete your own messages or messages as team leader" + .to_string(), + )); + } + let deleted = self.repo.delete_message(message_id).await?; + if !deleted { + return Err(AppError::NotFoundError("Message not found".to_string())); + } + Ok(()) + } } diff --git a/imphnen-hackathon/src/chat/domain/entity.rs b/imphnen-hackathon/src/chat/domain/entity.rs index c2f1623..3440f60 100644 --- a/imphnen-hackathon/src/chat/domain/entity.rs +++ b/imphnen-hackathon/src/chat/domain/entity.rs @@ -1,29 +1,29 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct MessageEntity { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub message: String, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub message: String, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone)] pub struct MessageWithUser { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user_fullname: String, - pub user_avatar: Option, - pub message: String, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user_fullname: String, + pub user_avatar: Option, + pub message: String, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Default)] pub struct SendMessageInput { - pub message: String, + pub message: String, } diff --git a/imphnen-hackathon/src/chat/domain/repository.rs b/imphnen-hackathon/src/chat/domain/repository.rs index c78f9d1..49ccbfd 100644 --- a/imphnen-hackathon/src/chat/domain/repository.rs +++ b/imphnen-hackathon/src/chat/domain/repository.rs @@ -1,27 +1,44 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait ChatRepository: Send + Sync { - async fn find_team_messages(&self, team_id: Uuid) -> Result, AppError>; + async fn find_team_messages( + &self, + team_id: Uuid, + ) -> Result, AppError>; - async fn create_message( - &self, - id: Uuid, - team_id: Uuid, - user_id: Uuid, - message: &str, - ) -> Result; + async fn create_message( + &self, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: &str, + ) -> Result; - async fn find_message_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_message_by_id( + &self, + id: Uuid, + ) -> Result, AppError>; - async fn delete_message(&self, id: Uuid) -> Result; + async fn delete_message(&self, id: Uuid) -> Result; - async fn get_user_info(&self, user_id: Uuid) -> Result)>, AppError>; + async fn get_user_info( + &self, + user_id: Uuid, + ) -> Result)>, AppError>; - async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result; + async fn is_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; - async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result; + async fn is_team_leader( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; } diff --git a/imphnen-hackathon/src/chat/domain/service.rs b/imphnen-hackathon/src/chat/domain/service.rs index f25d59f..f60369a 100644 --- a/imphnen-hackathon/src/chat/domain/service.rs +++ b/imphnen-hackathon/src/chat/domain/service.rs @@ -1,18 +1,26 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait ChatService: Send + Sync { - async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result, AppError>; + async fn get_team_messages( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result, AppError>; - async fn send_message( - &self, - team_id: Uuid, - user_id: Uuid, - input: SendMessageInput, - ) -> Result; + async fn send_message( + &self, + team_id: Uuid, + user_id: Uuid, + input: SendMessageInput, + ) -> Result; - async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError>; + async fn delete_message( + &self, + message_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError>; } diff --git a/imphnen-hackathon/src/chat/infrastructure/http/dto.rs b/imphnen-hackathon/src/chat/infrastructure/http/dto.rs index 9f883ff..dd3ed01 100644 --- a/imphnen-hackathon/src/chat/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/chat/infrastructure/http/dto.rs @@ -1,43 +1,43 @@ +use crate::chat::domain::entity::*; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::chat::domain::entity::*; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct MessageResponse { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user_fullname: String, - pub user_avatar: Option, - pub message: String, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user_fullname: String, + pub user_avatar: Option, + pub message: String, + pub created_at: Option>, + pub updated_at: Option>, } impl From for MessageResponse { - fn from(e: MessageWithUser) -> Self { - Self { - id: e.id, - team_id: e.team_id, - user_id: e.user_id, - user_fullname: e.user_fullname, - user_avatar: e.user_avatar, - message: e.message, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + fn from(e: MessageWithUser) -> Self { + Self { + id: e.id, + team_id: e.team_id, + user_id: e.user_id, + user_fullname: e.user_fullname, + user_avatar: e.user_avatar, + message: e.message, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SendMessageRequest { - pub message: String, + pub message: String, } impl From for SendMessageInput { - fn from(r: SendMessageRequest) -> Self { - Self { message: r.message } - } + fn from(r: SendMessageRequest) -> Self { + Self { message: r.message } + } } diff --git a/imphnen-hackathon/src/chat/infrastructure/http/handlers.rs b/imphnen-hackathon/src/chat/infrastructure/http/handlers.rs index 67ed4d9..0f6161e 100644 --- a/imphnen-hackathon/src/chat/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/chat/infrastructure/http/handlers.rs @@ -1,36 +1,42 @@ +use super::dto::*; +use crate::chat::domain::service::ChatService; +use crate::middleware::hackathon_auth::HackathonAuthUser; use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use imphnen_utils::{ + errors::AppError, + response_format::{ApiMessage, ApiSuccess}, +}; use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}}; -use crate::middleware::hackathon_auth::HackathonAuthUser; -use crate::chat::domain::service::ChatService; -use super::dto::*; pub async fn get_team_messages_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, ) -> Result { - let messages = service.get_team_messages(team_id, auth.user_id).await?; - let response: Vec = messages.into_iter().map(MessageResponse::from).collect(); - Ok(ApiSuccess(response).into_response()) + let messages = service.get_team_messages(team_id, auth.user_id).await?; + let response: Vec = + messages.into_iter().map(MessageResponse::from).collect(); + Ok(ApiSuccess(response).into_response()) } pub async fn send_message_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, + Json(body): Json, ) -> Result { - let message = service.send_message(team_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(MessageResponse::from(message)).into_response()) + let message = service + .send_message(team_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(MessageResponse::from(message)).into_response()) } pub async fn delete_message_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(message_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(message_id): Path, ) -> Result { - service.delete_message(message_id, auth.user_id).await?; - Ok(ApiMessage::ok("Message deleted").into_response()) + service.delete_message(message_id, auth.user_id).await?; + Ok(ApiMessage::ok("Message deleted").into_response()) } diff --git a/imphnen-hackathon/src/chat/infrastructure/http/routes.rs b/imphnen-hackathon/src/chat/infrastructure/http/routes.rs index 21b2172..3f13d63 100644 --- a/imphnen-hackathon/src/chat/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/chat/infrastructure/http/routes.rs @@ -1,20 +1,27 @@ -use axum::{middleware::from_fn, routing::{delete, get}, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; use crate::chat::application::chat_service::ChatServiceImpl; use crate::chat::domain::service::ChatService; use crate::chat::infrastructure::persistence::PostgresChatRepository; use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{delete, get}, +}; +use sqlx::PgPool; +use std::sync::Arc; pub fn build_chat_routes(pool: Arc) -> Router { - let service: Arc = Arc::new(ChatServiceImpl::new( - Arc::new(PostgresChatRepository::new(pool.clone())), - )); - Router::new() - .route("/chat/teams/:team_id", get(get_team_messages_handler).post(send_message_handler)) - .route("/chat/messages/:message_id", delete(delete_message_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) + let service: Arc = Arc::new(ChatServiceImpl::new(Arc::new( + PostgresChatRepository::new(pool.clone()), + ))); + Router::new() + .route( + "/chat/teams/:team_id", + get(get_team_messages_handler).post(send_message_handler), + ) + .route("/chat/messages/:message_id", delete(delete_message_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) } diff --git a/imphnen-hackathon/src/chat/infrastructure/persistence/postgres_chat_repository.rs b/imphnen-hackathon/src/chat/infrastructure/persistence/postgres_chat_repository.rs index 2cab1aa..a131ed1 100644 --- a/imphnen-hackathon/src/chat/infrastructure/persistence/postgres_chat_repository.rs +++ b/imphnen-hackathon/src/chat/infrastructure/persistence/postgres_chat_repository.rs @@ -1,127 +1,161 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::chat::domain::entity::*; use crate::chat::domain::repository::ChatRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] struct MessageRow { - id: Uuid, - team_id: Uuid, - user_id: Uuid, - message: String, - created_at: Option>, - updated_at: Option>, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: String, + created_at: Option>, + updated_at: Option>, } impl From for MessageEntity { - fn from(r: MessageRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - user_id: r.user_id, - message: r.message, - created_at: r.created_at, - updated_at: r.updated_at, - } - } + fn from(r: MessageRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + user_id: r.user_id, + message: r.message, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } #[derive(FromRow)] struct MessageWithUserRow { - id: Uuid, - team_id: Uuid, - user_id: Uuid, - user_fullname: String, - user_avatar: Option, - message: String, - created_at: Option>, - updated_at: Option>, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + user_fullname: String, + user_avatar: Option, + message: String, + created_at: Option>, + updated_at: Option>, } impl From for MessageWithUser { - fn from(r: MessageWithUserRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - user_id: r.user_id, - user_fullname: r.user_fullname, - user_avatar: r.user_avatar, - message: r.message, - created_at: r.created_at, - updated_at: r.updated_at, - } - } + fn from(r: MessageWithUserRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + user_id: r.user_id, + user_fullname: r.user_fullname, + user_avatar: r.user_avatar, + message: r.message, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } #[derive(FromRow)] struct UserInfoRow { - fullname: String, - avatar: Option, + fullname: String, + avatar: Option, } pub struct PostgresChatRepository { - pool: Arc, + pool: Arc, } impl PostgresChatRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } + pub fn new(pool: Arc) -> Self { + Self { pool } + } } #[async_trait] impl ChatRepository for PostgresChatRepository { - async fn find_team_messages(&self, team_id: Uuid) -> Result, AppError> { - let rows: Vec = sqlx::query_as( + async fn find_team_messages( + &self, + team_id: Uuid, + ) -> Result, AppError> { + let rows: Vec = sqlx::query_as( "SELECT m.id, m.team_id, m.user_id, u.fullname AS user_fullname, u.avatar AS user_avatar, m.message, m.created_at, m.updated_at FROM hackathon_team_messages m JOIN hackathon_users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at ASC" ) .bind(team_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + Ok(rows.into_iter().map(Into::into).collect()) + } - async fn create_message(&self, id: Uuid, team_id: Uuid, user_id: Uuid, message: &str) -> Result { - let now = Utc::now(); - let row: MessageRow = sqlx::query_as( + async fn create_message( + &self, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: &str, + ) -> Result { + let now = Utc::now(); + let row: MessageRow = sqlx::query_as( "INSERT INTO hackathon_team_messages (id, team_id, user_id, message, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, team_id, user_id, message, created_at, updated_at" ) .bind(id).bind(team_id).bind(user_id).bind(message).bind(now).bind(now) .fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn find_message_by_id(&self, id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn find_message_by_id( + &self, + id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, team_id, user_id, message, created_at, updated_at FROM hackathon_team_messages WHERE id = $1" ) .bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn delete_message(&self, id: Uuid) -> Result { - let result = sqlx::query("DELETE FROM hackathon_team_messages WHERE id = $1") - .bind(id).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(result.rows_affected() > 0) - } + async fn delete_message(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM hackathon_team_messages WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(result.rows_affected() > 0) + } - async fn get_user_info(&self, user_id: Uuid) -> Result)>, AppError> { - let row: Option = sqlx::query_as( - "SELECT fullname, avatar FROM hackathon_users WHERE id = $1" - ) - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(|r| (r.fullname, r.avatar))) - } + async fn get_user_info( + &self, + user_id: Uuid, + ) -> Result)>, AppError> { + let row: Option = + sqlx::query_as("SELECT fullname, avatar FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(row.map(|r| (r.fullname, r.avatar))) + } - async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") + async fn is_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)") - .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn is_team_leader( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)", + ) + .bind(team_id) + .bind(user_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } } diff --git a/imphnen-hackathon/src/chat/mod.rs b/imphnen-hackathon/src/chat/mod.rs index 4aba622..7b7e6bd 100644 --- a/imphnen-hackathon/src/chat/mod.rs +++ b/imphnen-hackathon/src/chat/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::build_chat_routes; diff --git a/imphnen-hackathon/src/common/cities.rs b/imphnen-hackathon/src/common/cities.rs index a9030c0..8e8af73 100644 --- a/imphnen-hackathon/src/common/cities.rs +++ b/imphnen-hackathon/src/common/cities.rs @@ -1,120 +1,509 @@ pub static INDONESIAN_CITIES: &[&str] = &[ - "Aceh", "Banda Aceh", "Sabang", "Langsa", "Lhokseumawe", "Subulussalam", - "Bireuen", "Aceh Besar", "Aceh Timur", "Aceh Utara", "Aceh Barat", "Nagan Raya", - "Aceh Selatan", "Aceh Tenggara", "Gayo Lues", "Aceh Tengah", "Bener Meriah", - "Pidie", "Pidie Jaya", "Aceh Jaya", "Aceh Barat Daya", "Aceh Singkil", "Simeulue", - "Medan", "Binjai", "Tebing Tinggi", "Pematangsiantar", "Tanjungbalai", "Sibolga", - "Padangsidimpuan", "Gunungsitoli", - "Deli Serdang", "Asahan", "Langkat", "Serdang Bedagai", "Batubara", "Labuhanbatu", - "Labuhanbatu Utara", "Labuhanbatu Selatan", "Karo", "Dairi", "Pakpak Bharat", - "Humbang Hasundutan", "Toba", "Samosir", "Tapanuli Utara", "Tapanuli Tengah", - "Tapanuli Selatan", "Padang Lawas", "Padang Lawas Utara", "Mandailing Natal", - "Nias", "Nias Utara", "Nias Barat", "Nias Selatan", - "Padang", "Solok", "Sawah Lunto", "Padangpanjang", "Bukittinggi", "Payakumbuh", "Pariaman", - "Agam", "Limapuluh Kota", "Tanah Datar", "Padang Pariaman", "Pesisir Selatan", - "Solok Selatan", "Sijunjung", "Dharmasraya", "Pasaman", "Pasaman Barat", - "Kepulauan Mentawai", - "Pekanbaru", "Dumai", - "Kampar", "Pelalawan", "Siak", "Bengkalis", "Rokan Hilir", "Rokan Hulu", - "Kuantan Singingi", "Indragiri Hulu", "Indragiri Hilir", "Kepulauan Meranti", - "Jambi", "Sungai Penuh", - "Batanghari", "Muaro Jambi", "Tanjung Jabung Timur", "Tanjung Jabung Barat", - "Sarolangun", "Merangin", "Bungo", "Tebo", "Kerinci", - "Palembang", "Pagar Alam", "Lubuklinggau", "Prabumulih", - "Ogan Komering Ulu", "Ogan Komering Ulu Timur", "Ogan Komering Ulu Selatan", - "Ogan Komering Ilir", "Ogan Ilir", "Muara Enim", "Lahat", "Empat Lawang", - "Musi Banyuasin", "Banyuasin", "Musi Rawas", "Musi Rawas Utara", "Penukal Abab Lematang Ilir", - "Bengkulu", "Bengkulu Utara", "Bengkulu Selatan", "Bengkulu Tengah", - "Rejang Lebong", "Kepahiang", "Lebong", "Seluma", "Kaur", "Mukomuko", - "Bandar Lampung", "Metro", - "Lampung Utara", "Lampung Selatan", "Lampung Tengah", "Lampung Barat", "Lampung Timur", - "Tulang Bawang", "Tulang Bawang Barat", "Mesuji", "Pringsewu", "Pesawaran", - "Tanggamus", "Way Kanan", "Pesisir Barat", - "Pangkalpinang", - "Bangka", "Bangka Tengah", "Bangka Selatan", "Bangka Barat", "Belitung", "Belitung Timur", - "Tanjungpinang", "Batam", - "Bintan", "Karimun", "Natuna", "Anambas", "Lingga", - "Jakarta", "Jakarta Selatan", "Jakarta Timur", "Jakarta Pusat", "Jakarta Barat", "Jakarta Utara", - "Kepulauan Seribu", - "Bogor", "Sukabumi", "Bandung", "Cirebon", "Bekasi", "Depok", "Cimahi", "Tasikmalaya", "Banjar", - "Cianjur", "Garut", "Tasikmalaya", "Ciamis", "Kuningan", "Majalengka", "Sumedang", - "Indramayu", "Subang", "Purwakarta", "Karawang", "Bekasi", "Bandung Barat", "Pangandaran", - "Semarang", "Surakarta", "Magelang", "Salatiga", "Pekalongan", "Tegal", - "Cilacap", "Banyumas", "Purbalingga", "Banjarnegara", "Kebumen", "Purworejo", - "Wonosobo", "Magelang", "Boyolali", "Klaten", "Sukoharjo", "Wonogiri", "Karanganyar", - "Sragen", "Grobogan", "Blora", "Rembang", "Pati", "Kudus", "Jepara", "Demak", - "Semarang", "Temanggung", "Kendal", "Batang", "Pekalongan", "Pemalang", "Tegal", "Brebes", - "Yogyakarta", - "Sleman", "Bantul", "Kulon Progo", "Gunungkidul", - "Surabaya", "Malang", "Kediri", "Blitar", "Madiun", "Mojokerto", "Pasuruan", "Probolinggo", - "Batu", - "Pacitan", "Ponorogo", "Trenggalek", "Tulungagung", "Blitar", "Kediri", "Malang", - "Lumajang", "Jember", "Banyuwangi", "Bondowoso", "Situbondo", "Probolinggo", "Pasuruan", - "Sidoarjo", "Mojokerto", "Jombang", "Nganjuk", "Madiun", "Magetan", "Ngawi", - "Bojonegoro", "Tuban", "Lamongan", "Gresik", "Bangkalan", "Sampang", "Pamekasan", "Sumenep", - "Serang", "Cilegon", "Tangerang", "Tangerang Selatan", - "Pandeglang", "Lebak", "Tangerang", - "Denpasar", - "Badung", "Gianyar", "Tabanan", "Bangli", "Klungkung", "Buleleng", "Jembrana", "Karangasem", - "Mataram", "Bima", - "Lombok Barat", "Lombok Tengah", "Lombok Timur", "Lombok Utara", "Sumbawa", "Sumbawa Barat", - "Dompu", "Bima", - "Kupang", - "Sumba Barat", "Sumba Timur", "Sumba Tengah", "Sumba Barat Daya", - "Flores Timur", "Sikka", "Ende", "Ngada", "Nagekeo", "Manggarai", "Manggarai Timur", - "Manggarai Barat", "Rote Ndao", "Kupang", "Timor Tengah Selatan", "Timor Tengah Utara", - "Belu", "Malaka", "Alor", "Lembata", - "Pontianak", "Singkawang", - "Sambas", "Bengkayang", "Landak", "Mempawah", "Sanggau", "Sekadau", "Melawi", "Sintang", - "Kapuas Hulu", "Kubu Raya", "Kayong Utara", "Ketapang", - "Palangkaraya", - "Kotawaringin Barat", "Kotawaringin Timur", "Kapuas", "Barito Selatan", "Barito Utara", - "Katingan", "Seruyan", "Sukamara", "Lamandau", "Gunung Mas", "Pulang Pisau", - "Murung Raya", "Barito Timur", - "Banjarmasin", "Banjarbaru", - "Tanah Laut", "Kotabaru", "Banjar", "Barito Kuala", "Tapin", "Hulu Sungai Selatan", - "Hulu Sungai Tengah", "Hulu Sungai Utara", "Tabalong", "Tanah Bumbu", "Balangan", - "Samarinda", "Balikpapan", "Bontang", - "Paser", "Kutai Barat", "Kutai Kartanegara", "Kutai Timur", "Berau", - "Penajam Paser Utara", "Mahakam Ulu", - "Tarakan", - "Bulungan", "Tana Tidung", "Malinau", "Nunukan", - "Manado", "Bitung", "Tomohon", "Kotamobagu", - "Minahasa", "Minahasa Utara", "Minahasa Selatan", "Minahasa Tenggara", - "Bolaang Mongondow", "Bolaang Mongondow Utara", "Bolaang Mongondow Selatan", - "Bolaang Mongondow Timur", "Kepulauan Sangihe", "Kepulauan Sitaro", "Kepulauan Talaud", - "Palu", - "Donggala", "Sigi", "Parigi Moutong", "Tojo Una-Una", "Banggai", "Banggai Kepulauan", - "Banggai Laut", "Morowali", "Morowali Utara", "Poso", "Toli-Toli", "Buol", - "Gorontalo", - "Gorontalo", "Bone Bolango", "Pohuwato", "Boalemo", "Gorontalo Utara", - "Makassar", "Parepare", "Palopo", - "Gowa", "Takalar", "Jeneponto", "Bantaeng", "Bulukumba", "Selayar", "Sinjai", - "Bone", "Soppeng", "Wajo", "Sidrap", "Pinrang", "Enrekang", "Tana Toraja", - "Toraja Utara", "Luwu", "Luwu Timur", "Luwu Utara", "Barru", "Pangkep", "Maros", - "Mamuju", "Mamuju Tengah", "Mamuju Utara", - "Mamasa", "Polewali Mandar", "Majene", - "Kendari", "Baubau", - "Konawe", "Konawe Selatan", "Konawe Utara", "Konawe Kepulauan", "Kolaka", "Kolaka Timur", - "Kolaka Utara", "Bombana", "Buton", "Buton Selatan", "Buton Tengah", "Buton Utara", - "Muna", "Muna Barat", "Wakatobi", - "Ambon", "Tual", - "Buru", "Buru Selatan", "Seram Bagian Barat", "Seram Bagian Timur", "Maluku Tengah", - "Maluku Tenggara", "Maluku Barat Daya", "Kepulauan Aru", - "Ternate", "Tidore Kepulauan", - "Halmahera Barat", "Halmahera Utara", "Halmahera Timur", "Halmahera Selatan", - "Halmahera Tengah", "Kepulauan Sula", "Pulau Morotai", "Pulau Taliabu", - "Jayapura", - "Merauke", "Jayawijaya", "Mimika", "Boven Digoel", "Mappi", "Asmat", "Yahukimo", - "Pegunungan Bintang", "Tolikara", "Sarmi", "Keerom", "Waropen", "Supiori", - "Mamberamo Raya", "Nduga", "Lanny Jaya", "Mamberamo Tengah", "Yalimo", "Puncak", - "Dogiyai", "Intan Jaya", "Deiyai", "Puncak Jaya", - "Sorong", "Sorong Selatan", "Raja Ampat", "Teluk Bintuni", "Teluk Wondama", - "Manokwari", "Manokwari Selatan", "Pegunungan Arfak", "Fakfak", "Kaimana", - "Maybrat", "Tambrauw", + "Aceh", + "Banda Aceh", + "Sabang", + "Langsa", + "Lhokseumawe", + "Subulussalam", + "Bireuen", + "Aceh Besar", + "Aceh Timur", + "Aceh Utara", + "Aceh Barat", + "Nagan Raya", + "Aceh Selatan", + "Aceh Tenggara", + "Gayo Lues", + "Aceh Tengah", + "Bener Meriah", + "Pidie", + "Pidie Jaya", + "Aceh Jaya", + "Aceh Barat Daya", + "Aceh Singkil", + "Simeulue", + "Medan", + "Binjai", + "Tebing Tinggi", + "Pematangsiantar", + "Tanjungbalai", + "Sibolga", + "Padangsidimpuan", + "Gunungsitoli", + "Deli Serdang", + "Asahan", + "Langkat", + "Serdang Bedagai", + "Batubara", + "Labuhanbatu", + "Labuhanbatu Utara", + "Labuhanbatu Selatan", + "Karo", + "Dairi", + "Pakpak Bharat", + "Humbang Hasundutan", + "Toba", + "Samosir", + "Tapanuli Utara", + "Tapanuli Tengah", + "Tapanuli Selatan", + "Padang Lawas", + "Padang Lawas Utara", + "Mandailing Natal", + "Nias", + "Nias Utara", + "Nias Barat", + "Nias Selatan", + "Padang", + "Solok", + "Sawah Lunto", + "Padangpanjang", + "Bukittinggi", + "Payakumbuh", + "Pariaman", + "Agam", + "Limapuluh Kota", + "Tanah Datar", + "Padang Pariaman", + "Pesisir Selatan", + "Solok Selatan", + "Sijunjung", + "Dharmasraya", + "Pasaman", + "Pasaman Barat", + "Kepulauan Mentawai", + "Pekanbaru", + "Dumai", + "Kampar", + "Pelalawan", + "Siak", + "Bengkalis", + "Rokan Hilir", + "Rokan Hulu", + "Kuantan Singingi", + "Indragiri Hulu", + "Indragiri Hilir", + "Kepulauan Meranti", + "Jambi", + "Sungai Penuh", + "Batanghari", + "Muaro Jambi", + "Tanjung Jabung Timur", + "Tanjung Jabung Barat", + "Sarolangun", + "Merangin", + "Bungo", + "Tebo", + "Kerinci", + "Palembang", + "Pagar Alam", + "Lubuklinggau", + "Prabumulih", + "Ogan Komering Ulu", + "Ogan Komering Ulu Timur", + "Ogan Komering Ulu Selatan", + "Ogan Komering Ilir", + "Ogan Ilir", + "Muara Enim", + "Lahat", + "Empat Lawang", + "Musi Banyuasin", + "Banyuasin", + "Musi Rawas", + "Musi Rawas Utara", + "Penukal Abab Lematang Ilir", + "Bengkulu", + "Bengkulu Utara", + "Bengkulu Selatan", + "Bengkulu Tengah", + "Rejang Lebong", + "Kepahiang", + "Lebong", + "Seluma", + "Kaur", + "Mukomuko", + "Bandar Lampung", + "Metro", + "Lampung Utara", + "Lampung Selatan", + "Lampung Tengah", + "Lampung Barat", + "Lampung Timur", + "Tulang Bawang", + "Tulang Bawang Barat", + "Mesuji", + "Pringsewu", + "Pesawaran", + "Tanggamus", + "Way Kanan", + "Pesisir Barat", + "Pangkalpinang", + "Bangka", + "Bangka Tengah", + "Bangka Selatan", + "Bangka Barat", + "Belitung", + "Belitung Timur", + "Tanjungpinang", + "Batam", + "Bintan", + "Karimun", + "Natuna", + "Anambas", + "Lingga", + "Jakarta", + "Jakarta Selatan", + "Jakarta Timur", + "Jakarta Pusat", + "Jakarta Barat", + "Jakarta Utara", + "Kepulauan Seribu", + "Bogor", + "Sukabumi", + "Bandung", + "Cirebon", + "Bekasi", + "Depok", + "Cimahi", + "Tasikmalaya", + "Banjar", + "Cianjur", + "Garut", + "Tasikmalaya", + "Ciamis", + "Kuningan", + "Majalengka", + "Sumedang", + "Indramayu", + "Subang", + "Purwakarta", + "Karawang", + "Bekasi", + "Bandung Barat", + "Pangandaran", + "Semarang", + "Surakarta", + "Magelang", + "Salatiga", + "Pekalongan", + "Tegal", + "Cilacap", + "Banyumas", + "Purbalingga", + "Banjarnegara", + "Kebumen", + "Purworejo", + "Wonosobo", + "Magelang", + "Boyolali", + "Klaten", + "Sukoharjo", + "Wonogiri", + "Karanganyar", + "Sragen", + "Grobogan", + "Blora", + "Rembang", + "Pati", + "Kudus", + "Jepara", + "Demak", + "Semarang", + "Temanggung", + "Kendal", + "Batang", + "Pekalongan", + "Pemalang", + "Tegal", + "Brebes", + "Yogyakarta", + "Sleman", + "Bantul", + "Kulon Progo", + "Gunungkidul", + "Surabaya", + "Malang", + "Kediri", + "Blitar", + "Madiun", + "Mojokerto", + "Pasuruan", + "Probolinggo", + "Batu", + "Pacitan", + "Ponorogo", + "Trenggalek", + "Tulungagung", + "Blitar", + "Kediri", + "Malang", + "Lumajang", + "Jember", + "Banyuwangi", + "Bondowoso", + "Situbondo", + "Probolinggo", + "Pasuruan", + "Sidoarjo", + "Mojokerto", + "Jombang", + "Nganjuk", + "Madiun", + "Magetan", + "Ngawi", + "Bojonegoro", + "Tuban", + "Lamongan", + "Gresik", + "Bangkalan", + "Sampang", + "Pamekasan", + "Sumenep", + "Serang", + "Cilegon", + "Tangerang", + "Tangerang Selatan", + "Pandeglang", + "Lebak", + "Tangerang", + "Denpasar", + "Badung", + "Gianyar", + "Tabanan", + "Bangli", + "Klungkung", + "Buleleng", + "Jembrana", + "Karangasem", + "Mataram", + "Bima", + "Lombok Barat", + "Lombok Tengah", + "Lombok Timur", + "Lombok Utara", + "Sumbawa", + "Sumbawa Barat", + "Dompu", + "Bima", + "Kupang", + "Sumba Barat", + "Sumba Timur", + "Sumba Tengah", + "Sumba Barat Daya", + "Flores Timur", + "Sikka", + "Ende", + "Ngada", + "Nagekeo", + "Manggarai", + "Manggarai Timur", + "Manggarai Barat", + "Rote Ndao", + "Kupang", + "Timor Tengah Selatan", + "Timor Tengah Utara", + "Belu", + "Malaka", + "Alor", + "Lembata", + "Pontianak", + "Singkawang", + "Sambas", + "Bengkayang", + "Landak", + "Mempawah", + "Sanggau", + "Sekadau", + "Melawi", + "Sintang", + "Kapuas Hulu", + "Kubu Raya", + "Kayong Utara", + "Ketapang", + "Palangkaraya", + "Kotawaringin Barat", + "Kotawaringin Timur", + "Kapuas", + "Barito Selatan", + "Barito Utara", + "Katingan", + "Seruyan", + "Sukamara", + "Lamandau", + "Gunung Mas", + "Pulang Pisau", + "Murung Raya", + "Barito Timur", + "Banjarmasin", + "Banjarbaru", + "Tanah Laut", + "Kotabaru", + "Banjar", + "Barito Kuala", + "Tapin", + "Hulu Sungai Selatan", + "Hulu Sungai Tengah", + "Hulu Sungai Utara", + "Tabalong", + "Tanah Bumbu", + "Balangan", + "Samarinda", + "Balikpapan", + "Bontang", + "Paser", + "Kutai Barat", + "Kutai Kartanegara", + "Kutai Timur", + "Berau", + "Penajam Paser Utara", + "Mahakam Ulu", + "Tarakan", + "Bulungan", + "Tana Tidung", + "Malinau", + "Nunukan", + "Manado", + "Bitung", + "Tomohon", + "Kotamobagu", + "Minahasa", + "Minahasa Utara", + "Minahasa Selatan", + "Minahasa Tenggara", + "Bolaang Mongondow", + "Bolaang Mongondow Utara", + "Bolaang Mongondow Selatan", + "Bolaang Mongondow Timur", + "Kepulauan Sangihe", + "Kepulauan Sitaro", + "Kepulauan Talaud", + "Palu", + "Donggala", + "Sigi", + "Parigi Moutong", + "Tojo Una-Una", + "Banggai", + "Banggai Kepulauan", + "Banggai Laut", + "Morowali", + "Morowali Utara", + "Poso", + "Toli-Toli", + "Buol", + "Gorontalo", + "Gorontalo", + "Bone Bolango", + "Pohuwato", + "Boalemo", + "Gorontalo Utara", + "Makassar", + "Parepare", + "Palopo", + "Gowa", + "Takalar", + "Jeneponto", + "Bantaeng", + "Bulukumba", + "Selayar", + "Sinjai", + "Bone", + "Soppeng", + "Wajo", + "Sidrap", + "Pinrang", + "Enrekang", + "Tana Toraja", + "Toraja Utara", + "Luwu", + "Luwu Timur", + "Luwu Utara", + "Barru", + "Pangkep", + "Maros", + "Mamuju", + "Mamuju Tengah", + "Mamuju Utara", + "Mamasa", + "Polewali Mandar", + "Majene", + "Kendari", + "Baubau", + "Konawe", + "Konawe Selatan", + "Konawe Utara", + "Konawe Kepulauan", + "Kolaka", + "Kolaka Timur", + "Kolaka Utara", + "Bombana", + "Buton", + "Buton Selatan", + "Buton Tengah", + "Buton Utara", + "Muna", + "Muna Barat", + "Wakatobi", + "Ambon", + "Tual", + "Buru", + "Buru Selatan", + "Seram Bagian Barat", + "Seram Bagian Timur", + "Maluku Tengah", + "Maluku Tenggara", + "Maluku Barat Daya", + "Kepulauan Aru", + "Ternate", + "Tidore Kepulauan", + "Halmahera Barat", + "Halmahera Utara", + "Halmahera Timur", + "Halmahera Selatan", + "Halmahera Tengah", + "Kepulauan Sula", + "Pulau Morotai", + "Pulau Taliabu", + "Jayapura", + "Merauke", + "Jayawijaya", + "Mimika", + "Boven Digoel", + "Mappi", + "Asmat", + "Yahukimo", + "Pegunungan Bintang", + "Tolikara", + "Sarmi", + "Keerom", + "Waropen", + "Supiori", + "Mamberamo Raya", + "Nduga", + "Lanny Jaya", + "Mamberamo Tengah", + "Yalimo", + "Puncak", + "Dogiyai", + "Intan Jaya", + "Deiyai", + "Puncak Jaya", + "Sorong", + "Sorong Selatan", + "Raja Ampat", + "Teluk Bintuni", + "Teluk Wondama", + "Manokwari", + "Manokwari Selatan", + "Pegunungan Arfak", + "Fakfak", + "Kaimana", + "Maybrat", + "Tambrauw", ]; pub fn is_valid_indonesian_city(city: &str) -> bool { - let city_lower = city.to_lowercase(); - INDONESIAN_CITIES.iter().any(|c| c.to_lowercase() == city_lower) + let city_lower = city.to_lowercase(); + INDONESIAN_CITIES + .iter() + .any(|c| c.to_lowercase() == city_lower) } diff --git a/imphnen-hackathon/src/config.rs b/imphnen-hackathon/src/config.rs deleted file mode 100644 index bc4fc00..0000000 --- a/imphnen-hackathon/src/config.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::env; - -#[derive(Debug, Clone)] -pub struct HackathonConfig { - pub smtp_host: String, - pub smtp_user: String, - pub smtp_password: String, - pub from_email: String, - pub frontend_url: String, -} - -impl HackathonConfig { - pub fn from_env() -> Self { - Self { - smtp_host: env::var("HACKATHON_SMTP_HOST").unwrap_or_default(), - smtp_user: env::var("HACKATHON_SMTP_USER").unwrap_or_default(), - smtp_password: env::var("HACKATHON_SMTP_PASSWORD").unwrap_or_default(), - from_email: env::var("HACKATHON_FROM_EMAIL").unwrap_or_default(), - frontend_url: env::var("HACKATHON_FRONTEND_URL") - .unwrap_or_else(|_| "https://hackathon.imphnen.dev".to_string()), - } - } -} diff --git a/imphnen-hackathon/src/invitations/application/invitation_service.rs b/imphnen-hackathon/src/invitations/application/invitation_service.rs index f0122da..2c4b78e 100644 --- a/imphnen-hackathon/src/invitations/application/invitation_service.rs +++ b/imphnen-hackathon/src/invitations/application/invitation_service.rs @@ -1,129 +1,184 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use chrono::{Utc, TimeZone}; -use imphnen_utils::errors::AppError; use crate::invitations::domain::entity::*; use crate::invitations::domain::repository::InvitationRepository; use crate::invitations::domain::service::InvitationService; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; fn is_team_features_closed() -> bool { - let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap(); - Utc::now() >= deadline + let deadline = Utc + .with_ymd_and_hms(2025, 11, 30, 16, 59, 0) + .single() + .expect("valid constant date"); + Utc::now() >= deadline } pub struct InvitationServiceImpl { - repo: Arc, + repo: Arc, } impl InvitationServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } - async fn do_invite( - &self, - team_id: Uuid, - inviter_id: Uuid, - input: CreateInvitationInput, - ) -> Result { - if is_team_features_closed() { - return Err(AppError::BadRequestError( - "Team invitations are closed (deadline: November 30, 2025).".to_string(), - )); - } - let leader_id = self.repo.get_team_leader_id(team_id).await? - .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; - if leader_id != inviter_id { - return Err(AppError::ForbiddenError("Only the team leader can send invitations".to_string())); - } - if self.repo.team_has_submission(team_id).await? { - return Err(AppError::BadRequestError("Cannot invite after submitting a project".to_string())); - } - let count = self.repo.active_member_count(team_id).await?; - if count >= 5 { - return Err(AppError::BadRequestError("Team already has the maximum of 5 members".to_string())); - } - let team_name = self.repo.get_team_name(team_id).await? - .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; - let inviter_fullname = self.repo.get_inviter_name(inviter_id).await? - .unwrap_or_else(|| "Unknown".to_string()); - let invitation_id = Uuid::new_v4(); - let entity = self.repo.create(invitation_id, team_id, inviter_id, &input.invitee_email).await?; - tracing::warn!("Email sending is not available; invitation created for {}", input.invitee_email); - Ok(InvitationWithDetails { - id: entity.id, - team_id: entity.team_id, - team_name, - inviter_id: entity.inviter_id, - inviter_fullname, - invitee_email: entity.invitee_email, - status: entity.status, - created_at: entity.created_at, - }) - } + async fn do_invite( + &self, + team_id: Uuid, + inviter_id: Uuid, + input: CreateInvitationInput, + ) -> Result { + if is_team_features_closed() { + return Err(AppError::BadRequestError( + "Team invitations are closed (deadline: November 30, 2025).".to_string(), + )); + } + let leader_id = self + .repo + .get_team_leader_id(team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; + if leader_id != inviter_id { + return Err(AppError::ForbiddenError( + "Only the team leader can send invitations".to_string(), + )); + } + if self.repo.team_has_submission(team_id).await? { + return Err(AppError::BadRequestError( + "Cannot invite after submitting a project".to_string(), + )); + } + let count = self.repo.active_member_count(team_id).await?; + if count >= 5 { + return Err(AppError::BadRequestError( + "Team already has the maximum of 5 members".to_string(), + )); + } + let team_name = self + .repo + .get_team_name(team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; + let inviter_fullname = self + .repo + .get_inviter_name(inviter_id) + .await? + .unwrap_or_else(|| "Unknown".to_string()); + let invitation_id = Uuid::new_v4(); + let entity = self + .repo + .create(invitation_id, team_id, inviter_id, &input.invitee_email) + .await?; + tracing::warn!( + "Email sending is not available; invitation created for {}", + input.invitee_email + ); + Ok(InvitationWithDetails { + id: entity.id, + team_id: entity.team_id, + team_name, + inviter_id: entity.inviter_id, + inviter_fullname, + invitee_email: entity.invitee_email, + status: entity.status, + created_at: entity.created_at, + }) + } } #[async_trait] impl InvitationService for InvitationServiceImpl { - async fn invite_member( - &self, - team_id: Uuid, - inviter_id: Uuid, - input: CreateInvitationInput, - ) -> Result { - self.do_invite(team_id, inviter_id, input).await - } + async fn invite_member( + &self, + team_id: Uuid, + inviter_id: Uuid, + input: CreateInvitationInput, + ) -> Result { + self.do_invite(team_id, inviter_id, input).await + } - async fn invite_member_for_team( - &self, - team_id: Uuid, - inviter_id: Uuid, - input: CreateInvitationInput, - ) -> Result { - self.do_invite(team_id, inviter_id, input).await - } + async fn invite_member_for_team( + &self, + team_id: Uuid, + inviter_id: Uuid, + input: CreateInvitationInput, + ) -> Result { + self.do_invite(team_id, inviter_id, input).await + } - async fn get_my_invitations(&self, user_id: Uuid) -> Result, AppError> { - let email = self.repo.get_user_email(user_id).await? - .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; - self.repo.find_pending_by_email(&email).await - } + async fn get_my_invitations( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let email = self + .repo + .get_user_email(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; + self.repo.find_pending_by_email(&email).await + } - async fn respond_to_invitation( - &self, - invitation_id: Uuid, - user_id: Uuid, - accept: bool, - ) -> Result<(), AppError> { - let invitation = self.repo.find_by_id(invitation_id).await? - .ok_or_else(|| AppError::NotFoundError("Invitation not found".to_string()))?; - let user_email = self.repo.get_user_email(user_id).await? - .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; - if invitation.invitee_email != user_email { - return Err(AppError::ForbiddenError("This invitation is not for you".to_string())); - } - if invitation.status != "pending" { - return Err(AppError::BadRequestError("Invitation is no longer pending".to_string())); - } - if accept { - if self.repo.team_has_submission(invitation.team_id).await? { - return Err(AppError::BadRequestError("Cannot join a team that has already submitted".to_string())); - } - if let Some(active_team) = self.repo.user_active_team_name(user_id).await? { - return Err(AppError::ConflictError(format!("You are already a member of team '{}'", active_team))); - } - let count = self.repo.active_member_count(invitation.team_id).await?; - if count >= 5 { - return Err(AppError::BadRequestError("Team is already full".to_string())); - } - self.repo.update_status(invitation_id, "accepted").await?; - self.repo.add_team_member(invitation.team_id, user_id).await?; - self.repo.reject_pending_for_email_except(&user_email, invitation_id).await?; - self.repo.reject_pending_join_requests_for_user(user_id).await?; - } else { - self.repo.update_status(invitation_id, "rejected").await?; - } - Ok(()) - } + async fn respond_to_invitation( + &self, + invitation_id: Uuid, + user_id: Uuid, + accept: bool, + ) -> Result<(), AppError> { + let invitation = + self.repo.find_by_id(invitation_id).await?.ok_or_else(|| { + AppError::NotFoundError("Invitation not found".to_string()) + })?; + let user_email = self + .repo + .get_user_email(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?; + if invitation.invitee_email != user_email { + return Err(AppError::ForbiddenError( + "This invitation is not for you".to_string(), + )); + } + if invitation.status != "pending" { + return Err(AppError::BadRequestError( + "Invitation is no longer pending".to_string(), + )); + } + if accept { + if self.repo.team_has_submission(invitation.team_id).await? { + return Err(AppError::BadRequestError( + "Cannot join a team that has already submitted".to_string(), + )); + } + if let Some(active_team) = self.repo.user_active_team_name(user_id).await? { + return Err(AppError::ConflictError(format!( + "You are already a member of team '{}'", + active_team + ))); + } + let count = self.repo.active_member_count(invitation.team_id).await?; + if count >= 5 { + return Err(AppError::BadRequestError( + "Team is already full".to_string(), + )); + } + self.repo.update_status(invitation_id, "accepted").await?; + self + .repo + .add_team_member(invitation.team_id, user_id) + .await?; + self + .repo + .reject_pending_for_email_except(&user_email, invitation_id) + .await?; + self + .repo + .reject_pending_join_requests_for_user(user_id) + .await?; + } else { + self.repo.update_status(invitation_id, "rejected").await?; + } + Ok(()) + } } diff --git a/imphnen-hackathon/src/invitations/domain/entity.rs b/imphnen-hackathon/src/invitations/domain/entity.rs index d699da7..6eeb51e 100644 --- a/imphnen-hackathon/src/invitations/domain/entity.rs +++ b/imphnen-hackathon/src/invitations/domain/entity.rs @@ -1,29 +1,29 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct InvitationEntity { - pub id: Uuid, - pub team_id: Uuid, - pub inviter_id: Uuid, - pub invitee_email: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub inviter_id: Uuid, + pub invitee_email: String, + pub status: String, + pub created_at: Option>, } #[derive(Debug, Clone)] pub struct InvitationWithDetails { - pub id: Uuid, - pub team_id: Uuid, - pub team_name: String, - pub inviter_id: Uuid, - pub inviter_fullname: String, - pub invitee_email: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub team_name: String, + pub inviter_id: Uuid, + pub inviter_fullname: String, + pub invitee_email: String, + pub status: String, + pub created_at: Option>, } #[derive(Debug, Default)] pub struct CreateInvitationInput { - pub invitee_email: String, + pub invitee_email: String, } diff --git a/imphnen-hackathon/src/invitations/domain/repository.rs b/imphnen-hackathon/src/invitations/domain/repository.rs index 68fa25e..08d24e3 100644 --- a/imphnen-hackathon/src/invitations/domain/repository.rs +++ b/imphnen-hackathon/src/invitations/domain/repository.rs @@ -1,41 +1,65 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait InvitationRepository: Send + Sync { - async fn create( - &self, - invitation_id: Uuid, - team_id: Uuid, - inviter_id: Uuid, - invitee_email: &str, - ) -> Result; + async fn create( + &self, + invitation_id: Uuid, + team_id: Uuid, + inviter_id: Uuid, + invitee_email: &str, + ) -> Result; - async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) + -> Result, AppError>; - async fn find_pending_by_email(&self, email: &str) -> Result, AppError>; + async fn find_pending_by_email( + &self, + email: &str, + ) -> Result, AppError>; - async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>; + async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>; - async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError>; + async fn reject_pending_for_email_except( + &self, + email: &str, + except_id: Uuid, + ) -> Result<(), AppError>; - async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; + async fn add_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError>; - async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError>; + async fn reject_pending_join_requests_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError>; - async fn get_team_leader_id(&self, team_id: Uuid) -> Result, AppError>; + async fn get_team_leader_id( + &self, + team_id: Uuid, + ) -> Result, AppError>; - async fn get_team_name(&self, team_id: Uuid) -> Result, AppError>; + async fn get_team_name(&self, team_id: Uuid) -> Result, AppError>; - async fn get_user_email(&self, user_id: Uuid) -> Result, AppError>; + async fn get_user_email(&self, user_id: Uuid) -> Result, AppError>; - async fn get_inviter_name(&self, user_id: Uuid) -> Result, AppError>; + async fn get_inviter_name( + &self, + user_id: Uuid, + ) -> Result, AppError>; - async fn active_member_count(&self, team_id: Uuid) -> Result; + async fn active_member_count(&self, team_id: Uuid) -> Result; - async fn team_has_submission(&self, team_id: Uuid) -> Result; + async fn team_has_submission(&self, team_id: Uuid) -> Result; - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError>; + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError>; } diff --git a/imphnen-hackathon/src/invitations/domain/service.rs b/imphnen-hackathon/src/invitations/domain/service.rs index 04d88a4..ac42f25 100644 --- a/imphnen-hackathon/src/invitations/domain/service.rs +++ b/imphnen-hackathon/src/invitations/domain/service.rs @@ -1,30 +1,33 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait InvitationService: Send + Sync { - async fn invite_member( - &self, - team_id: Uuid, - inviter_id: Uuid, - input: CreateInvitationInput, - ) -> Result; + async fn invite_member( + &self, + team_id: Uuid, + inviter_id: Uuid, + input: CreateInvitationInput, + ) -> Result; - async fn invite_member_for_team( - &self, - team_id: Uuid, - inviter_id: Uuid, - input: CreateInvitationInput, - ) -> Result; + async fn invite_member_for_team( + &self, + team_id: Uuid, + inviter_id: Uuid, + input: CreateInvitationInput, + ) -> Result; - async fn get_my_invitations(&self, user_id: Uuid) -> Result, AppError>; + async fn get_my_invitations( + &self, + user_id: Uuid, + ) -> Result, AppError>; - async fn respond_to_invitation( - &self, - invitation_id: Uuid, - user_id: Uuid, - accept: bool, - ) -> Result<(), AppError>; + async fn respond_to_invitation( + &self, + invitation_id: Uuid, + user_id: Uuid, + accept: bool, + ) -> Result<(), AppError>; } diff --git a/imphnen-hackathon/src/invitations/infrastructure/http/dto.rs b/imphnen-hackathon/src/invitations/infrastructure/http/dto.rs index 91c6649..7be4cc6 100644 --- a/imphnen-hackathon/src/invitations/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/invitations/infrastructure/http/dto.rs @@ -1,50 +1,50 @@ +use crate::invitations::domain::entity::*; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::invitations::domain::entity::*; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct InvitationResponse { - pub id: Uuid, - pub team_id: Uuid, - pub team_name: String, - pub inviter_id: Uuid, - pub inviter_fullname: String, - pub invitee_email: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub team_name: String, + pub inviter_id: Uuid, + pub inviter_fullname: String, + pub invitee_email: String, + pub status: String, + pub created_at: Option>, } impl From for InvitationResponse { - fn from(e: InvitationWithDetails) -> Self { - Self { - id: e.id, - team_id: e.team_id, - team_name: e.team_name, - inviter_id: e.inviter_id, - inviter_fullname: e.inviter_fullname, - invitee_email: e.invitee_email, - status: e.status, - created_at: e.created_at, - } - } + fn from(e: InvitationWithDetails) -> Self { + Self { + id: e.id, + team_id: e.team_id, + team_name: e.team_name, + inviter_id: e.inviter_id, + inviter_fullname: e.inviter_fullname, + invitee_email: e.invitee_email, + status: e.status, + created_at: e.created_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RespondToInvitationRequest { - pub accept: bool, + pub accept: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateInvitationRequest { - pub invitee_email: String, + pub invitee_email: String, } impl From for CreateInvitationInput { - fn from(r: CreateInvitationRequest) -> Self { - Self { - invitee_email: r.invitee_email, - } - } + fn from(r: CreateInvitationRequest) -> Self { + Self { + invitee_email: r.invitee_email, + } + } } diff --git a/imphnen-hackathon/src/invitations/infrastructure/http/handlers.rs b/imphnen-hackathon/src/invitations/infrastructure/http/handlers.rs index 94230e2..ddbdb6e 100644 --- a/imphnen-hackathon/src/invitations/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/invitations/infrastructure/http/handlers.rs @@ -1,37 +1,49 @@ +use super::dto::*; +use crate::invitations::domain::service::InvitationService; +use crate::middleware::hackathon_auth::HackathonAuthUser; use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use imphnen_utils::{ + errors::AppError, + response_format::{ApiMessage, ApiSuccess}, +}; use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}}; -use crate::middleware::hackathon_auth::HackathonAuthUser; -use crate::invitations::domain::service::InvitationService; -use super::dto::*; pub async fn get_my_invitations_handler( - Extension(service): Extension>, - Extension(auth): Extension, + Extension(service): Extension>, + Extension(auth): Extension, ) -> Result { - let list = service.get_my_invitations(auth.user_id).await?; - let response: Vec = list.into_iter().map(InvitationResponse::from).collect(); - Ok(ApiSuccess(response).into_response()) + let list = service.get_my_invitations(auth.user_id).await?; + let response: Vec = + list.into_iter().map(InvitationResponse::from).collect(); + Ok(ApiSuccess(response).into_response()) } pub async fn respond_to_invitation_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(invitation_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(invitation_id): Path, + Json(body): Json, ) -> Result { - service.respond_to_invitation(invitation_id, auth.user_id, body.accept).await?; - let msg = if body.accept { "Invitation accepted" } else { "Invitation declined" }; - Ok(ApiMessage::ok(msg).into_response()) + service + .respond_to_invitation(invitation_id, auth.user_id, body.accept) + .await?; + let msg = if body.accept { + "Invitation accepted" + } else { + "Invitation declined" + }; + Ok(ApiMessage::ok(msg).into_response()) } pub async fn invite_team_member_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, + Json(body): Json, ) -> Result { - let invitation = service.invite_member(team_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response()) + let invitation = service + .invite_member(team_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response()) } diff --git a/imphnen-hackathon/src/invitations/infrastructure/http/routes.rs b/imphnen-hackathon/src/invitations/infrastructure/http/routes.rs index ceff93a..2145a03 100644 --- a/imphnen-hackathon/src/invitations/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/invitations/infrastructure/http/routes.rs @@ -1,21 +1,31 @@ -use axum::{middleware::from_fn, routing::{get, post}, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; use crate::invitations::application::invitation_service::InvitationServiceImpl; use crate::invitations::domain::service::InvitationService; use crate::invitations::infrastructure::persistence::PostgresInvitationRepository; use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{get, post}, +}; +use sqlx::PgPool; +use std::sync::Arc; pub fn build_invitation_routes(pool: Arc) -> Router { - let service: Arc = Arc::new(InvitationServiceImpl::new( - Arc::new(PostgresInvitationRepository::new(pool.clone())), - )); - Router::new() - .route("/invitations/my", get(get_my_invitations_handler)) - .route("/invitations/:invitation_id/respond", post(respond_to_invitation_handler)) - .route("/invitations/teams/:team_id/invite", post(invite_team_member_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) + let service: Arc = Arc::new(InvitationServiceImpl::new( + Arc::new(PostgresInvitationRepository::new(pool.clone())), + )); + Router::new() + .route("/invitations/my", get(get_my_invitations_handler)) + .route( + "/invitations/:invitation_id/respond", + post(respond_to_invitation_handler), + ) + .route( + "/invitations/teams/:team_id/invite", + post(invite_team_member_handler), + ) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) } diff --git a/imphnen-hackathon/src/invitations/infrastructure/persistence/postgres_invitation_repository.rs b/imphnen-hackathon/src/invitations/infrastructure/persistence/postgres_invitation_repository.rs index 5e81d90..385da5c 100644 --- a/imphnen-hackathon/src/invitations/infrastructure/persistence/postgres_invitation_repository.rs +++ b/imphnen-hackathon/src/invitations/infrastructure/persistence/postgres_invitation_repository.rs @@ -1,159 +1,211 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::invitations::domain::entity::*; use crate::invitations::domain::repository::InvitationRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] struct InvitationRow { - id: Uuid, - team_id: Uuid, - inviter_id: Uuid, - invitee_email: String, - status: String, - created_at: Option>, + id: Uuid, + team_id: Uuid, + inviter_id: Uuid, + invitee_email: String, + status: String, + created_at: Option>, } impl From for InvitationEntity { - fn from(r: InvitationRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - inviter_id: r.inviter_id, - invitee_email: r.invitee_email, - status: r.status, - created_at: r.created_at, - } - } + fn from(r: InvitationRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + inviter_id: r.inviter_id, + invitee_email: r.invitee_email, + status: r.status, + created_at: r.created_at, + } + } } #[derive(FromRow)] struct InvitationDetailsRow { - id: Uuid, - team_id: Uuid, - team_name: String, - inviter_id: Uuid, - inviter_fullname: String, - invitee_email: String, - status: String, - created_at: Option>, + id: Uuid, + team_id: Uuid, + team_name: String, + inviter_id: Uuid, + inviter_fullname: String, + invitee_email: String, + status: String, + created_at: Option>, } impl From for InvitationWithDetails { - fn from(r: InvitationDetailsRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - team_name: r.team_name, - inviter_id: r.inviter_id, - inviter_fullname: r.inviter_fullname, - invitee_email: r.invitee_email, - status: r.status, - created_at: r.created_at, - } - } + fn from(r: InvitationDetailsRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + team_name: r.team_name, + inviter_id: r.inviter_id, + inviter_fullname: r.inviter_fullname, + invitee_email: r.invitee_email, + status: r.status, + created_at: r.created_at, + } + } } pub struct PostgresInvitationRepository { - pool: Arc, + pool: Arc, } impl PostgresInvitationRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } + pub fn new(pool: Arc) -> Self { + Self { pool } + } } #[async_trait] impl InvitationRepository for PostgresInvitationRepository { - async fn create(&self, invitation_id: Uuid, team_id: Uuid, inviter_id: Uuid, invitee_email: &str) -> Result { - let row: InvitationRow = sqlx::query_as( + async fn create( + &self, + invitation_id: Uuid, + team_id: Uuid, + inviter_id: Uuid, + invitee_email: &str, + ) -> Result { + let row: InvitationRow = sqlx::query_as( "INSERT INTO hackathon_team_invitations (id, team_id, inviter_id, invitee_email, status, created_at) VALUES ($1, $2, $3, $4, 'pending', NOW()) RETURNING id, team_id, inviter_id, invitee_email, status, created_at" ) .bind(invitation_id).bind(team_id).bind(inviter_id).bind(invitee_email) .fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn find_by_id(&self, id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn find_by_id( + &self, + id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, team_id, inviter_id, invitee_email, status, created_at FROM hackathon_team_invitations WHERE id = $1" ) .bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn find_pending_by_email(&self, email: &str) -> Result, AppError> { - let rows: Vec = sqlx::query_as( + async fn find_pending_by_email( + &self, + email: &str, + ) -> Result, AppError> { + let rows: Vec = sqlx::query_as( "SELECT i.id, i.team_id, t.name AS team_name, i.inviter_id, u.fullname AS inviter_fullname, i.invitee_email, i.status, i.created_at FROM hackathon_team_invitations i JOIN hackathon_teams t ON t.id = i.team_id JOIN hackathon_users u ON u.id = i.inviter_id WHERE i.invitee_email = $1 AND i.status = 'pending'" ) .bind(email).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + Ok(rows.into_iter().map(Into::into).collect()) + } - async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_invitations SET status = $1 WHERE id = $2") - .bind(status).bind(id) - .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_invitations SET status = $1 WHERE id = $2") + .bind(status) + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending' AND id != $2") + async fn reject_pending_for_email_except( + &self, + email: &str, + except_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending' AND id != $2") .bind(email).bind(except_id) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())") + async fn add_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())") .bind(Uuid::new_v4()).bind(team_id).bind(user_id) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'") + async fn reject_pending_join_requests_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'") .bind(user_id) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn get_team_leader_id(&self, team_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1") - .bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn get_team_leader_id( + &self, + team_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1") + .bind(team_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn get_team_name(&self, team_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT name FROM hackathon_teams WHERE id = $1") - .bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn get_team_name(&self, team_id: Uuid) -> Result, AppError> { + sqlx::query_scalar("SELECT name FROM hackathon_teams WHERE id = $1") + .bind(team_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn get_user_email(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn get_user_email(&self, user_id: Uuid) -> Result, AppError> { + sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn get_inviter_name(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT fullname FROM hackathon_users WHERE id = $1") - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn get_inviter_name( + &self, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT fullname FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn active_member_count(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") + async fn active_member_count(&self, team_id: Uuid) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn team_has_submission(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)") - .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn team_has_submission(&self, team_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)", + ) + .bind(team_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1") + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1") .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } } diff --git a/imphnen-hackathon/src/invitations/mod.rs b/imphnen-hackathon/src/invitations/mod.rs index 823cb88..d811a9a 100644 --- a/imphnen-hackathon/src/invitations/mod.rs +++ b/imphnen-hackathon/src/invitations/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::build_invitation_routes; diff --git a/imphnen-hackathon/src/join_requests/application/join_request_service.rs b/imphnen-hackathon/src/join_requests/application/join_request_service.rs index 131489d..702c80d 100644 --- a/imphnen-hackathon/src/join_requests/application/join_request_service.rs +++ b/imphnen-hackathon/src/join_requests/application/join_request_service.rs @@ -1,117 +1,175 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use chrono::{Utc, TimeZone}; -use imphnen_utils::errors::AppError; use crate::join_requests::domain::entity::*; use crate::join_requests::domain::repository::JoinRequestRepository; use crate::join_requests::domain::service::JoinRequestService; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; fn is_team_features_closed() -> bool { - let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap(); - Utc::now() >= deadline + let deadline = Utc + .with_ymd_and_hms(2025, 11, 30, 16, 59, 0) + .single() + .expect("valid constant date"); + Utc::now() >= deadline } pub struct JoinRequestServiceImpl { - repo: Arc, + repo: Arc, } impl JoinRequestServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl JoinRequestService for JoinRequestServiceImpl { - async fn create_join_request( - &self, - team_id: Uuid, - user_id: Uuid, - input: CreateJoinRequestInput, - ) -> Result { - if is_team_features_closed() { - return Err(AppError::BadRequestError( - "Join requests are closed (deadline: November 30, 2025).".to_string(), - )); - } - if !self.repo.team_exists(team_id).await? { - return Err(AppError::NotFoundError("Team not found".to_string())); - } - if self.repo.team_has_submission(team_id).await? { - return Err(AppError::BadRequestError("Cannot request to join a team that has already submitted".to_string())); - } - if let Some(active_team) = self.repo.user_active_team_name(user_id).await? { - return Err(AppError::ConflictError(format!("You are already a member of team '{}'", active_team))); - } - let count = self.repo.active_member_count(team_id).await?; - if count >= 5 { - return Err(AppError::BadRequestError("Team is already full (max 5 members)".to_string())); - } - if self.repo.pending_request_exists(team_id, user_id).await? { - return Err(AppError::ConflictError("You already have a pending request for this team".to_string())); - } - let id = Uuid::new_v4(); - let entity = self.repo.create(id, team_id, user_id, &input.message).await?; - let details: Vec = self.repo.find_by_user(user_id).await?; - details.into_iter().find(|r| r.id == entity.id) - .ok_or_else(|| AppError::InternalServerError("Failed to retrieve created join request".to_string())) - } + async fn create_join_request( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateJoinRequestInput, + ) -> Result { + if is_team_features_closed() { + return Err(AppError::BadRequestError( + "Join requests are closed (deadline: November 30, 2025).".to_string(), + )); + } + if !self.repo.team_exists(team_id).await? { + return Err(AppError::NotFoundError("Team not found".to_string())); + } + if self.repo.team_has_submission(team_id).await? { + return Err(AppError::BadRequestError( + "Cannot request to join a team that has already submitted".to_string(), + )); + } + if let Some(active_team) = self.repo.user_active_team_name(user_id).await? { + return Err(AppError::ConflictError(format!( + "You are already a member of team '{}'", + active_team + ))); + } + let count = self.repo.active_member_count(team_id).await?; + if count >= 5 { + return Err(AppError::BadRequestError( + "Team is already full (max 5 members)".to_string(), + )); + } + if self.repo.pending_request_exists(team_id, user_id).await? { + return Err(AppError::ConflictError( + "You already have a pending request for this team".to_string(), + )); + } + let id = Uuid::new_v4(); + let entity = self + .repo + .create(id, team_id, user_id, &input.message) + .await?; + let details: Vec = + self.repo.find_by_user(user_id).await?; + details + .into_iter() + .find(|r| r.id == entity.id) + .ok_or_else(|| { + AppError::InternalServerError( + "Failed to retrieve created join request".to_string(), + ) + }) + } - async fn get_my_join_requests(&self, user_id: Uuid) -> Result, AppError> { - self.repo.find_by_user(user_id).await - } + async fn get_my_join_requests( + &self, + user_id: Uuid, + ) -> Result, AppError> { + self.repo.find_by_user(user_id).await + } - async fn get_team_join_requests( - &self, - team_id: Uuid, - user_id: Uuid, - ) -> Result, AppError> { - let leader_id = self.repo.get_team_leader_id(team_id).await? - .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; - if leader_id != user_id { - return Err(AppError::ForbiddenError("Only the team leader can view join requests".to_string())); - } - self.repo.find_pending_by_team(team_id).await - } + async fn get_team_join_requests( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + let leader_id = self + .repo + .get_team_leader_id(team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; + if leader_id != user_id { + return Err(AppError::ForbiddenError( + "Only the team leader can view join requests".to_string(), + )); + } + self.repo.find_pending_by_team(team_id).await + } - async fn respond_to_join_request( - &self, - request_id: Uuid, - user_id: Uuid, - accept: bool, - ) -> Result<(), AppError> { - let request = self.repo.find_by_id(request_id).await? - .ok_or_else(|| AppError::NotFoundError("Join request not found".to_string()))?; - let leader_id = self.repo.get_team_leader_id(request.team_id).await? - .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; - if leader_id != user_id { - return Err(AppError::ForbiddenError("Only the team leader can respond to join requests".to_string())); - } - if request.status != "pending" { - return Err(AppError::BadRequestError("Join request is no longer pending".to_string())); - } - if accept { - if is_team_features_closed() { - return Err(AppError::BadRequestError("Team features are now closed".to_string())); - } - if self.repo.team_has_submission(request.team_id).await? { - return Err(AppError::BadRequestError("Cannot accept join request after submitting a project".to_string())); - } - if let Some(active_team) = self.repo.user_active_team_name(request.user_id).await? { - return Err(AppError::ConflictError(format!("User is already a member of team '{}'", active_team))); - } - let count = self.repo.active_member_count(request.team_id).await?; - if count >= 5 { - return Err(AppError::BadRequestError("Team is already full".to_string())); - } - self.repo.update_status(request_id, "accepted").await?; - self.repo.add_team_member(request.team_id, request.user_id).await?; - self.repo.reject_pending_invitations_for_user(request.user_id).await?; - self.repo.reject_other_pending_for_user(request.user_id, request_id).await?; - } else { - self.repo.update_status(request_id, "rejected").await?; - } - Ok(()) - } + async fn respond_to_join_request( + &self, + request_id: Uuid, + user_id: Uuid, + accept: bool, + ) -> Result<(), AppError> { + let request = self.repo.find_by_id(request_id).await?.ok_or_else(|| { + AppError::NotFoundError("Join request not found".to_string()) + })?; + let leader_id = self + .repo + .get_team_leader_id(request.team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; + if leader_id != user_id { + return Err(AppError::ForbiddenError( + "Only the team leader can respond to join requests".to_string(), + )); + } + if request.status != "pending" { + return Err(AppError::BadRequestError( + "Join request is no longer pending".to_string(), + )); + } + if accept { + if is_team_features_closed() { + return Err(AppError::BadRequestError( + "Team features are now closed".to_string(), + )); + } + if self.repo.team_has_submission(request.team_id).await? { + return Err(AppError::BadRequestError( + "Cannot accept join request after submitting a project".to_string(), + )); + } + if let Some(active_team) = + self.repo.user_active_team_name(request.user_id).await? + { + return Err(AppError::ConflictError(format!( + "User is already a member of team '{}'", + active_team + ))); + } + let count = self.repo.active_member_count(request.team_id).await?; + if count >= 5 { + return Err(AppError::BadRequestError( + "Team is already full".to_string(), + )); + } + self.repo.update_status(request_id, "accepted").await?; + self + .repo + .add_team_member(request.team_id, request.user_id) + .await?; + self + .repo + .reject_pending_invitations_for_user(request.user_id) + .await?; + self + .repo + .reject_other_pending_for_user(request.user_id, request_id) + .await?; + } else { + self.repo.update_status(request_id, "rejected").await?; + } + Ok(()) + } } diff --git a/imphnen-hackathon/src/join_requests/domain/entity.rs b/imphnen-hackathon/src/join_requests/domain/entity.rs index 9296bd1..88e6397 100644 --- a/imphnen-hackathon/src/join_requests/domain/entity.rs +++ b/imphnen-hackathon/src/join_requests/domain/entity.rs @@ -1,30 +1,30 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct JoinRequestEntity { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub message: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub message: String, + pub status: String, + pub created_at: Option>, } #[derive(Debug, Clone)] pub struct JoinRequestWithDetails { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user_fullname: String, - pub user_email: String, - pub user_avatar: Option, - pub message: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user_fullname: String, + pub user_email: String, + pub user_avatar: Option, + pub message: String, + pub status: String, + pub created_at: Option>, } #[derive(Debug, Default)] pub struct CreateJoinRequestInput { - pub message: String, + pub message: String, } diff --git a/imphnen-hackathon/src/join_requests/domain/repository.rs b/imphnen-hackathon/src/join_requests/domain/repository.rs index eae9265..da042c0 100644 --- a/imphnen-hackathon/src/join_requests/domain/repository.rs +++ b/imphnen-hackathon/src/join_requests/domain/repository.rs @@ -1,43 +1,73 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait JoinRequestRepository: Send + Sync { - async fn create( - &self, - id: Uuid, - team_id: Uuid, - user_id: Uuid, - message: &str, - ) -> Result; + async fn create( + &self, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: &str, + ) -> Result; - async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_by_id( + &self, + id: Uuid, + ) -> Result, AppError>; - async fn find_by_user(&self, user_id: Uuid) -> Result, AppError>; + async fn find_by_user( + &self, + user_id: Uuid, + ) -> Result, AppError>; - async fn find_pending_by_team(&self, team_id: Uuid) -> Result, AppError>; + async fn find_pending_by_team( + &self, + team_id: Uuid, + ) -> Result, AppError>; - async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>; + async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>; - async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; + async fn add_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError>; - async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError>; + async fn reject_pending_invitations_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError>; - async fn reject_other_pending_for_user(&self, user_id: Uuid, except_id: Uuid) -> Result<(), AppError>; + async fn reject_other_pending_for_user( + &self, + user_id: Uuid, + except_id: Uuid, + ) -> Result<(), AppError>; - async fn get_team_leader_id(&self, team_id: Uuid) -> Result, AppError>; + async fn get_team_leader_id( + &self, + team_id: Uuid, + ) -> Result, AppError>; - async fn get_user_email(&self, user_id: Uuid) -> Result, AppError>; + async fn get_user_email(&self, user_id: Uuid) -> Result, AppError>; - async fn team_exists(&self, team_id: Uuid) -> Result; + async fn team_exists(&self, team_id: Uuid) -> Result; - async fn team_has_submission(&self, team_id: Uuid) -> Result; + async fn team_has_submission(&self, team_id: Uuid) -> Result; - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError>; + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError>; - async fn active_member_count(&self, team_id: Uuid) -> Result; + async fn active_member_count(&self, team_id: Uuid) -> Result; - async fn pending_request_exists(&self, team_id: Uuid, user_id: Uuid) -> Result; + async fn pending_request_exists( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; } diff --git a/imphnen-hackathon/src/join_requests/domain/service.rs b/imphnen-hackathon/src/join_requests/domain/service.rs index 650bda9..2f59be9 100644 --- a/imphnen-hackathon/src/join_requests/domain/service.rs +++ b/imphnen-hackathon/src/join_requests/domain/service.rs @@ -1,29 +1,32 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait JoinRequestService: Send + Sync { - async fn create_join_request( - &self, - team_id: Uuid, - user_id: Uuid, - input: CreateJoinRequestInput, - ) -> Result; + async fn create_join_request( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateJoinRequestInput, + ) -> Result; - async fn get_my_join_requests(&self, user_id: Uuid) -> Result, AppError>; + async fn get_my_join_requests( + &self, + user_id: Uuid, + ) -> Result, AppError>; - async fn get_team_join_requests( - &self, - team_id: Uuid, - user_id: Uuid, - ) -> Result, AppError>; + async fn get_team_join_requests( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result, AppError>; - async fn respond_to_join_request( - &self, - request_id: Uuid, - user_id: Uuid, - accept: bool, - ) -> Result<(), AppError>; + async fn respond_to_join_request( + &self, + request_id: Uuid, + user_id: Uuid, + accept: bool, + ) -> Result<(), AppError>; } diff --git a/imphnen-hackathon/src/join_requests/infrastructure/http/dto.rs b/imphnen-hackathon/src/join_requests/infrastructure/http/dto.rs index 13050eb..4f0305b 100644 --- a/imphnen-hackathon/src/join_requests/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/join_requests/infrastructure/http/dto.rs @@ -1,50 +1,50 @@ +use crate::join_requests::domain::entity::*; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::join_requests::domain::entity::*; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct JoinRequestResponse { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user_fullname: String, - pub user_email: String, - pub user_avatar: Option, - pub message: String, - pub status: String, - pub created_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user_fullname: String, + pub user_email: String, + pub user_avatar: Option, + pub message: String, + pub status: String, + pub created_at: Option>, } impl From for JoinRequestResponse { - fn from(e: JoinRequestWithDetails) -> Self { - Self { - id: e.id, - team_id: e.team_id, - user_id: e.user_id, - user_fullname: e.user_fullname, - user_email: e.user_email, - user_avatar: e.user_avatar, - message: e.message, - status: e.status, - created_at: e.created_at, - } - } + fn from(e: JoinRequestWithDetails) -> Self { + Self { + id: e.id, + team_id: e.team_id, + user_id: e.user_id, + user_fullname: e.user_fullname, + user_email: e.user_email, + user_avatar: e.user_avatar, + message: e.message, + status: e.status, + created_at: e.created_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateJoinRequestRequest { - pub message: String, + pub message: String, } impl From for CreateJoinRequestInput { - fn from(r: CreateJoinRequestRequest) -> Self { - Self { message: r.message } - } + fn from(r: CreateJoinRequestRequest) -> Self { + Self { message: r.message } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RespondToJoinRequestRequest { - pub accept: bool, + pub accept: bool, } diff --git a/imphnen-hackathon/src/join_requests/infrastructure/http/handlers.rs b/imphnen-hackathon/src/join_requests/infrastructure/http/handlers.rs index bb333a6..8aa1282 100644 --- a/imphnen-hackathon/src/join_requests/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/join_requests/infrastructure/http/handlers.rs @@ -1,47 +1,62 @@ +use super::dto::*; +use crate::join_requests::domain::service::JoinRequestService; +use crate::middleware::hackathon_auth::HackathonAuthUser; use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use imphnen_utils::{ + errors::AppError, + response_format::{ApiMessage, ApiSuccess}, +}; use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}}; -use crate::middleware::hackathon_auth::HackathonAuthUser; -use crate::join_requests::domain::service::JoinRequestService; -use super::dto::*; pub async fn create_join_request_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, + Json(body): Json, ) -> Result { - let request = service.create_join_request(team_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(JoinRequestResponse::from(request)).into_response()) + let request = service + .create_join_request(team_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(JoinRequestResponse::from(request)).into_response()) } pub async fn get_my_join_requests_handler( - Extension(service): Extension>, - Extension(auth): Extension, + Extension(service): Extension>, + Extension(auth): Extension, ) -> Result { - let list = service.get_my_join_requests(auth.user_id).await?; - let response: Vec = list.into_iter().map(JoinRequestResponse::from).collect(); - Ok(ApiSuccess(response).into_response()) + let list = service.get_my_join_requests(auth.user_id).await?; + let response: Vec = + list.into_iter().map(JoinRequestResponse::from).collect(); + Ok(ApiSuccess(response).into_response()) } pub async fn get_team_join_requests_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, ) -> Result { - let list = service.get_team_join_requests(team_id, auth.user_id).await?; - let response: Vec = list.into_iter().map(JoinRequestResponse::from).collect(); - Ok(ApiSuccess(response).into_response()) + let list = service + .get_team_join_requests(team_id, auth.user_id) + .await?; + let response: Vec = + list.into_iter().map(JoinRequestResponse::from).collect(); + Ok(ApiSuccess(response).into_response()) } pub async fn respond_to_join_request_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(request_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(request_id): Path, + Json(body): Json, ) -> Result { - service.respond_to_join_request(request_id, auth.user_id, body.accept).await?; - let msg = if body.accept { "Join request accepted" } else { "Join request rejected" }; - Ok(ApiMessage::ok(msg).into_response()) + service + .respond_to_join_request(request_id, auth.user_id, body.accept) + .await?; + let msg = if body.accept { + "Join request accepted" + } else { + "Join request rejected" + }; + Ok(ApiMessage::ok(msg).into_response()) } diff --git a/imphnen-hackathon/src/join_requests/infrastructure/http/routes.rs b/imphnen-hackathon/src/join_requests/infrastructure/http/routes.rs index 69ad6a6..dd9bf6e 100644 --- a/imphnen-hackathon/src/join_requests/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/join_requests/infrastructure/http/routes.rs @@ -1,22 +1,35 @@ -use axum::{middleware::from_fn, routing::{get, post}, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; use crate::join_requests::application::join_request_service::JoinRequestServiceImpl; use crate::join_requests::domain::service::JoinRequestService; use crate::join_requests::infrastructure::persistence::PostgresJoinRequestRepository; use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{get, post}, +}; +use sqlx::PgPool; +use std::sync::Arc; pub fn build_join_request_routes(pool: Arc) -> Router { - let service: Arc = Arc::new(JoinRequestServiceImpl::new( - Arc::new(PostgresJoinRequestRepository::new(pool.clone())), - )); - Router::new() - .route("/join-requests/teams/:team_id", post(create_join_request_handler)) - .route("/join-requests/my", get(get_my_join_requests_handler)) - .route("/join-requests/teams/:team_id/pending", get(get_team_join_requests_handler)) - .route("/join-requests/:request_id/respond", post(respond_to_join_request_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) + let service: Arc = Arc::new(JoinRequestServiceImpl::new( + Arc::new(PostgresJoinRequestRepository::new(pool.clone())), + )); + Router::new() + .route( + "/join-requests/teams/:team_id", + post(create_join_request_handler), + ) + .route("/join-requests/my", get(get_my_join_requests_handler)) + .route( + "/join-requests/teams/:team_id/pending", + get(get_team_join_requests_handler), + ) + .route( + "/join-requests/:request_id/respond", + post(respond_to_join_request_handler), + ) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) } diff --git a/imphnen-hackathon/src/join_requests/infrastructure/persistence/postgres_join_request_repository.rs b/imphnen-hackathon/src/join_requests/infrastructure/persistence/postgres_join_request_repository.rs index 47a8d63..5b84ff6 100644 --- a/imphnen-hackathon/src/join_requests/infrastructure/persistence/postgres_join_request_repository.rs +++ b/imphnen-hackathon/src/join_requests/infrastructure/persistence/postgres_join_request_repository.rs @@ -1,173 +1,230 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::join_requests::domain::entity::*; use crate::join_requests::domain::repository::JoinRequestRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] struct JoinRequestRow { - id: Uuid, - team_id: Uuid, - user_id: Uuid, - message: String, - status: String, - created_at: Option>, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: String, + status: String, + created_at: Option>, } impl From for JoinRequestEntity { - fn from(r: JoinRequestRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - user_id: r.user_id, - message: r.message, - status: r.status, - created_at: r.created_at, - } - } + fn from(r: JoinRequestRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + user_id: r.user_id, + message: r.message, + status: r.status, + created_at: r.created_at, + } + } } #[derive(FromRow)] struct JoinRequestDetailsRow { - id: Uuid, - team_id: Uuid, - user_id: Uuid, - user_fullname: String, - user_email: String, - user_avatar: Option, - message: String, - status: String, - created_at: Option>, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + user_fullname: String, + user_email: String, + user_avatar: Option, + message: String, + status: String, + created_at: Option>, } impl From for JoinRequestWithDetails { - fn from(r: JoinRequestDetailsRow) -> Self { - Self { - id: r.id, - team_id: r.team_id, - user_id: r.user_id, - user_fullname: r.user_fullname, - user_email: r.user_email, - user_avatar: r.user_avatar, - message: r.message, - status: r.status, - created_at: r.created_at, - } - } + fn from(r: JoinRequestDetailsRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + user_id: r.user_id, + user_fullname: r.user_fullname, + user_email: r.user_email, + user_avatar: r.user_avatar, + message: r.message, + status: r.status, + created_at: r.created_at, + } + } } pub struct PostgresJoinRequestRepository { - pool: Arc, + pool: Arc, } impl PostgresJoinRequestRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } + pub fn new(pool: Arc) -> Self { + Self { pool } + } } #[async_trait] impl JoinRequestRepository for PostgresJoinRequestRepository { - async fn create(&self, id: Uuid, team_id: Uuid, user_id: Uuid, message: &str) -> Result { - let row: JoinRequestRow = sqlx::query_as( + async fn create( + &self, + id: Uuid, + team_id: Uuid, + user_id: Uuid, + message: &str, + ) -> Result { + let row: JoinRequestRow = sqlx::query_as( "INSERT INTO hackathon_team_join_requests (id, team_id, user_id, message, status, created_at) VALUES ($1, $2, $3, $4, 'pending', NOW()) RETURNING id, team_id, user_id, message, status, created_at" ) .bind(id).bind(team_id).bind(user_id).bind(message) .fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn find_by_id(&self, id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn find_by_id( + &self, + id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, team_id, user_id, message, status, created_at FROM hackathon_team_join_requests WHERE id = $1" ) .bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn find_by_user(&self, user_id: Uuid) -> Result, AppError> { - let rows: Vec = sqlx::query_as( + async fn find_by_user( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let rows: Vec = sqlx::query_as( "SELECT r.id, r.team_id, r.user_id, u.fullname AS user_fullname, u.email AS user_email, u.avatar AS user_avatar, r.message, r.status, r.created_at FROM hackathon_team_join_requests r JOIN hackathon_users u ON u.id = r.user_id WHERE r.user_id = $1 ORDER BY r.created_at DESC" ) .bind(user_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + Ok(rows.into_iter().map(Into::into).collect()) + } - async fn find_pending_by_team(&self, team_id: Uuid) -> Result, AppError> { - let rows: Vec = sqlx::query_as( + async fn find_pending_by_team( + &self, + team_id: Uuid, + ) -> Result, AppError> { + let rows: Vec = sqlx::query_as( "SELECT r.id, r.team_id, r.user_id, u.fullname AS user_fullname, u.email AS user_email, u.avatar AS user_avatar, r.message, r.status, r.created_at FROM hackathon_team_join_requests r JOIN hackathon_users u ON u.id = r.user_id WHERE r.team_id = $1 AND r.status = 'pending' ORDER BY r.created_at ASC" ) .bind(team_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + Ok(rows.into_iter().map(Into::into).collect()) + } - async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_join_requests SET status = $1 WHERE id = $2") - .bind(status).bind(id) - .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_join_requests SET status = $1 WHERE id = $2") + .bind(status) + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())") + async fn add_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())") .bind(Uuid::new_v4()).bind(team_id).bind(user_id) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError> { - let email: Option = sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - if let Some(email) = email { - sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending'") + async fn reject_pending_invitations_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError> { + let email: Option = + sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + if let Some(email) = email { + sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending'") .bind(email) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - } - Ok(()) - } + } + Ok(()) + } - async fn reject_other_pending_for_user(&self, user_id: Uuid, except_id: Uuid) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending' AND id != $2") + async fn reject_other_pending_for_user( + &self, + user_id: Uuid, + except_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending' AND id != $2") .bind(user_id).bind(except_id) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn get_team_leader_id(&self, team_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1") - .bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn get_team_leader_id( + &self, + team_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1") + .bind(team_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn get_user_email(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") + async fn get_user_email(&self, user_id: Uuid) -> Result, AppError> { + sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn team_exists(&self, team_id: Uuid) -> Result { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1)") + .bind(team_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn team_has_submission(&self, team_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)", + ) + .bind(team_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1") .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn team_exists(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1)") + async fn active_member_count(&self, team_id: Uuid) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn team_has_submission(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)") - .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1") - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn active_member_count(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") - .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn pending_request_exists(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_join_requests WHERE team_id = $1 AND user_id = $2 AND status = 'pending')") + async fn pending_request_exists( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_join_requests WHERE team_id = $1 AND user_id = $2 AND status = 'pending')") .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } } diff --git a/imphnen-hackathon/src/join_requests/mod.rs b/imphnen-hackathon/src/join_requests/mod.rs index 80eccad..29dd7e6 100644 --- a/imphnen-hackathon/src/join_requests/mod.rs +++ b/imphnen-hackathon/src/join_requests/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::build_join_request_routes; diff --git a/imphnen-hackathon/src/lib.rs b/imphnen-hackathon/src/lib.rs index fadb7ff..1d4430c 100644 --- a/imphnen-hackathon/src/lib.rs +++ b/imphnen-hackathon/src/lib.rs @@ -1,46 +1,47 @@ -pub mod config; -pub mod common; -pub mod middleware; pub mod admin; pub mod certificates; pub mod chat; +pub mod common; +pub mod invitations; +pub mod join_requests; +pub mod middleware; pub mod storage; pub mod submissions; pub mod teams; pub mod users; -pub mod invitations; -pub mod join_requests; pub mod winners; pub use admin::hackathon_admin_routes; pub use certificates::hackathon_certificates_routes; pub use chat::build_chat_routes; +pub use invitations::build_invitation_routes; +pub use join_requests::build_join_request_routes; pub use storage::hackathon_storage_routes; pub use submissions::hackathon_submissions_routes; pub use teams::build_team_routes; pub use users::hackathon_users_routes; -pub use invitations::build_invitation_routes; -pub use join_requests::build_join_request_routes; pub use winners::hackathon_winners_routes; -pub use config::HackathonConfig; use axum::Router; +use imphnen_storage::MinioService; use sea_orm::DatabaseConnection; use std::sync::Arc; -use imphnen_libs::MinioService; -pub fn hackathon_router(db: DatabaseConnection, _config: Arc, minio: Arc) -> Router { - let pool = Arc::new(db.get_postgres_connection_pool().clone()); +pub fn hackathon_router( + db: DatabaseConnection, + minio: Arc, +) -> Router { + let pool = Arc::new(db.get_postgres_connection_pool().clone()); - Router::new() - .merge(hackathon_users_routes(pool.clone())) - .merge(build_team_routes(pool.clone())) - .merge(build_invitation_routes(pool.clone())) - .merge(build_join_request_routes(pool.clone())) - .merge(build_chat_routes(pool.clone())) - .merge(hackathon_submissions_routes(pool.clone())) - .merge(hackathon_storage_routes(pool.clone(), minio)) - .merge(hackathon_certificates_routes(pool.clone())) - .merge(hackathon_winners_routes(pool.clone())) - .merge(hackathon_admin_routes(pool)) + Router::new() + .merge(hackathon_users_routes(pool.clone())) + .merge(build_team_routes(pool.clone())) + .merge(build_invitation_routes(pool.clone())) + .merge(build_join_request_routes(pool.clone())) + .merge(build_chat_routes(pool.clone())) + .merge(hackathon_submissions_routes(pool.clone())) + .merge(hackathon_storage_routes(pool.clone(), minio)) + .merge(hackathon_certificates_routes(pool.clone())) + .merge(hackathon_winners_routes(pool.clone())) + .merge(hackathon_admin_routes(pool)) } diff --git a/imphnen-hackathon/src/middleware/admin_only.rs b/imphnen-hackathon/src/middleware/admin_only.rs index 72c4751..f0e1b32 100644 --- a/imphnen-hackathon/src/middleware/admin_only.rs +++ b/imphnen-hackathon/src/middleware/admin_only.rs @@ -1,21 +1,25 @@ +use crate::middleware::hackathon_auth::HackathonAuthUser; use axum::{ - body::Body, - extract::Extension, - http::{Request, StatusCode}, - middleware::Next, - response::{IntoResponse, Response}, - Json, + Json, + body::Body, + extract::Extension, + http::{Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, }; use serde_json::json; -use crate::middleware::hackathon_auth::HackathonAuthUser; pub async fn admin_only( - Extension(auth_user): Extension, - req: Request, - next: Next, + Extension(auth_user): Extension, + req: Request, + next: Next, ) -> Response { - if !auth_user.is_admin { - return (StatusCode::FORBIDDEN, Json(json!({ "message": "Forbidden - Admin access required" }))).into_response(); - } - next.run(req).await + if !auth_user.is_admin { + return ( + StatusCode::FORBIDDEN, + Json(json!({ "message": "Forbidden - Admin access required" })), + ) + .into_response(); + } + next.run(req).await } diff --git a/imphnen-hackathon/src/middleware/hackathon_auth.rs b/imphnen-hackathon/src/middleware/hackathon_auth.rs index eaa4749..e7c3dc0 100644 --- a/imphnen-hackathon/src/middleware/hackathon_auth.rs +++ b/imphnen-hackathon/src/middleware/hackathon_auth.rs @@ -1,49 +1,62 @@ -use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}}; use axum::http::StatusCode; +use axum::{ + body::Body, + extract::Request, + middleware::Next, + response::{IntoResponse, Response}, +}; +use imphnen_libs::decode_access_token; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; -use serde::{Deserialize, Serialize}; -use imphnen_libs::decode_access_token; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HackathonAuthUser { - pub user_id: Uuid, - pub is_admin: bool, + pub user_id: Uuid, + pub is_admin: bool, } pub async fn hackathon_auth_middleware( - axum::Extension(pool): axum::Extension>, - mut request: Request, - next: Next, + axum::Extension(pool): axum::Extension>, + mut request: Request, + next: Next, ) -> Result { - let auth_header = request - .headers() - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response())?; + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| { + (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response() + })?; - let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { - (StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response() - })?; + let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "Invalid Authorization header format", + ) + .into_response() + })?; - let token_data = decode_access_token(token).map_err(|_| { - (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response() - })?; + let token_data = decode_access_token(token).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response() + })?; - let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| { - (StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response() - })?; + let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response() + })?; - let is_admin: bool = sqlx::query_scalar( - "SELECT COALESCE(is_admin, false) FROM hackathon_users WHERE id = $1" - ) - .bind(user_id) - .fetch_optional(pool.as_ref()) - .await - .unwrap_or(None) - .unwrap_or(false); + let is_admin: bool = sqlx::query_scalar( + "SELECT COALESCE(is_admin, false) FROM hackathon_users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(pool.as_ref()) + .await + .unwrap_or(None) + .unwrap_or(false); - request.extensions_mut().insert(HackathonAuthUser { user_id, is_admin }); - Ok(next.run(request).await) + request + .extensions_mut() + .insert(HackathonAuthUser { user_id, is_admin }); + Ok(next.run(request).await) } diff --git a/imphnen-hackathon/src/storage/application/mod.rs b/imphnen-hackathon/src/storage/application/mod.rs new file mode 100644 index 0000000..bdc1edb --- /dev/null +++ b/imphnen-hackathon/src/storage/application/mod.rs @@ -0,0 +1 @@ +pub mod storage_service; diff --git a/imphnen-hackathon/src/storage/application/storage_service.rs b/imphnen-hackathon/src/storage/application/storage_service.rs new file mode 100644 index 0000000..ff080f9 --- /dev/null +++ b/imphnen-hackathon/src/storage/application/storage_service.rs @@ -0,0 +1,38 @@ +use crate::storage::domain::service::StorageService; +use async_trait::async_trait; +use chrono::Utc; +use imphnen_storage::MinioService; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; + +pub struct StorageServiceImpl { + minio: Arc, +} + +impl StorageServiceImpl { + pub fn new(minio: Arc) -> Self { + Self { minio } + } +} + +#[async_trait] +impl StorageService for StorageServiceImpl { + async fn upload( + &self, + folder: &str, + user_id: Uuid, + filename: &str, + content_type: &str, + data_base64: &str, + ) -> Result { + let ext = filename.rsplit('.').next().unwrap_or("bin"); + let unique_name = + format!("{}-{}.{}", user_id, Utc::now().timestamp_millis(), ext); + self + .minio + .upload_base64_file(data_base64, content_type, folder, &unique_name) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } +} diff --git a/imphnen-hackathon/src/storage/domain/mod.rs b/imphnen-hackathon/src/storage/domain/mod.rs new file mode 100644 index 0000000..1f278a4 --- /dev/null +++ b/imphnen-hackathon/src/storage/domain/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/imphnen-hackathon/src/storage/domain/service.rs b/imphnen-hackathon/src/storage/domain/service.rs new file mode 100644 index 0000000..e12b7ea --- /dev/null +++ b/imphnen-hackathon/src/storage/domain/service.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait StorageService: Send + Sync { + async fn upload( + &self, + folder: &str, + user_id: Uuid, + filename: &str, + content_type: &str, + data_base64: &str, + ) -> Result; +} diff --git a/imphnen-hackathon/src/storage/infrastructure/http/dto.rs b/imphnen-hackathon/src/storage/infrastructure/http/dto.rs new file mode 100644 index 0000000..017aa2f --- /dev/null +++ b/imphnen-hackathon/src/storage/infrastructure/http/dto.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UploadRequest { + pub filename: String, + pub content_type: String, + pub data: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UploadResponse { + pub url: String, +} diff --git a/imphnen-hackathon/src/storage/infrastructure/http/handlers.rs b/imphnen-hackathon/src/storage/infrastructure/http/handlers.rs new file mode 100644 index 0000000..48c17d0 --- /dev/null +++ b/imphnen-hackathon/src/storage/infrastructure/http/handlers.rs @@ -0,0 +1,74 @@ +use super::dto::{UploadRequest, UploadResponse}; +use crate::middleware::hackathon_auth::HackathonAuthUser; +use crate::storage::domain::service::StorageService; +use axum::{Extension, Json, response::IntoResponse}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; + +pub async fn upload_file_handler( + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + let url = service + .upload( + "uploads", + auth.user_id, + &body.filename, + &body.content_type, + &body.data, + ) + .await?; + Ok(ApiSuccess(UploadResponse { url }).into_response()) +} + +pub async fn upload_avatar_handler( + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + let url = service + .upload( + "avatars", + auth.user_id, + &body.filename, + &body.content_type, + &body.data, + ) + .await?; + Ok(ApiSuccess(UploadResponse { url }).into_response()) +} + +pub async fn upload_team_handler( + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + let url = service + .upload( + "teams", + auth.user_id, + &body.filename, + &body.content_type, + &body.data, + ) + .await?; + Ok(ApiSuccess(UploadResponse { url }).into_response()) +} + +pub async fn upload_submission_handler( + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + let url = service + .upload( + "submissions", + auth.user_id, + &body.filename, + &body.content_type, + &body.data, + ) + .await?; + Ok(ApiSuccess(UploadResponse { url }).into_response()) +} diff --git a/imphnen-hackathon/src/storage/infrastructure/http/mod.rs b/imphnen-hackathon/src/storage/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-hackathon/src/storage/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-hackathon/src/storage/infrastructure/http/routes.rs b/imphnen-hackathon/src/storage/infrastructure/http/routes.rs new file mode 100644 index 0000000..77bab99 --- /dev/null +++ b/imphnen-hackathon/src/storage/infrastructure/http/routes.rs @@ -0,0 +1,23 @@ +use super::handlers::*; +use crate::middleware::hackathon_auth::hackathon_auth_middleware; +use crate::storage::application::storage_service::StorageServiceImpl; +use crate::storage::domain::service::StorageService; +use axum::{Extension, Router, middleware::from_fn, routing::post}; +use imphnen_storage::MinioService; +use sqlx::PgPool; +use std::sync::Arc; + +pub fn hackathon_storage_routes( + pool: Arc, + minio: Arc, +) -> Router { + let service: Arc = Arc::new(StorageServiceImpl::new(minio)); + Router::new() + .route("/upload", post(upload_file_handler)) + .route("/upload/avatar", post(upload_avatar_handler)) + .route("/upload/team", post(upload_team_handler)) + .route("/upload/submission", post(upload_submission_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) +} diff --git a/imphnen-hackathon/src/storage/infrastructure/mod.rs b/imphnen-hackathon/src/storage/infrastructure/mod.rs new file mode 100644 index 0000000..3883215 --- /dev/null +++ b/imphnen-hackathon/src/storage/infrastructure/mod.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/imphnen-hackathon/src/storage/mod.rs b/imphnen-hackathon/src/storage/mod.rs index 9d991bf..76900d1 100644 --- a/imphnen-hackathon/src/storage/mod.rs +++ b/imphnen-hackathon/src/storage/mod.rs @@ -1,4 +1,5 @@ -pub mod service; -pub mod routes; +pub mod application; +pub mod domain; +pub mod infrastructure; -pub use routes::hackathon_storage_routes; +pub use infrastructure::http::routes::hackathon_storage_routes; diff --git a/imphnen-hackathon/src/storage/routes.rs b/imphnen-hackathon/src/storage/routes.rs deleted file mode 100644 index 06498ab..0000000 --- a/imphnen-hackathon/src/storage/routes.rs +++ /dev/null @@ -1,69 +0,0 @@ -use axum::{middleware::from_fn, response::IntoResponse, routing::post, Extension, Json, Router}; -use sqlx::PgPool; -use std::sync::Arc; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; -use imphnen_libs::MinioService; -use crate::middleware::hackathon_auth::{hackathon_auth_middleware, HackathonAuthUser}; -use super::service::StorageService; - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UploadRequest { - pub filename: String, - pub content_type: String, - pub data: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct UploadResponse { - pub url: String, -} - -async fn upload_file_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, -) -> Result { - let url = service.upload("uploads", auth.user_id, &body.filename, &body.content_type, &body.data).await?; - Ok(ApiSuccess(UploadResponse { url }).into_response()) -} - -async fn upload_avatar_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, -) -> Result { - let url = service.upload("avatars", auth.user_id, &body.filename, &body.content_type, &body.data).await?; - Ok(ApiSuccess(UploadResponse { url }).into_response()) -} - -async fn upload_team_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, -) -> Result { - let url = service.upload("teams", auth.user_id, &body.filename, &body.content_type, &body.data).await?; - Ok(ApiSuccess(UploadResponse { url }).into_response()) -} - -async fn upload_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, -) -> Result { - let url = service.upload("submissions", auth.user_id, &body.filename, &body.content_type, &body.data).await?; - Ok(ApiSuccess(UploadResponse { url }).into_response()) -} - -pub fn hackathon_storage_routes(pool: Arc, minio: Arc) -> Router { - let service = Arc::new(StorageService::new(minio)); - Router::new() - .route("/upload", post(upload_file_handler)) - .route("/upload/avatar", post(upload_avatar_handler)) - .route("/upload/team", post(upload_team_handler)) - .route("/upload/submission", post(upload_submission_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) -} diff --git a/imphnen-hackathon/src/storage/service.rs b/imphnen-hackathon/src/storage/service.rs deleted file mode 100644 index 08d532d..0000000 --- a/imphnen-hackathon/src/storage/service.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::Utc; -use imphnen_utils::errors::AppError; -use imphnen_libs::MinioService; - -pub struct StorageService { - minio: Arc, -} - -impl StorageService { - pub fn new(minio: Arc) -> Self { Self { minio } } - - pub async fn upload(&self, folder: &str, user_id: Uuid, filename: &str, content_type: &str, data_base64: &str) -> Result { - let ext = filename.rsplit('.').next().unwrap_or("bin"); - let unique_name = format!("{}-{}.{}", user_id, Utc::now().timestamp_millis(), ext); - self.minio - .upload_base64_file(data_base64, content_type, folder, &unique_name) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } -} diff --git a/imphnen-hackathon/src/submissions/application/submission_service.rs b/imphnen-hackathon/src/submissions/application/submission_service.rs index 18fee30..9b2ba8d 100644 --- a/imphnen-hackathon/src/submissions/application/submission_service.rs +++ b/imphnen-hackathon/src/submissions/application/submission_service.rs @@ -1,98 +1,163 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use chrono::{Utc, TimeZone}; -use imphnen_utils::errors::AppError; use crate::submissions::domain::entity::*; use crate::submissions::domain::repository::SubmissionRepository; use crate::submissions::domain::service::SubmissionService; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; fn is_submission_deadline_passed() -> bool { - let deadline = Utc.with_ymd_and_hms(2025, 12, 7, 16, 59, 0).unwrap(); - Utc::now() >= deadline + let deadline = Utc + .with_ymd_and_hms(2025, 12, 7, 16, 59, 0) + .single() + .expect("valid constant date"); + Utc::now() >= deadline } pub struct SubmissionServiceImpl { - repo: Arc, + repo: Arc, } impl SubmissionServiceImpl { - pub fn new(repo: Arc) -> Self { Self { repo } } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl SubmissionService for SubmissionServiceImpl { - async fn create_submission(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result { - if is_submission_deadline_passed() { - return Err(AppError::BadRequestError("Submission deadline has passed (December 7, 2025 23:59 WIB).".to_string())); - } - if !self.repo.is_team_leader(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can create submission".to_string())); - } - if self.repo.find_by_team(team_id).await?.is_some() { - return Err(AppError::ConflictError("Team already has a submission".to_string())); - } - self.repo.create(team_id, user_id, input).await - } + async fn create_submission( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateSubmissionInput, + ) -> Result { + if is_submission_deadline_passed() { + return Err(AppError::BadRequestError( + "Submission deadline has passed (December 7, 2025 23:59 WIB).".to_string(), + )); + } + if !self.repo.is_team_leader(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can create submission".to_string(), + )); + } + if self.repo.find_by_team(team_id).await?.is_some() { + return Err(AppError::ConflictError( + "Team already has a submission".to_string(), + )); + } + self.repo.create(team_id, user_id, input).await + } - async fn get_team_submission(&self, team_id: Uuid, user_id: Uuid) -> Result { - if !self.repo.is_team_member(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team members can view submission".to_string())); - } - self.repo.find_by_team(team_id).await?.ok_or_else(|| AppError::NotFoundError("No submission found".to_string())) - } + async fn get_team_submission( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + if !self.repo.is_team_member(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team members can view submission".to_string(), + )); + } + self + .repo + .find_by_team(team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("No submission found".to_string())) + } - async fn update_submission(&self, submission_id: Uuid, user_id: Uuid, input: UpdateSubmissionInput) -> Result { - if is_submission_deadline_passed() { - return Err(AppError::BadRequestError("Submission deadline has passed.".to_string())); - } - let sub = self.repo.find_by_id(submission_id).await?; - if !self.repo.is_team_leader(sub.team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can update submission".to_string())); - } - if sub.status != "draft" { - return Err(AppError::BadRequestError("Can only update draft submissions".to_string())); - } - self.repo.update(submission_id, input).await - } + async fn update_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + input: UpdateSubmissionInput, + ) -> Result { + if is_submission_deadline_passed() { + return Err(AppError::BadRequestError( + "Submission deadline has passed.".to_string(), + )); + } + let sub = self.repo.find_by_id(submission_id).await?; + if !self.repo.is_team_leader(sub.team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can update submission".to_string(), + )); + } + if sub.status != "draft" { + return Err(AppError::BadRequestError( + "Can only update draft submissions".to_string(), + )); + } + self.repo.update(submission_id, input).await + } - async fn submit_project(&self, submission_id: Uuid, user_id: Uuid) -> Result { - if is_submission_deadline_passed() { - return Err(AppError::BadRequestError("Submission deadline has passed.".to_string())); - } - let sub = self.repo.find_by_id(submission_id).await?; - if !self.repo.is_team_leader(sub.team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can submit".to_string())); - } - if sub.status != "draft" { - return Err(AppError::BadRequestError("Can only submit from draft status".to_string())); - } - let count = self.repo.team_member_count(sub.team_id).await?; - if count < 2 { - return Err(AppError::BadRequestError("Team must have at least 2 members to submit".to_string())); - } - self.repo.update_status(submission_id, "pending").await - } + async fn submit_project( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result { + if is_submission_deadline_passed() { + return Err(AppError::BadRequestError( + "Submission deadline has passed.".to_string(), + )); + } + let sub = self.repo.find_by_id(submission_id).await?; + if !self.repo.is_team_leader(sub.team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can submit".to_string(), + )); + } + if sub.status != "draft" { + return Err(AppError::BadRequestError( + "Can only submit from draft status".to_string(), + )); + } + let count = self.repo.team_member_count(sub.team_id).await?; + if count < 2 { + return Err(AppError::BadRequestError( + "Team must have at least 2 members to submit".to_string(), + )); + } + self.repo.update_status(submission_id, "pending").await + } - async fn confirm_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result { - let sub = self.repo.find_by_id(submission_id).await?; - if !self.repo.is_team_leader(sub.team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can confirm submission".to_string())); - } - if sub.status != "pending" { - return Err(AppError::BadRequestError("Can only confirm pending submissions".to_string())); - } - self.repo.update_status(submission_id, "submitted").await - } + async fn confirm_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result { + let sub = self.repo.find_by_id(submission_id).await?; + if !self.repo.is_team_leader(sub.team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can confirm submission".to_string(), + )); + } + if sub.status != "pending" { + return Err(AppError::BadRequestError( + "Can only confirm pending submissions".to_string(), + )); + } + self.repo.update_status(submission_id, "submitted").await + } - async fn cancel_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result { - let sub = self.repo.find_by_id(submission_id).await?; - if !self.repo.is_team_leader(sub.team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can cancel submission".to_string())); - } - if sub.status == "submitted" { - return Err(AppError::BadRequestError("Cannot cancel a confirmed submission".to_string())); - } - self.repo.update_status(submission_id, "draft").await - } + async fn cancel_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result { + let sub = self.repo.find_by_id(submission_id).await?; + if !self.repo.is_team_leader(sub.team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can cancel submission".to_string(), + )); + } + if sub.status == "submitted" { + return Err(AppError::BadRequestError( + "Cannot cancel a confirmed submission".to_string(), + )); + } + self.repo.update_status(submission_id, "draft").await + } } diff --git a/imphnen-hackathon/src/submissions/domain/entity.rs b/imphnen-hackathon/src/submissions/domain/entity.rs index c90b54d..3423ab4 100644 --- a/imphnen-hackathon/src/submissions/domain/entity.rs +++ b/imphnen-hackathon/src/submissions/domain/entity.rs @@ -1,39 +1,39 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct SubmissionEntity { - pub id: Uuid, - pub team_id: Uuid, - pub project_name: String, - pub description: String, - pub repository_url: String, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, - pub status: String, - pub submitted_at: Option>, - pub submitted_by: Uuid, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub project_name: String, + pub description: String, + pub repository_url: String, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, + pub status: String, + pub submitted_at: Option>, + pub submitted_by: Uuid, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Default)] pub struct CreateSubmissionInput { - pub project_name: String, - pub description: String, - pub repository_url: String, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, + pub project_name: String, + pub description: String, + pub repository_url: String, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, } #[derive(Debug, Default)] pub struct UpdateSubmissionInput { - pub project_name: Option, - pub description: Option, - pub repository_url: Option, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, + pub project_name: Option, + pub description: Option, + pub repository_url: Option, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, } diff --git a/imphnen-hackathon/src/submissions/domain/repository.rs b/imphnen-hackathon/src/submissions/domain/repository.rs index 7b6fad9..3da693d 100644 --- a/imphnen-hackathon/src/submissions/domain/repository.rs +++ b/imphnen-hackathon/src/submissions/domain/repository.rs @@ -1,16 +1,40 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait SubmissionRepository: Send + Sync { - async fn create(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result; - async fn find_by_team(&self, team_id: Uuid) -> Result, AppError>; - async fn find_by_id(&self, id: Uuid) -> Result; - async fn update(&self, id: Uuid, input: UpdateSubmissionInput) -> Result; - async fn update_status(&self, id: Uuid, status: &str) -> Result; - async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result; - async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result; - async fn team_member_count(&self, team_id: Uuid) -> Result; + async fn create( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateSubmissionInput, + ) -> Result; + async fn find_by_team( + &self, + team_id: Uuid, + ) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn update( + &self, + id: Uuid, + input: UpdateSubmissionInput, + ) -> Result; + async fn update_status( + &self, + id: Uuid, + status: &str, + ) -> Result; + async fn is_team_leader( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; + async fn is_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; + async fn team_member_count(&self, team_id: Uuid) -> Result; } diff --git a/imphnen-hackathon/src/submissions/domain/service.rs b/imphnen-hackathon/src/submissions/domain/service.rs index 9eea81d..de89607 100644 --- a/imphnen-hackathon/src/submissions/domain/service.rs +++ b/imphnen-hackathon/src/submissions/domain/service.rs @@ -1,14 +1,40 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait SubmissionService: Send + Sync { - async fn create_submission(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result; - async fn get_team_submission(&self, team_id: Uuid, user_id: Uuid) -> Result; - async fn update_submission(&self, submission_id: Uuid, user_id: Uuid, input: UpdateSubmissionInput) -> Result; - async fn submit_project(&self, submission_id: Uuid, user_id: Uuid) -> Result; - async fn confirm_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result; - async fn cancel_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result; + async fn create_submission( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateSubmissionInput, + ) -> Result; + async fn get_team_submission( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result; + async fn update_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + input: UpdateSubmissionInput, + ) -> Result; + async fn submit_project( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result; + async fn confirm_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result; + async fn cancel_submission( + &self, + submission_id: Uuid, + user_id: Uuid, + ) -> Result; } diff --git a/imphnen-hackathon/src/submissions/infrastructure/http/dto.rs b/imphnen-hackathon/src/submissions/infrastructure/http/dto.rs index b55ad72..b6618c4 100644 --- a/imphnen-hackathon/src/submissions/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/submissions/infrastructure/http/dto.rs @@ -1,71 +1,88 @@ +use crate::submissions::domain::entity::*; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::submissions::domain::entity::*; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SubmissionResponse { - pub id: Uuid, - pub team_id: Uuid, - pub project_name: String, - pub description: String, - pub repository_url: String, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, - pub status: String, - pub submitted_at: Option>, - pub submitted_by: Uuid, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub project_name: String, + pub description: String, + pub repository_url: String, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, + pub status: String, + pub submitted_at: Option>, + pub submitted_by: Uuid, + pub created_at: Option>, + pub updated_at: Option>, } impl From for SubmissionResponse { - fn from(e: SubmissionEntity) -> Self { - Self { - id: e.id, team_id: e.team_id, project_name: e.project_name, description: e.description, - repository_url: e.repository_url, demo_url: e.demo_url, presentation_url: e.presentation_url, - screenshots: e.screenshots, status: e.status, submitted_at: e.submitted_at, - submitted_by: e.submitted_by, created_at: e.created_at, updated_at: e.updated_at, - } - } + fn from(e: SubmissionEntity) -> Self { + Self { + id: e.id, + team_id: e.team_id, + project_name: e.project_name, + description: e.description, + repository_url: e.repository_url, + demo_url: e.demo_url, + presentation_url: e.presentation_url, + screenshots: e.screenshots, + status: e.status, + submitted_at: e.submitted_at, + submitted_by: e.submitted_by, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateSubmissionRequest { - pub project_name: String, - pub description: String, - pub repository_url: String, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, + pub project_name: String, + pub description: String, + pub repository_url: String, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, } impl From for CreateSubmissionInput { - fn from(r: CreateSubmissionRequest) -> Self { - Self { - project_name: r.project_name, description: r.description, repository_url: r.repository_url, - demo_url: r.demo_url, presentation_url: r.presentation_url, screenshots: r.screenshots, - } - } + fn from(r: CreateSubmissionRequest) -> Self { + Self { + project_name: r.project_name, + description: r.description, + repository_url: r.repository_url, + demo_url: r.demo_url, + presentation_url: r.presentation_url, + screenshots: r.screenshots, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateSubmissionRequest { - pub project_name: Option, - pub description: Option, - pub repository_url: Option, - pub demo_url: Option, - pub presentation_url: Option, - pub screenshots: Option>, + pub project_name: Option, + pub description: Option, + pub repository_url: Option, + pub demo_url: Option, + pub presentation_url: Option, + pub screenshots: Option>, } impl From for UpdateSubmissionInput { - fn from(r: UpdateSubmissionRequest) -> Self { - Self { - project_name: r.project_name, description: r.description, repository_url: r.repository_url, - demo_url: r.demo_url, presentation_url: r.presentation_url, screenshots: r.screenshots, - } - } + fn from(r: UpdateSubmissionRequest) -> Self { + Self { + project_name: r.project_name, + description: r.description, + repository_url: r.repository_url, + demo_url: r.demo_url, + presentation_url: r.presentation_url, + screenshots: r.screenshots, + } + } } diff --git a/imphnen-hackathon/src/submissions/infrastructure/http/handlers.rs b/imphnen-hackathon/src/submissions/infrastructure/http/handlers.rs index 48f5b44..974d47a 100644 --- a/imphnen-hackathon/src/submissions/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/submissions/infrastructure/http/handlers.rs @@ -1,63 +1,71 @@ -use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use std::sync::Arc; -use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use super::dto::*; use crate::middleware::hackathon_auth::HackathonAuthUser; use crate::submissions::domain::service::SubmissionService; -use super::dto::*; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; pub async fn create_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, + Json(body): Json, ) -> Result { - let sub = service.create_submission(team_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service + .create_submission(team_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } pub async fn get_team_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, ) -> Result { - let sub = service.get_team_submission(team_id, auth.user_id).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service.get_team_submission(team_id, auth.user_id).await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } pub async fn update_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(submission_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(submission_id): Path, + Json(body): Json, ) -> Result { - let sub = service.update_submission(submission_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service + .update_submission(submission_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } pub async fn submit_project_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(submission_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(submission_id): Path, ) -> Result { - let sub = service.submit_project(submission_id, auth.user_id).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service.submit_project(submission_id, auth.user_id).await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } pub async fn confirm_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(submission_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(submission_id): Path, ) -> Result { - let sub = service.confirm_submission(submission_id, auth.user_id).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service + .confirm_submission(submission_id, auth.user_id) + .await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } pub async fn cancel_submission_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(submission_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(submission_id): Path, ) -> Result { - let sub = service.cancel_submission(submission_id, auth.user_id).await?; - Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) + let sub = service + .cancel_submission(submission_id, auth.user_id) + .await?; + Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response()) } diff --git a/imphnen-hackathon/src/submissions/infrastructure/http/routes.rs b/imphnen-hackathon/src/submissions/infrastructure/http/routes.rs index 926124f..6621456 100644 --- a/imphnen-hackathon/src/submissions/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/submissions/infrastructure/http/routes.rs @@ -1,21 +1,42 @@ -use axum::{middleware::from_fn, routing::{get, post, put}, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; +use crate::middleware::hackathon_auth::hackathon_auth_middleware; use crate::submissions::application::submission_service::SubmissionServiceImpl; use crate::submissions::domain::service::SubmissionService; use crate::submissions::infrastructure::persistence::PostgresSubmissionRepository; -use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{get, post, put}, +}; +use sqlx::PgPool; +use std::sync::Arc; pub fn hackathon_submissions_routes(pool: Arc) -> Router { - let service: Arc = Arc::new(SubmissionServiceImpl::new(Arc::new(PostgresSubmissionRepository::new(pool.clone())))); - Router::new() - .route("/submissions/teams/:team_id", get(get_team_submission_handler).post(create_submission_handler)) - .route("/submissions/:submission_id", put(update_submission_handler)) - .route("/submissions/:submission_id/submit", post(submit_project_handler)) - .route("/submissions/:submission_id/confirm", post(confirm_submission_handler)) - .route("/submissions/:submission_id/cancel", post(cancel_submission_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) + let service: Arc = Arc::new(SubmissionServiceImpl::new( + Arc::new(PostgresSubmissionRepository::new(pool.clone())), + )); + Router::new() + .route( + "/submissions/teams/:team_id", + get(get_team_submission_handler).post(create_submission_handler), + ) + .route( + "/submissions/:submission_id", + put(update_submission_handler), + ) + .route( + "/submissions/:submission_id/submit", + post(submit_project_handler), + ) + .route( + "/submissions/:submission_id/confirm", + post(confirm_submission_handler), + ) + .route( + "/submissions/:submission_id/cancel", + post(cancel_submission_handler), + ) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) } diff --git a/imphnen-hackathon/src/submissions/infrastructure/persistence/postgres_submission_repository.rs b/imphnen-hackathon/src/submissions/infrastructure/persistence/postgres_submission_repository.rs index 2bb24f6..bbef21b 100644 --- a/imphnen-hackathon/src/submissions/infrastructure/persistence/postgres_submission_repository.rs +++ b/imphnen-hackathon/src/submissions/infrastructure/persistence/postgres_submission_repository.rs @@ -1,106 +1,198 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::submissions::domain::entity::*; use crate::submissions::domain::repository::SubmissionRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] struct SubmissionRow { - id: Uuid, team_id: Uuid, project_name: String, description: String, repository_url: String, - demo_url: Option, presentation_url: Option, screenshots: Option>, - status: String, submitted_at: Option>, submitted_by: Uuid, - created_at: Option>, updated_at: Option>, + id: Uuid, + team_id: Uuid, + project_name: String, + description: String, + repository_url: String, + demo_url: Option, + presentation_url: Option, + screenshots: Option>, + status: String, + submitted_at: Option>, + submitted_by: Uuid, + created_at: Option>, + updated_at: Option>, } impl From for SubmissionEntity { - fn from(r: SubmissionRow) -> Self { - Self { - id: r.id, team_id: r.team_id, project_name: r.project_name, description: r.description, - repository_url: r.repository_url, demo_url: r.demo_url, presentation_url: r.presentation_url, - screenshots: r.screenshots, status: r.status, submitted_at: r.submitted_at, - submitted_by: r.submitted_by, created_at: r.created_at, updated_at: r.updated_at, - } - } + fn from(r: SubmissionRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + project_name: r.project_name, + description: r.description, + repository_url: r.repository_url, + demo_url: r.demo_url, + presentation_url: r.presentation_url, + screenshots: r.screenshots, + status: r.status, + submitted_at: r.submitted_at, + submitted_by: r.submitted_by, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } -pub struct PostgresSubmissionRepository { pool: Arc } -impl PostgresSubmissionRepository { pub fn new(pool: Arc) -> Self { Self { pool } } } +pub struct PostgresSubmissionRepository { + pool: Arc, +} +impl PostgresSubmissionRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} #[async_trait] impl SubmissionRepository for PostgresSubmissionRepository { - async fn create(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result { - let id = Uuid::new_v4(); - let now = Utc::now(); - let row: SubmissionRow = sqlx::query_as( + async fn create( + &self, + team_id: Uuid, + user_id: Uuid, + input: CreateSubmissionInput, + ) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + let row: SubmissionRow = sqlx::query_as( "INSERT INTO hackathon_project_submissions (id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, $11) RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at" ) .bind(id).bind(team_id).bind(&input.project_name).bind(&input.description) .bind(&input.repository_url).bind(&input.demo_url).bind(&input.presentation_url) .bind(&input.screenshots).bind(user_id).bind(now).bind(now) .fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn find_by_team(&self, team_id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn find_by_team( + &self, + team_id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at FROM hackathon_project_submissions WHERE team_id = $1 LIMIT 1" ) .bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn find_by_id(&self, id: Uuid) -> Result { - let row: SubmissionRow = sqlx::query_as( + async fn find_by_id(&self, id: Uuid) -> Result { + let row: SubmissionRow = sqlx::query_as( "SELECT id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at FROM hackathon_project_submissions WHERE id = $1" ) .bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))? .ok_or_else(|| AppError::NotFoundError("Submission not found".to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn update(&self, id: Uuid, input: UpdateSubmissionInput) -> Result { - let mut sets = vec!["updated_at = $1".to_string()]; - let mut idx = 2usize; - if input.project_name.is_some() { sets.push(format!("project_name = ${}", idx)); idx += 1; } - if input.description.is_some() { sets.push(format!("description = ${}", idx)); idx += 1; } - if input.repository_url.is_some() { sets.push(format!("repository_url = ${}", idx)); idx += 1; } - if input.demo_url.is_some() { sets.push(format!("demo_url = ${}", idx)); idx += 1; } - if input.presentation_url.is_some() { sets.push(format!("presentation_url = ${}", idx)); idx += 1; } - if input.screenshots.is_some() { sets.push(format!("screenshots = ${}", idx)); idx += 1; } - let sql = format!("UPDATE hackathon_project_submissions SET {} WHERE id = ${} RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at", sets.join(", "), idx); - let mut q = sqlx::query_as::<_, SubmissionRow>(&sql).bind(Utc::now()); - if let Some(v) = input.project_name { q = q.bind(v); } - if let Some(v) = input.description { q = q.bind(v); } - if let Some(v) = input.repository_url { q = q.bind(v); } - if let Some(v) = input.demo_url { q = q.bind(v); } - if let Some(v) = input.presentation_url { q = q.bind(v); } - if let Some(v) = input.screenshots { q = q.bind(v); } - q.bind(id).fetch_one(self.pool.as_ref()).await.map(Into::into).map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn update( + &self, + id: Uuid, + input: UpdateSubmissionInput, + ) -> Result { + let mut sets = vec!["updated_at = $1".to_string()]; + let mut idx = 2usize; + if input.project_name.is_some() { + sets.push(format!("project_name = ${}", idx)); + idx += 1; + } + if input.description.is_some() { + sets.push(format!("description = ${}", idx)); + idx += 1; + } + if input.repository_url.is_some() { + sets.push(format!("repository_url = ${}", idx)); + idx += 1; + } + if input.demo_url.is_some() { + sets.push(format!("demo_url = ${}", idx)); + idx += 1; + } + if input.presentation_url.is_some() { + sets.push(format!("presentation_url = ${}", idx)); + idx += 1; + } + if input.screenshots.is_some() { + sets.push(format!("screenshots = ${}", idx)); + idx += 1; + } + let sql = format!( + "UPDATE hackathon_project_submissions SET {} WHERE id = ${} RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at", + sets.join(", "), + idx + ); + let mut q = sqlx::query_as::<_, SubmissionRow>(&sql).bind(Utc::now()); + if let Some(v) = input.project_name { + q = q.bind(v); + } + if let Some(v) = input.description { + q = q.bind(v); + } + if let Some(v) = input.repository_url { + q = q.bind(v); + } + if let Some(v) = input.demo_url { + q = q.bind(v); + } + if let Some(v) = input.presentation_url { + q = q.bind(v); + } + if let Some(v) = input.screenshots { + q = q.bind(v); + } + q.bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map(Into::into) + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn update_status(&self, id: Uuid, status: &str) -> Result { - let row: SubmissionRow = sqlx::query_as( + async fn update_status( + &self, + id: Uuid, + status: &str, + ) -> Result { + let row: SubmissionRow = sqlx::query_as( "UPDATE hackathon_project_submissions SET status = $1, submitted_at = CASE WHEN $1 = 'submitted' THEN NOW() ELSE submitted_at END, updated_at = NOW() WHERE id = $2 RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at" ) .bind(status).bind(id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)") + async fn is_team_leader( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)", + ) + .bind(team_id) + .bind(user_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn is_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") - .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn team_member_count(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") + async fn team_member_count(&self, team_id: Uuid) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } } diff --git a/imphnen-hackathon/src/submissions/mod.rs b/imphnen-hackathon/src/submissions/mod.rs index 9e1b94d..fae3a81 100644 --- a/imphnen-hackathon/src/submissions/mod.rs +++ b/imphnen-hackathon/src/submissions/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::hackathon_submissions_routes; diff --git a/imphnen-hackathon/src/teams/application/team_service.rs b/imphnen-hackathon/src/teams/application/team_service.rs index 24e8f85..c230627 100644 --- a/imphnen-hackathon/src/teams/application/team_service.rs +++ b/imphnen-hackathon/src/teams/application/team_service.rs @@ -1,177 +1,322 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use chrono::{Utc, TimeZone}; -use imphnen_utils::errors::AppError; +use crate::common::cities::is_valid_indonesian_city; use crate::teams::domain::entity::*; use crate::teams::domain::repository::TeamRepository; use crate::teams::domain::service::TeamService; -use crate::common::cities::is_valid_indonesian_city; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; fn is_team_features_closed() -> bool { - let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap(); - Utc::now() >= deadline + let deadline = Utc + .with_ymd_and_hms(2025, 11, 30, 16, 59, 0) + .single() + .expect("valid constant date"); + Utc::now() >= deadline } fn team_features_closed_err() -> AppError { - AppError::BadRequestError("Team features are closed. The deadline was November 30, 2025 at 23:59 WIB.".to_string()) + AppError::BadRequestError( + "Team features are closed. The deadline was November 30, 2025 at 23:59 WIB." + .to_string(), + ) } pub struct TeamServiceImpl { - repo: Arc, + repo: Arc, } impl TeamServiceImpl { - pub fn new(repo: Arc) -> Self { Self { repo } } + pub fn new(repo: Arc) -> Self { + Self { repo } + } - async fn assemble_team_details(&self, entity: TeamEntity) -> Result { - let leader = self.repo.get_leader(entity.leader_id).await?; - let members = self.repo.get_members(entity.id).await?; - let member_count = members.len() as i64; - let has_submission = self.repo.team_has_submission(entity.id).await?; - Ok(TeamWithDetails { - id: entity.id, - name: entity.name, - description: entity.description, - city: entity.city, - visibility: entity.visibility, - logo: entity.logo, - banner: entity.banner, - leader_id: entity.leader_id, - leader, - members: Some(members), - member_count: Some(member_count), - has_submission: Some(has_submission), - created_at: entity.created_at, - updated_at: entity.updated_at, - }) - } + async fn assemble_team_details( + &self, + entity: TeamEntity, + ) -> Result { + let leader = self.repo.get_leader(entity.leader_id).await?; + let members = self.repo.get_members(entity.id).await?; + let member_count = members.len() as i64; + let has_submission = self.repo.team_has_submission(entity.id).await?; + Ok(TeamWithDetails { + id: entity.id, + name: entity.name, + description: entity.description, + city: entity.city, + visibility: entity.visibility, + logo: entity.logo, + banner: entity.banner, + leader_id: entity.leader_id, + leader, + members: Some(members), + member_count: Some(member_count), + has_submission: Some(has_submission), + created_at: entity.created_at, + updated_at: entity.updated_at, + }) + } } #[async_trait] impl TeamService for TeamServiceImpl { - async fn create_team(&self, user_id: Uuid, input: CreateTeamInput) -> Result { - if is_team_features_closed() { return Err(team_features_closed_err()); } - if !is_valid_indonesian_city(&input.city) { - return Err(AppError::BadRequestError(format!("Invalid city '{}'. Only Indonesian cities are allowed.", input.city))); - } - if let Some(name) = self.repo.user_active_team_name(user_id).await? { - return Err(AppError::ConflictError(format!("You are already a member of team '{}'. Leave your current team first.", name))); - } - let id = Uuid::new_v4(); - let entity = self.repo.create(id, user_id, input).await?; - self.repo.add_member(entity.id, user_id, "leader").await?; - self.repo.reject_pending_invitations_for_user(user_id).await?; - self.repo.reject_pending_join_requests_for_user(user_id).await?; - self.assemble_team_details(entity).await - } + async fn create_team( + &self, + user_id: Uuid, + input: CreateTeamInput, + ) -> Result { + if is_team_features_closed() { + return Err(team_features_closed_err()); + } + if !is_valid_indonesian_city(&input.city) { + return Err(AppError::BadRequestError(format!( + "Invalid city '{}'. Only Indonesian cities are allowed.", + input.city + ))); + } + if let Some(name) = self.repo.user_active_team_name(user_id).await? { + return Err(AppError::ConflictError(format!( + "You are already a member of team '{}'. Leave your current team first.", + name + ))); + } + let id = Uuid::new_v4(); + let entity = self.repo.create(id, user_id, input).await?; + self.repo.add_member(entity.id, user_id, "leader").await?; + self + .repo + .reject_pending_invitations_for_user(user_id) + .await?; + self + .repo + .reject_pending_join_requests_for_user(user_id) + .await?; + self.assemble_team_details(entity).await + } - async fn get_team_by_id(&self, team_id: Uuid) -> Result { - let entity = self.repo.find_by_id(team_id).await? - .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; - self.assemble_team_details(entity).await - } + async fn get_team_by_id( + &self, + team_id: Uuid, + ) -> Result { + let entity = self + .repo + .find_by_id(team_id) + .await? + .ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?; + self.assemble_team_details(entity).await + } - async fn browse_teams(&self, input: BrowseTeamsInput) -> Result { - let page = if input.page < 1 { 1 } else { input.page }; - let per_page = if input.per_page < 1 { 10 } else if input.per_page > 100 { 100 } else { input.per_page }; - let normalized = BrowseTeamsInput { page, per_page, ..input }; - let (teams, total) = self.repo.browse(normalized).await?; + async fn browse_teams( + &self, + input: BrowseTeamsInput, + ) -> Result { + let page = if input.page < 1 { 1 } else { input.page }; + let per_page = if input.per_page < 1 { + 10 + } else if input.per_page > 100 { + 100 + } else { + input.per_page + }; + let normalized = BrowseTeamsInput { + page, + per_page, + ..input + }; + let (teams, total) = self.repo.browse(normalized).await?; - let leader_ids: Vec = teams.iter().map(|t| t.leader_id).collect(); - let team_ids: Vec = teams.iter().map(|t| t.id).collect(); + let leader_ids: Vec = teams.iter().map(|t| t.leader_id).collect(); + let team_ids: Vec = teams.iter().map(|t| t.id).collect(); - let leaders = if !leader_ids.is_empty() { self.repo.get_leaders_batch(leader_ids).await? } else { vec![] }; - let counts = if !team_ids.is_empty() { self.repo.get_member_counts_batch(team_ids.clone()).await? } else { vec![] }; - let submitted_ids = if !team_ids.is_empty() { self.repo.get_submitted_team_ids(team_ids).await? } else { vec![] }; + let leaders = if !leader_ids.is_empty() { + self.repo.get_leaders_batch(leader_ids).await? + } else { + vec![] + }; + let counts = if !team_ids.is_empty() { + self.repo.get_member_counts_batch(team_ids.clone()).await? + } else { + vec![] + }; + let submitted_ids = if !team_ids.is_empty() { + self.repo.get_submitted_team_ids(team_ids).await? + } else { + vec![] + }; - let result_teams: Vec = teams.into_iter().map(|t| { - let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned(); - let member_count = counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c); - let has_submission = submitted_ids.contains(&t.id); - TeamWithDetails { - id: t.id, name: t.name, description: t.description, city: t.city, - visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id, - leader, members: None, member_count, has_submission: Some(has_submission), - created_at: t.created_at, updated_at: t.updated_at, - } - }).collect(); + let result_teams: Vec = teams + .into_iter() + .map(|t| { + let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned(); + let member_count = + counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c); + let has_submission = submitted_ids.contains(&t.id); + TeamWithDetails { + id: t.id, + name: t.name, + description: t.description, + city: t.city, + visibility: t.visibility, + logo: t.logo, + banner: t.banner, + leader_id: t.leader_id, + leader, + members: None, + member_count, + has_submission: Some(has_submission), + created_at: t.created_at, + updated_at: t.updated_at, + } + }) + .collect(); - Ok(BrowseTeamsResult { teams: result_teams, total, page, per_page }) - } + Ok(BrowseTeamsResult { + teams: result_teams, + total, + page, + per_page, + }) + } - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError> { - let teams = self.repo.find_by_user(user_id).await?; - let leader_ids: Vec = teams.iter().map(|t| t.leader_id).collect(); - let team_ids: Vec = teams.iter().map(|t| t.id).collect(); - let leaders = if !leader_ids.is_empty() { self.repo.get_leaders_batch(leader_ids).await? } else { vec![] }; - let counts = if !team_ids.is_empty() { self.repo.get_member_counts_batch(team_ids).await? } else { vec![] }; - Ok(teams.into_iter().map(|t| { - let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned(); - let member_count = counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c); - TeamWithDetails { - id: t.id, name: t.name, description: t.description, city: t.city, - visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id, - leader, members: None, member_count, has_submission: None, - created_at: t.created_at, updated_at: t.updated_at, - } - }).collect()) - } + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let teams = self.repo.find_by_user(user_id).await?; + let leader_ids: Vec = teams.iter().map(|t| t.leader_id).collect(); + let team_ids: Vec = teams.iter().map(|t| t.id).collect(); + let leaders = if !leader_ids.is_empty() { + self.repo.get_leaders_batch(leader_ids).await? + } else { + vec![] + }; + let counts = if !team_ids.is_empty() { + self.repo.get_member_counts_batch(team_ids).await? + } else { + vec![] + }; + Ok( + teams + .into_iter() + .map(|t| { + let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned(); + let member_count = + counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c); + TeamWithDetails { + id: t.id, + name: t.name, + description: t.description, + city: t.city, + visibility: t.visibility, + logo: t.logo, + banner: t.banner, + leader_id: t.leader_id, + leader, + members: None, + member_count, + has_submission: None, + created_at: t.created_at, + updated_at: t.updated_at, + } + }) + .collect(), + ) + } - async fn update_team(&self, team_id: Uuid, user_id: Uuid, input: UpdateTeamInput) -> Result { - if is_team_features_closed() { return Err(team_features_closed_err()); } - if !self.repo.is_leader(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string())); - } - if let Some(ref city) = input.city { - if !is_valid_indonesian_city(city) { - return Err(AppError::BadRequestError(format!("Invalid city '{}'. Only Indonesian cities are allowed.", city))); - } - } - let entity = self.repo.update(team_id, input).await?; - self.assemble_team_details(entity).await - } + async fn update_team( + &self, + team_id: Uuid, + user_id: Uuid, + input: UpdateTeamInput, + ) -> Result { + if is_team_features_closed() { + return Err(team_features_closed_err()); + } + if !self.repo.is_leader(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can perform this action".to_string(), + )); + } + if let Some(ref city) = input.city + && !is_valid_indonesian_city(city) + { + return Err(AppError::BadRequestError(format!( + "Invalid city '{}'. Only Indonesian cities are allowed.", + city + ))); + } + let entity = self.repo.update(team_id, input).await?; + self.assemble_team_details(entity).await + } - async fn remove_team_member(&self, team_id: Uuid, user_id: Uuid, member_id: Uuid) -> Result<(), AppError> { - if is_team_features_closed() { return Err(team_features_closed_err()); } - if !self.repo.is_leader(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string())); - } - if member_id == user_id { - return Err(AppError::BadRequestError("Team leader cannot remove themselves".to_string())); - } - if self.repo.team_has_submission(team_id).await? { - return Err(AppError::ConflictError("Cannot remove members after project submission".to_string())); - } - self.repo.remove_member(team_id, member_id).await - } + async fn remove_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + member_id: Uuid, + ) -> Result<(), AppError> { + if is_team_features_closed() { + return Err(team_features_closed_err()); + } + if !self.repo.is_leader(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can perform this action".to_string(), + )); + } + if member_id == user_id { + return Err(AppError::BadRequestError( + "Team leader cannot remove themselves".to_string(), + )); + } + if self.repo.team_has_submission(team_id).await? { + return Err(AppError::ConflictError( + "Cannot remove members after project submission".to_string(), + )); + } + self.repo.remove_member(team_id, member_id).await + } - async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - if is_team_features_closed() { return Err(team_features_closed_err()); } - if !self.repo.is_member(team_id, user_id).await? { - return Err(AppError::NotFoundError("You are not a member of this team".to_string())); - } - if self.repo.team_has_submission(team_id).await? { - return Err(AppError::ConflictError("Cannot leave team after project submission".to_string())); - } - if self.repo.is_leader(team_id, user_id).await? { - return Err(AppError::BadRequestError("Team leader cannot leave team. Transfer leadership or delete the team.".to_string())); - } - self.repo.remove_member(team_id, user_id).await - } + async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { + if is_team_features_closed() { + return Err(team_features_closed_err()); + } + if !self.repo.is_member(team_id, user_id).await? { + return Err(AppError::NotFoundError( + "You are not a member of this team".to_string(), + )); + } + if self.repo.team_has_submission(team_id).await? { + return Err(AppError::ConflictError( + "Cannot leave team after project submission".to_string(), + )); + } + if self.repo.is_leader(team_id, user_id).await? { + return Err(AppError::BadRequestError( + "Team leader cannot leave team. Transfer leadership or delete the team." + .to_string(), + )); + } + self.repo.remove_member(team_id, user_id).await + } - async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - if !self.repo.is_leader(team_id, user_id).await? { - return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string())); - } - let count = self.repo.get_member_count(team_id).await?; - if count > 1 { - return Err(AppError::ConflictError("Cannot delete team with other members. Remove all members first.".to_string())); - } - let deleted = self.repo.delete(team_id).await?; - if !deleted { - return Err(AppError::NotFoundError("Team not found".to_string())); - } - Ok(()) - } + async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { + if !self.repo.is_leader(team_id, user_id).await? { + return Err(AppError::ForbiddenError( + "Only team leader can perform this action".to_string(), + )); + } + let count = self.repo.get_member_count(team_id).await?; + if count > 1 { + return Err(AppError::ConflictError( + "Cannot delete team with other members. Remove all members first." + .to_string(), + )); + } + let deleted = self.repo.delete(team_id).await?; + if !deleted { + return Err(AppError::NotFoundError("Team not found".to_string())); + } + Ok(()) + } } diff --git a/imphnen-hackathon/src/teams/domain/entity.rs b/imphnen-hackathon/src/teams/domain/entity.rs index 66c101b..135faf2 100644 --- a/imphnen-hackathon/src/teams/domain/entity.rs +++ b/imphnen-hackathon/src/teams/domain/entity.rs @@ -1,98 +1,98 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct TeamEntity { - pub id: Uuid, - pub name: String, - pub description: Option, - pub city: String, - pub visibility: String, - pub logo: Option, - pub banner: Option, - pub leader_id: Uuid, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, + pub leader_id: Uuid, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone)] pub struct TeamUserInfo { - pub id: Uuid, - pub email: String, - pub fullname: String, - pub avatar: Option, - pub phone_number: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, - pub is_active: Option, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub phone_number: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, + pub is_active: Option, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone)] pub struct TeamMemberEntity { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user: TeamUserInfo, - pub role: String, - pub status: String, - pub joined_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user: TeamUserInfo, + pub role: String, + pub status: String, + pub joined_at: Option>, } #[derive(Debug, Clone)] pub struct TeamWithDetails { - pub id: Uuid, - pub name: String, - pub description: Option, - pub city: String, - pub visibility: String, - pub logo: Option, - pub banner: Option, - pub leader_id: Uuid, - pub leader: Option, - pub members: Option>, - pub member_count: Option, - pub has_submission: Option, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, + pub leader_id: Uuid, + pub leader: Option, + pub members: Option>, + pub member_count: Option, + pub has_submission: Option, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone, Default)] pub struct CreateTeamInput { - pub name: String, - pub description: Option, - pub city: String, - pub visibility: String, - pub logo: Option, - pub banner: Option, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, } #[derive(Debug, Clone, Default)] pub struct UpdateTeamInput { - pub name: Option, - pub description: Option, - pub city: Option, - pub visibility: Option, - pub logo: Option, - pub banner: Option, + pub name: Option, + pub description: Option, + pub city: Option, + pub visibility: Option, + pub logo: Option, + pub banner: Option, } #[derive(Debug, Clone, Default)] pub struct BrowseTeamsInput { - pub search: Option, - pub city: Option, - pub min_members: Option, - pub max_members: Option, - pub has_submission: Option, - pub page: i64, - pub per_page: i64, + pub search: Option, + pub city: Option, + pub min_members: Option, + pub max_members: Option, + pub has_submission: Option, + pub page: i64, + pub per_page: i64, } pub struct BrowseTeamsResult { - pub teams: Vec, - pub total: i64, - pub page: i64, - pub per_page: i64, + pub teams: Vec, + pub total: i64, + pub page: i64, + pub per_page: i64, } diff --git a/imphnen-hackathon/src/teams/domain/repository.rs b/imphnen-hackathon/src/teams/domain/repository.rs index a71553a..bfe154a 100644 --- a/imphnen-hackathon/src/teams/domain/repository.rs +++ b/imphnen-hackathon/src/teams/domain/repository.rs @@ -1,28 +1,73 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait TeamRepository: Send + Sync { - async fn create(&self, id: Uuid, leader_id: Uuid, input: CreateTeamInput) -> Result; - async fn find_by_id(&self, id: Uuid) -> Result, AppError>; - async fn browse(&self, input: BrowseTeamsInput) -> Result<(Vec, i64), AppError>; - async fn find_by_user(&self, user_id: Uuid) -> Result, AppError>; - async fn update(&self, id: Uuid, input: UpdateTeamInput) -> Result; - async fn delete(&self, id: Uuid) -> Result; - async fn get_members(&self, team_id: Uuid) -> Result, AppError>; - async fn get_leader(&self, leader_id: Uuid) -> Result, AppError>; - async fn add_member(&self, team_id: Uuid, user_id: Uuid, role: &str) -> Result<(), AppError>; - async fn remove_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; - async fn get_member_count(&self, team_id: Uuid) -> Result; - async fn is_member(&self, team_id: Uuid, user_id: Uuid) -> Result; - async fn is_leader(&self, team_id: Uuid, user_id: Uuid) -> Result; - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError>; - async fn team_has_submission(&self, team_id: Uuid) -> Result; - async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError>; - async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError>; - async fn get_leaders_batch(&self, leader_ids: Vec) -> Result, AppError>; - async fn get_member_counts_batch(&self, team_ids: Vec) -> Result, AppError>; - async fn get_submitted_team_ids(&self, team_ids: Vec) -> Result, AppError>; + async fn create( + &self, + id: Uuid, + leader_id: Uuid, + input: CreateTeamInput, + ) -> Result; + async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn browse( + &self, + input: BrowseTeamsInput, + ) -> Result<(Vec, i64), AppError>; + async fn find_by_user(&self, user_id: Uuid) -> Result, AppError>; + async fn update( + &self, + id: Uuid, + input: UpdateTeamInput, + ) -> Result; + async fn delete(&self, id: Uuid) -> Result; + async fn get_members( + &self, + team_id: Uuid, + ) -> Result, AppError>; + async fn get_leader( + &self, + leader_id: Uuid, + ) -> Result, AppError>; + async fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> Result<(), AppError>; + async fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError>; + async fn get_member_count(&self, team_id: Uuid) -> Result; + async fn is_member(&self, team_id: Uuid, user_id: Uuid) -> Result; + async fn is_leader(&self, team_id: Uuid, user_id: Uuid) -> Result; + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError>; + async fn team_has_submission(&self, team_id: Uuid) -> Result; + async fn reject_pending_invitations_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError>; + async fn reject_pending_join_requests_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError>; + async fn get_leaders_batch( + &self, + leader_ids: Vec, + ) -> Result, AppError>; + async fn get_member_counts_batch( + &self, + team_ids: Vec, + ) -> Result, AppError>; + async fn get_submitted_team_ids( + &self, + team_ids: Vec, + ) -> Result, AppError>; } diff --git a/imphnen-hackathon/src/teams/domain/service.rs b/imphnen-hackathon/src/teams/domain/service.rs index efce530..4214fcb 100644 --- a/imphnen-hackathon/src/teams/domain/service.rs +++ b/imphnen-hackathon/src/teams/domain/service.rs @@ -1,16 +1,37 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::*; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait TeamService: Send + Sync { - async fn create_team(&self, user_id: Uuid, input: CreateTeamInput) -> Result; - async fn get_team_by_id(&self, team_id: Uuid) -> Result; - async fn browse_teams(&self, input: BrowseTeamsInput) -> Result; - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError>; - async fn update_team(&self, team_id: Uuid, user_id: Uuid, input: UpdateTeamInput) -> Result; - async fn remove_team_member(&self, team_id: Uuid, user_id: Uuid, member_id: Uuid) -> Result<(), AppError>; - async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; - async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; + async fn create_team( + &self, + user_id: Uuid, + input: CreateTeamInput, + ) -> Result; + async fn get_team_by_id(&self, team_id: Uuid) + -> Result; + async fn browse_teams( + &self, + input: BrowseTeamsInput, + ) -> Result; + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError>; + async fn update_team( + &self, + team_id: Uuid, + user_id: Uuid, + input: UpdateTeamInput, + ) -> Result; + async fn remove_team_member( + &self, + team_id: Uuid, + user_id: Uuid, + member_id: Uuid, + ) -> Result<(), AppError>; + async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; + async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>; } diff --git a/imphnen-hackathon/src/teams/infrastructure/http/dto.rs b/imphnen-hackathon/src/teams/infrastructure/http/dto.rs index 97dd56d..f46fc0c 100644 --- a/imphnen-hackathon/src/teams/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/teams/infrastructure/http/dto.rs @@ -1,140 +1,188 @@ +use crate::teams::domain::entity::*; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::teams::domain::entity::*; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UserInfoResponse { - pub id: Uuid, - pub email: String, - pub fullname: String, - pub avatar: Option, - pub phone_number: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, - pub is_active: Option, + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub phone_number: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, + pub is_active: Option, } impl From for UserInfoResponse { - fn from(u: TeamUserInfo) -> Self { - Self { id: u.id, email: u.email, fullname: u.fullname, avatar: u.avatar, - phone_number: u.phone_number, location: u.location, bio: u.bio, - skills: u.skills, is_active: u.is_active } - } + fn from(u: TeamUserInfo) -> Self { + Self { + id: u.id, + email: u.email, + fullname: u.fullname, + avatar: u.avatar, + phone_number: u.phone_number, + location: u.location, + bio: u.bio, + skills: u.skills, + is_active: u.is_active, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct TeamMemberResponse { - pub id: Uuid, - pub team_id: Uuid, - pub user_id: Uuid, - pub user: UserInfoResponse, - pub role: String, - pub status: String, - pub joined_at: Option>, + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub user: UserInfoResponse, + pub role: String, + pub status: String, + pub joined_at: Option>, } impl From for TeamMemberResponse { - fn from(m: TeamMemberEntity) -> Self { - Self { id: m.id, team_id: m.team_id, user_id: m.user_id, - user: UserInfoResponse::from(m.user), role: m.role, status: m.status, joined_at: m.joined_at } - } + fn from(m: TeamMemberEntity) -> Self { + Self { + id: m.id, + team_id: m.team_id, + user_id: m.user_id, + user: UserInfoResponse::from(m.user), + role: m.role, + status: m.status, + joined_at: m.joined_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct TeamResponse { - pub id: Uuid, - pub name: String, - pub description: Option, - pub city: String, - pub visibility: String, - pub logo: Option, - pub banner: Option, - pub leader_id: Uuid, - pub leader: Option, - pub members: Option>, - pub member_count: Option, - pub has_submission: Option, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, + pub leader_id: Uuid, + pub leader: Option, + pub members: Option>, + pub member_count: Option, + pub has_submission: Option, + pub created_at: Option>, + pub updated_at: Option>, } impl From for TeamResponse { - fn from(t: TeamWithDetails) -> Self { - Self { - id: t.id, name: t.name, description: t.description, city: t.city, - visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id, - leader: t.leader.map(UserInfoResponse::from), - members: t.members.map(|ms| ms.into_iter().map(TeamMemberResponse::from).collect()), - member_count: t.member_count, has_submission: t.has_submission, - created_at: t.created_at, updated_at: t.updated_at, - } - } + fn from(t: TeamWithDetails) -> Self { + Self { + id: t.id, + name: t.name, + description: t.description, + city: t.city, + visibility: t.visibility, + logo: t.logo, + banner: t.banner, + leader_id: t.leader_id, + leader: t.leader.map(UserInfoResponse::from), + members: t + .members + .map(|ms| ms.into_iter().map(TeamMemberResponse::from).collect()), + member_count: t.member_count, + has_submission: t.has_submission, + created_at: t.created_at, + updated_at: t.updated_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateTeamRequest { - pub name: String, - pub description: Option, - pub city: String, - pub visibility: String, - pub logo: Option, - pub banner: Option, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, } impl From for CreateTeamInput { - fn from(r: CreateTeamRequest) -> Self { - Self { name: r.name, description: r.description, city: r.city, - visibility: r.visibility, logo: r.logo, banner: r.banner } - } + fn from(r: CreateTeamRequest) -> Self { + Self { + name: r.name, + description: r.description, + city: r.city, + visibility: r.visibility, + logo: r.logo, + banner: r.banner, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateTeamRequest { - pub name: Option, - pub description: Option, - pub city: Option, - pub visibility: Option, - pub logo: Option, - pub banner: Option, + pub name: Option, + pub description: Option, + pub city: Option, + pub visibility: Option, + pub logo: Option, + pub banner: Option, } impl From for UpdateTeamInput { - fn from(r: UpdateTeamRequest) -> Self { - Self { name: r.name, description: r.description, city: r.city, - visibility: r.visibility, logo: r.logo, banner: r.banner } - } + fn from(r: UpdateTeamRequest) -> Self { + Self { + name: r.name, + description: r.description, + city: r.city, + visibility: r.visibility, + logo: r.logo, + banner: r.banner, + } + } } #[derive(Debug, Deserialize, ToSchema)] pub struct BrowseTeamsQuery { - pub search: Option, - pub city: Option, - pub min_members: Option, - pub max_members: Option, - pub has_submission: Option, - #[serde(default = "default_page")] - pub page: i64, - #[serde(default = "default_per_page")] - pub per_page: i64, + pub search: Option, + pub city: Option, + pub min_members: Option, + pub max_members: Option, + pub has_submission: Option, + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_per_page")] + pub per_page: i64, } -fn default_page() -> i64 { 1 } -fn default_per_page() -> i64 { 10 } +fn default_page() -> i64 { + 1 +} +fn default_per_page() -> i64 { + 10 +} impl From for BrowseTeamsInput { - fn from(q: BrowseTeamsQuery) -> Self { - Self { search: q.search, city: q.city, min_members: q.min_members, max_members: q.max_members, - has_submission: q.has_submission, page: q.page, per_page: q.per_page } - } + fn from(q: BrowseTeamsQuery) -> Self { + Self { + search: q.search, + city: q.city, + min_members: q.min_members, + max_members: q.max_members, + has_submission: q.has_submission, + page: q.page, + per_page: q.per_page, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct TeamListResponse { - pub data: Vec, - pub total: i64, - pub page: i64, - pub per_page: i64, + pub data: Vec, + pub total: i64, + pub page: i64, + pub per_page: i64, } diff --git a/imphnen-hackathon/src/teams/infrastructure/http/handlers.rs b/imphnen-hackathon/src/teams/infrastructure/http/handlers.rs index 812cb70..342dc4a 100644 --- a/imphnen-hackathon/src/teams/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/teams/infrastructure/http/handlers.rs @@ -1,82 +1,104 @@ -use axum::{Extension, Json, extract::{Path, Query}, response::IntoResponse}; -use std::sync::Arc; -use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}}; +use super::dto::*; use crate::middleware::hackathon_auth::HackathonAuthUser; use crate::teams::domain::service::TeamService; -use super::dto::*; +use axum::{ + Extension, Json, + extract::{Path, Query}, + response::IntoResponse, +}; +use imphnen_utils::{ + errors::AppError, + response_format::{ApiMessage, ApiSuccess}, +}; +use std::sync::Arc; +use uuid::Uuid; pub async fn create_team_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, ) -> Result { - let team = service.create_team(auth.user_id, body.into()).await?; - Ok(ApiSuccess(TeamResponse::from(team)).into_response()) + let team = service.create_team(auth.user_id, body.into()).await?; + Ok(ApiSuccess(TeamResponse::from(team)).into_response()) } pub async fn get_team_handler( - Extension(service): Extension>, - Path(team_id): Path, + Extension(service): Extension>, + Path(team_id): Path, ) -> Result { - let team = service.get_team_by_id(team_id).await?; - Ok(ApiSuccess(TeamResponse::from(team)).into_response()) + let team = service.get_team_by_id(team_id).await?; + Ok(ApiSuccess(TeamResponse::from(team)).into_response()) } pub async fn browse_teams_handler( - Extension(service): Extension>, - Query(query): Query, + Extension(service): Extension>, + Query(query): Query, ) -> Result { - let result = service.browse_teams(query.into()).await?; - Ok(ApiSuccess(TeamListResponse { - data: result.teams.into_iter().map(TeamResponse::from).collect(), - total: result.total, - page: result.page, - per_page: result.per_page, - }).into_response()) + let result = service.browse_teams(query.into()).await?; + Ok( + ApiSuccess(TeamListResponse { + data: result.teams.into_iter().map(TeamResponse::from).collect(), + total: result.total, + page: result.page, + per_page: result.per_page, + }) + .into_response(), + ) } pub async fn get_my_teams_handler( - Extension(service): Extension>, - Extension(auth): Extension, + Extension(service): Extension>, + Extension(auth): Extension, ) -> Result { - let teams = service.get_user_teams(auth.user_id).await?; - Ok(ApiSuccess(teams.into_iter().map(TeamResponse::from).collect::>()).into_response()) + let teams = service.get_user_teams(auth.user_id).await?; + Ok( + ApiSuccess( + teams + .into_iter() + .map(TeamResponse::from) + .collect::>(), + ) + .into_response(), + ) } pub async fn update_team_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, + Json(body): Json, ) -> Result { - let team = service.update_team(team_id, auth.user_id, body.into()).await?; - Ok(ApiSuccess(TeamResponse::from(team)).into_response()) + let team = service + .update_team(team_id, auth.user_id, body.into()) + .await?; + Ok(ApiSuccess(TeamResponse::from(team)).into_response()) } pub async fn delete_team_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, ) -> Result { - service.delete_team(team_id, auth.user_id).await?; - Ok(ApiMessage::ok("Team deleted successfully").into_response()) + service.delete_team(team_id, auth.user_id).await?; + Ok(ApiMessage::ok("Team deleted successfully").into_response()) } pub async fn leave_team_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path(team_id): Path, + Extension(service): Extension>, + Extension(auth): Extension, + Path(team_id): Path, ) -> Result { - service.leave_team(team_id, auth.user_id).await?; - Ok(ApiMessage::ok("Left team successfully").into_response()) + service.leave_team(team_id, auth.user_id).await?; + Ok(ApiMessage::ok("Left team successfully").into_response()) } pub async fn remove_member_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Path((team_id, member_id)): Path<(Uuid, Uuid)>, + Extension(service): Extension>, + Extension(auth): Extension, + Path((team_id, member_id)): Path<(Uuid, Uuid)>, ) -> Result { - service.remove_team_member(team_id, auth.user_id, member_id).await?; - Ok(ApiMessage::ok("Member removed successfully").into_response()) + service + .remove_team_member(team_id, auth.user_id, member_id) + .await?; + Ok(ApiMessage::ok("Member removed successfully").into_response()) } diff --git a/imphnen-hackathon/src/teams/infrastructure/http/routes.rs b/imphnen-hackathon/src/teams/infrastructure/http/routes.rs index bfe2069..5c11bf1 100644 --- a/imphnen-hackathon/src/teams/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/teams/infrastructure/http/routes.rs @@ -1,30 +1,40 @@ -use axum::{middleware::from_fn, routing::{delete, get, post, put}, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; +use crate::middleware::hackathon_auth::hackathon_auth_middleware; use crate::teams::application::team_service::TeamServiceImpl; use crate::teams::domain::service::TeamService; use crate::teams::infrastructure::persistence::PostgresTeamRepository; -use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{ + Extension, Router, + middleware::from_fn, + routing::{delete, get, post, put}, +}; +use sqlx::PgPool; +use std::sync::Arc; pub fn build_team_routes(pool: Arc) -> Router { - let repo = Arc::new(PostgresTeamRepository::new(pool.clone())); - let service: Arc = Arc::new(TeamServiceImpl::new(repo)); + let repo = Arc::new(PostgresTeamRepository::new(pool.clone())); + let service: Arc = Arc::new(TeamServiceImpl::new(repo)); - let public = Router::new() - .route("/teams/browse", get(browse_teams_handler)) - .route("/teams/:team_id", get(get_team_handler)) - .layer(Extension(service.clone())); + let public = Router::new() + .route("/teams/browse", get(browse_teams_handler)) + .route("/teams/:team_id", get(get_team_handler)) + .layer(Extension(service.clone())); - let protected = Router::new() - .route("/teams", post(create_team_handler)) - .route("/teams/my", get(get_my_teams_handler)) - .route("/teams/:team_id", put(update_team_handler).delete(delete_team_handler)) - .route("/teams/:team_id/leave", post(leave_team_handler)) - .route("/teams/:team_id/members/:member_id", delete(remove_member_handler)) - .layer(Extension(service)) - .layer(Extension(pool.clone())) - .layer(from_fn(hackathon_auth_middleware)); + let protected = Router::new() + .route("/teams", post(create_team_handler)) + .route("/teams/my", get(get_my_teams_handler)) + .route( + "/teams/:team_id", + put(update_team_handler).delete(delete_team_handler), + ) + .route("/teams/:team_id/leave", post(leave_team_handler)) + .route( + "/teams/:team_id/members/:member_id", + delete(remove_member_handler), + ) + .layer(Extension(service)) + .layer(Extension(pool.clone())) + .layer(from_fn(hackathon_auth_middleware)); - Router::new().merge(public).merge(protected) + Router::new().merge(public).merge(protected) } diff --git a/imphnen-hackathon/src/teams/infrastructure/persistence/mod.rs b/imphnen-hackathon/src/teams/infrastructure/persistence/mod.rs index f5d53d6..2774a9a 100644 --- a/imphnen-hackathon/src/teams/infrastructure/persistence/mod.rs +++ b/imphnen-hackathon/src/teams/infrastructure/persistence/mod.rs @@ -1,4 +1,4 @@ -pub mod postgres_team_repository; mod postgres_team_queries; +pub mod postgres_team_repository; pub use postgres_team_repository::PostgresTeamRepository; diff --git a/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_queries.rs b/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_queries.rs index e02e3ab..18c5471 100644 --- a/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_queries.rs +++ b/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_queries.rs @@ -1,111 +1,240 @@ -use uuid::Uuid; -use sqlx::FromRow; -use imphnen_utils::errors::AppError; -use crate::teams::domain::entity::{TeamEntity, TeamUserInfo, BrowseTeamsInput}; use super::postgres_team_repository::{PostgresTeamRepository, TeamRow, UserRow}; +use crate::teams::domain::entity::{BrowseTeamsInput, TeamEntity, TeamUserInfo}; +use imphnen_utils::errors::AppError; +use sqlx::FromRow; +use uuid::Uuid; impl PostgresTeamRepository { - pub(super) async fn browse_query(&self, input: BrowseTeamsInput) -> Result<(Vec, i64), AppError> { - let offset = (input.page - 1) * input.per_page; - let mut where_clauses: Vec = vec!["t.visibility = 'public'".to_string()]; - let mut param_count = 1usize; + pub(super) async fn browse_query( + &self, + input: BrowseTeamsInput, + ) -> Result<(Vec, i64), AppError> { + let offset = (input.page - 1) * input.per_page; + let mut where_clauses: Vec = vec!["t.visibility = 'public'".to_string()]; + let mut param_count = 1usize; - if input.city.is_some() { where_clauses.push(format!("t.city = ${}", param_count)); param_count += 1; } - if input.search.is_some() { where_clauses.push(format!("t.name ILIKE ${}", param_count)); param_count += 1; } - if input.min_members.is_some() { where_clauses.push(format!("mc.member_count >= ${}", param_count)); param_count += 1; } - if input.max_members.is_some() { where_clauses.push(format!("mc.member_count <= ${}", param_count)); param_count += 1; } - if let Some(has_sub) = input.has_submission { - let clause = if has_sub { - "EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)".to_string() - } else { - "NOT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)".to_string() - }; - where_clauses.push(clause); - } + if input.city.is_some() { + where_clauses.push(format!("t.city = ${}", param_count)); + param_count += 1; + } + if input.search.is_some() { + where_clauses.push(format!("t.name ILIKE ${}", param_count)); + param_count += 1; + } + if input.min_members.is_some() { + where_clauses.push(format!("mc.member_count >= ${}", param_count)); + param_count += 1; + } + if input.max_members.is_some() { + where_clauses.push(format!("mc.member_count <= ${}", param_count)); + param_count += 1; + } + if let Some(has_sub) = input.has_submission { + let clause = if has_sub { + "EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)" + .to_string() + } else { + "NOT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)".to_string() + }; + where_clauses.push(clause); + } - let where_sql = where_clauses.join(" AND "); - let base = format!( - "FROM hackathon_teams t LEFT JOIN (SELECT team_id, COUNT(*) as member_count FROM hackathon_team_members WHERE status = 'active' GROUP BY team_id) mc ON mc.team_id = t.id WHERE {}", - where_sql - ); + let where_sql = where_clauses.join(" AND "); + let base = format!( + "FROM hackathon_teams t LEFT JOIN (SELECT team_id, COUNT(*) as member_count FROM hackathon_team_members WHERE status = 'active' GROUP BY team_id) mc ON mc.team_id = t.id WHERE {}", + where_sql + ); - let count_sql = format!("SELECT COUNT(*) {}", base); - let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); - if let Some(ref v) = input.city { count_q = count_q.bind(v.clone()); } - if let Some(ref v) = input.search { count_q = count_q.bind(format!("%{}%", v)); } - if let Some(v) = input.min_members { count_q = count_q.bind(v); } - if let Some(v) = input.max_members { count_q = count_q.bind(v); } - let total: i64 = count_q.fetch_one(self.pool.as_ref()).await.unwrap_or(0); + let count_sql = format!("SELECT COUNT(*) {}", base); + let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); + if let Some(ref v) = input.city { + count_q = count_q.bind(v.clone()); + } + if let Some(ref v) = input.search { + count_q = count_q.bind(format!("%{}%", v)); + } + if let Some(v) = input.min_members { + count_q = count_q.bind(v); + } + if let Some(v) = input.max_members { + count_q = count_q.bind(v); + } + let total: i64 = count_q.fetch_one(self.pool.as_ref()).await.unwrap_or(0); - let select_sql = format!( - "SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at {} ORDER BY t.created_at DESC LIMIT ${} OFFSET ${}", - base, param_count, param_count + 1 - ); - let mut q = sqlx::query_as::<_, TeamRow>(&select_sql); - if let Some(v) = input.city { q = q.bind(v); } - if let Some(v) = input.search { q = q.bind(format!("%{}%", v)); } - if let Some(v) = input.min_members { q = q.bind(v); } - if let Some(v) = input.max_members { q = q.bind(v); } - q = q.bind(input.per_page).bind(offset); - let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok((rows.into_iter().map(Into::into).collect(), total)) - } + let select_sql = format!( + "SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at {} ORDER BY t.created_at DESC LIMIT ${} OFFSET ${}", + base, + param_count, + param_count + 1 + ); + let mut q = sqlx::query_as::<_, TeamRow>(&select_sql); + if let Some(v) = input.city { + q = q.bind(v); + } + if let Some(v) = input.search { + q = q.bind(format!("%{}%", v)); + } + if let Some(v) = input.min_members { + q = q.bind(v); + } + if let Some(v) = input.max_members { + q = q.bind(v); + } + q = q.bind(input.per_page).bind(offset); + let rows = q + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok((rows.into_iter().map(Into::into).collect(), total)) + } - pub(super) async fn update_query(&self, id: Uuid, input: crate::teams::domain::entity::UpdateTeamInput) -> Result { - use chrono::Utc; - let mut sets = vec!["updated_at = $1".to_string()]; - let mut idx = 2usize; - if input.name.is_some() { sets.push(format!("name = ${}", idx)); idx += 1; } - if input.description.is_some() { sets.push(format!("description = ${}", idx)); idx += 1; } - if input.city.is_some() { sets.push(format!("city = ${}", idx)); idx += 1; } - if input.visibility.is_some() { sets.push(format!("visibility = ${}", idx)); idx += 1; } - if input.logo.is_some() { sets.push(format!("logo = ${}", idx)); idx += 1; } - if input.banner.is_some() { sets.push(format!("banner = ${}", idx)); idx += 1; } - let sql = format!( - "UPDATE hackathon_teams SET {} WHERE id = ${} RETURNING id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at", - sets.join(", "), idx - ); - let mut q = sqlx::query_as::<_, TeamRow>(&sql).bind(Utc::now()); - if let Some(v) = input.name { q = q.bind(v); } - if let Some(v) = input.description { q = q.bind(v); } - if let Some(v) = input.city { q = q.bind(v); } - if let Some(v) = input.visibility { q = q.bind(v); } - if let Some(v) = input.logo { q = q.bind(v); } - if let Some(v) = input.banner { q = q.bind(v); } - q.bind(id).fetch_one(self.pool.as_ref()).await.map(Into::into).map_err(|e| AppError::InternalServerError(e.to_string())) - } + pub(super) async fn update_query( + &self, + id: Uuid, + input: crate::teams::domain::entity::UpdateTeamInput, + ) -> Result { + use chrono::Utc; + let mut sets = vec!["updated_at = $1".to_string()]; + let mut idx = 2usize; + if input.name.is_some() { + sets.push(format!("name = ${}", idx)); + idx += 1; + } + if input.description.is_some() { + sets.push(format!("description = ${}", idx)); + idx += 1; + } + if input.city.is_some() { + sets.push(format!("city = ${}", idx)); + idx += 1; + } + if input.visibility.is_some() { + sets.push(format!("visibility = ${}", idx)); + idx += 1; + } + if input.logo.is_some() { + sets.push(format!("logo = ${}", idx)); + idx += 1; + } + if input.banner.is_some() { + sets.push(format!("banner = ${}", idx)); + idx += 1; + } + let sql = format!( + "UPDATE hackathon_teams SET {} WHERE id = ${} RETURNING id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at", + sets.join(", "), + idx + ); + let mut q = sqlx::query_as::<_, TeamRow>(&sql).bind(Utc::now()); + if let Some(v) = input.name { + q = q.bind(v); + } + if let Some(v) = input.description { + q = q.bind(v); + } + if let Some(v) = input.city { + q = q.bind(v); + } + if let Some(v) = input.visibility { + q = q.bind(v); + } + if let Some(v) = input.logo { + q = q.bind(v); + } + if let Some(v) = input.banner { + q = q.bind(v); + } + q.bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map(Into::into) + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - pub(super) async fn leaders_batch_query(&self, leader_ids: Vec) -> Result, AppError> { - if leader_ids.is_empty() { return Ok(vec![]); } - let placeholders = (1..=leader_ids.len()).map(|i| format!("${}", i)).collect::>().join(", "); - let sql = format!("SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id IN ({})", placeholders); - let mut q = sqlx::query_as::<_, UserRow>(&sql); - for id in &leader_ids { q = q.bind(id); } - let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + pub(super) async fn leaders_batch_query( + &self, + leader_ids: Vec, + ) -> Result, AppError> { + if leader_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = (1..=leader_ids.len()) + .map(|i| format!("${}", i)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id IN ({})", + placeholders + ); + let mut q = sqlx::query_as::<_, UserRow>(&sql); + for id in &leader_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(Into::into).collect()) + } - pub(super) async fn member_counts_batch_query(&self, team_ids: Vec) -> Result, AppError> { - if team_ids.is_empty() { return Ok(vec![]); } - let placeholders = (1..=team_ids.len()).map(|i| format!("${}", i)).collect::>().join(", "); - let sql = format!("SELECT team_id, COUNT(*) as count FROM hackathon_team_members WHERE team_id IN ({}) AND status = 'active' GROUP BY team_id", placeholders); - #[derive(FromRow)] - struct CountRow { team_id: Uuid, count: i64 } - let mut q = sqlx::query_as::<_, CountRow>(&sql); - for id in &team_ids { q = q.bind(id); } - let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(|r| (r.team_id, r.count)).collect()) - } + pub(super) async fn member_counts_batch_query( + &self, + team_ids: Vec, + ) -> Result, AppError> { + if team_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = (1..=team_ids.len()) + .map(|i| format!("${}", i)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT team_id, COUNT(*) as count FROM hackathon_team_members WHERE team_id IN ({}) AND status = 'active' GROUP BY team_id", + placeholders + ); + #[derive(FromRow)] + struct CountRow { + team_id: Uuid, + count: i64, + } + let mut q = sqlx::query_as::<_, CountRow>(&sql); + for id in &team_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(|r| (r.team_id, r.count)).collect()) + } - pub(super) async fn submitted_team_ids_query(&self, team_ids: Vec) -> Result, AppError> { - if team_ids.is_empty() { return Ok(vec![]); } - let placeholders = (1..=team_ids.len()).map(|i| format!("${}", i)).collect::>().join(", "); - let sql = format!("SELECT DISTINCT team_id FROM hackathon_project_submissions WHERE team_id IN ({})", placeholders); - #[derive(FromRow)] - struct SubRow { team_id: Uuid } - let mut q = sqlx::query_as::<_, SubRow>(&sql); - for id in &team_ids { q = q.bind(id); } - let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(|r| r.team_id).collect()) - } + pub(super) async fn submitted_team_ids_query( + &self, + team_ids: Vec, + ) -> Result, AppError> { + if team_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = (1..=team_ids.len()) + .map(|i| format!("${}", i)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT DISTINCT team_id FROM hackathon_project_submissions WHERE team_id IN ({})", + placeholders + ); + #[derive(FromRow)] + struct SubRow { + team_id: Uuid, + } + let mut q = sqlx::query_as::<_, SubRow>(&sql); + for id in &team_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(|r| r.team_id).collect()) + } } diff --git a/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_repository.rs b/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_repository.rs index 3bd8b60..e7c6fa9 100644 --- a/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_repository.rs +++ b/imphnen-hackathon/src/teams/infrastructure/persistence/postgres_team_repository.rs @@ -1,182 +1,321 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::teams::domain::entity::*; use crate::teams::domain::repository::TeamRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] pub(crate) struct TeamRow { - pub id: Uuid, pub name: String, pub description: Option, pub city: String, - pub visibility: String, pub logo: Option, pub banner: Option, - pub leader_id: Uuid, pub created_at: Option>, pub updated_at: Option>, + pub id: Uuid, + pub name: String, + pub description: Option, + pub city: String, + pub visibility: String, + pub logo: Option, + pub banner: Option, + pub leader_id: Uuid, + pub created_at: Option>, + pub updated_at: Option>, } impl From for TeamEntity { - fn from(r: TeamRow) -> Self { - Self { id: r.id, name: r.name, description: r.description, city: r.city, visibility: r.visibility, - logo: r.logo, banner: r.banner, leader_id: r.leader_id, created_at: r.created_at, updated_at: r.updated_at } - } + fn from(r: TeamRow) -> Self { + Self { + id: r.id, + name: r.name, + description: r.description, + city: r.city, + visibility: r.visibility, + logo: r.logo, + banner: r.banner, + leader_id: r.leader_id, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } #[derive(FromRow)] pub(crate) struct UserRow { - pub id: Uuid, pub email: String, pub fullname: String, pub avatar: Option, - pub phone_number: Option, pub location: Option, pub bio: Option, - pub skills: Option>, pub is_active: Option, - pub created_at: Option>, pub updated_at: Option>, + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub phone_number: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, + pub is_active: Option, + pub created_at: Option>, + pub updated_at: Option>, } impl From for TeamUserInfo { - fn from(r: UserRow) -> Self { - Self { id: r.id, email: r.email, fullname: r.fullname, avatar: r.avatar, - phone_number: r.phone_number, location: r.location, bio: r.bio, - skills: r.skills, is_active: r.is_active, created_at: r.created_at, updated_at: r.updated_at } - } + fn from(r: UserRow) -> Self { + Self { + id: r.id, + email: r.email, + fullname: r.fullname, + avatar: r.avatar, + phone_number: r.phone_number, + location: r.location, + bio: r.bio, + skills: r.skills, + is_active: r.is_active, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } -pub struct PostgresTeamRepository { pub(crate) pool: Arc } -impl PostgresTeamRepository { pub fn new(pool: Arc) -> Self { Self { pool } } } +pub struct PostgresTeamRepository { + pub(crate) pool: Arc, +} +impl PostgresTeamRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} #[async_trait] impl TeamRepository for PostgresTeamRepository { - async fn create(&self, id: Uuid, leader_id: Uuid, input: CreateTeamInput) -> Result { - let now = Utc::now(); - let row: TeamRow = sqlx::query_as( + async fn create( + &self, + id: Uuid, + leader_id: Uuid, + input: CreateTeamInput, + ) -> Result { + let now = Utc::now(); + let row: TeamRow = sqlx::query_as( "INSERT INTO hackathon_teams (id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at" ) .bind(id).bind(&input.name).bind(&input.description).bind(&input.city) .bind(&input.visibility).bind(&input.logo).bind(&input.banner).bind(leader_id).bind(now).bind(now) .fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.into()) - } + Ok(row.into()) + } - async fn find_by_id(&self, id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn find_by_id(&self, id: Uuid) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at FROM hackathon_teams WHERE id = $1" ) .bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn find_by_user(&self, user_id: Uuid) -> Result, AppError> { - let rows: Vec = sqlx::query_as( + async fn find_by_user(&self, user_id: Uuid) -> Result, AppError> { + let rows: Vec = sqlx::query_as( "SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at FROM hackathon_teams t JOIN hackathon_team_members tm ON tm.team_id = t.id WHERE tm.user_id = $1 AND tm.status = 'active'" ) .bind(user_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(Into::into).collect()) - } + Ok(rows.into_iter().map(Into::into).collect()) + } - async fn get_leader(&self, leader_id: Uuid) -> Result, AppError> { - let row: Option = sqlx::query_as( + async fn get_leader( + &self, + leader_id: Uuid, + ) -> Result, AppError> { + let row: Option = sqlx::query_as( "SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id = $1" ) .bind(leader_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(row.map(Into::into)) - } + Ok(row.map(Into::into)) + } - async fn get_members(&self, team_id: Uuid) -> Result, AppError> { - #[derive(FromRow)] - struct MemberRow { - id: Uuid, team_id: Uuid, user_id: Uuid, role: String, status: String, joined_at: Option>, - user_email: String, user_fullname: String, user_avatar: Option, - user_phone_number: Option, user_location: Option, user_bio: Option, - user_skills: Option>, user_is_active: Option, - user_created_at: Option>, user_updated_at: Option>, - } - let rows: Vec = sqlx::query_as( + async fn get_members( + &self, + team_id: Uuid, + ) -> Result, AppError> { + #[derive(FromRow)] + struct MemberRow { + id: Uuid, + team_id: Uuid, + user_id: Uuid, + role: String, + status: String, + joined_at: Option>, + user_email: String, + user_fullname: String, + user_avatar: Option, + user_phone_number: Option, + user_location: Option, + user_bio: Option, + user_skills: Option>, + user_is_active: Option, + user_created_at: Option>, + user_updated_at: Option>, + } + let rows: Vec = sqlx::query_as( "SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.status, tm.joined_at, u.email as user_email, u.fullname as user_fullname, u.avatar as user_avatar, u.phone_number as user_phone_number, u.location as user_location, u.bio as user_bio, u.skills as user_skills, u.is_active as user_is_active, u.created_at as user_created_at, u.updated_at as user_updated_at FROM hackathon_team_members tm JOIN hackathon_users u ON tm.user_id = u.id WHERE tm.team_id = $1 AND tm.status = 'active' ORDER BY tm.role DESC, tm.joined_at ASC" ) .bind(team_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(|r| TeamMemberEntity { - id: r.id, team_id: r.team_id, user_id: r.user_id, role: r.role, status: r.status, joined_at: r.joined_at, - user: TeamUserInfo { id: r.user_id, email: r.user_email, fullname: r.user_fullname, avatar: r.user_avatar, - phone_number: r.user_phone_number, location: r.user_location, bio: r.user_bio, - skills: r.user_skills, is_active: r.user_is_active, created_at: r.user_created_at, updated_at: r.user_updated_at }, - }).collect()) - } + Ok( + rows + .into_iter() + .map(|r| TeamMemberEntity { + id: r.id, + team_id: r.team_id, + user_id: r.user_id, + role: r.role, + status: r.status, + joined_at: r.joined_at, + user: TeamUserInfo { + id: r.user_id, + email: r.user_email, + fullname: r.user_fullname, + avatar: r.user_avatar, + phone_number: r.user_phone_number, + location: r.user_location, + bio: r.user_bio, + skills: r.user_skills, + is_active: r.user_is_active, + created_at: r.user_created_at, + updated_at: r.user_updated_at, + }, + }) + .collect(), + ) + } - async fn add_member(&self, team_id: Uuid, user_id: Uuid, role: &str) -> Result<(), AppError> { - let now = Utc::now(); - sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, $4, 'active', $5) ON CONFLICT (team_id, user_id) DO NOTHING") + async fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> Result<(), AppError> { + let now = Utc::now(); + sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, $4, 'active', $5) ON CONFLICT (team_id, user_id) DO NOTHING") .bind(Uuid::new_v4()).bind(team_id).bind(user_id).bind(role).bind(now) .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn remove_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - sqlx::query("DELETE FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2") - .bind(team_id).bind(user_id) - .execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + async fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query( + "DELETE FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2", + ) + .bind(team_id) + .bind(user_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } - async fn get_member_count(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") + async fn get_member_count(&self, team_id: Uuid) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'") .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn is_member(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") + async fn is_member(&self, team_id: Uuid, user_id: Uuid) -> Result { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')") .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn is_leader(&self, team_id: Uuid, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)") - .bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn is_leader(&self, team_id: Uuid, user_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)", + ) + .bind(team_id) + .bind(user_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn user_active_team_name(&self, user_id: Uuid) -> Result, AppError> { - sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members tm ON tm.team_id = t.id WHERE tm.user_id = $1 AND tm.status = 'active' LIMIT 1") + async fn user_active_team_name( + &self, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members tm ON tm.team_id = t.id WHERE tm.user_id = $1 AND tm.status = 'active' LIMIT 1") .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + } - async fn team_has_submission(&self, team_id: Uuid) -> Result { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)") - .bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn team_has_submission(&self, team_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)", + ) + .bind(team_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError> { - let email: Option = sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") - .bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - if let Some(email) = email { - sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending'") + async fn reject_pending_invitations_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError> { + let email: Option = + sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1") + .bind(user_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + if let Some(email) = email { + sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending'") .bind(email).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - } - Ok(()) - } + } + Ok(()) + } - async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError> { - sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'") + async fn reject_pending_join_requests_for_user( + &self, + user_id: Uuid, + ) -> Result<(), AppError> { + sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'") .bind(user_id).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } + Ok(()) + } - async fn get_leaders_batch(&self, leader_ids: Vec) -> Result, AppError> { - self.leaders_batch_query(leader_ids).await - } + async fn get_leaders_batch( + &self, + leader_ids: Vec, + ) -> Result, AppError> { + self.leaders_batch_query(leader_ids).await + } - async fn get_member_counts_batch(&self, team_ids: Vec) -> Result, AppError> { - self.member_counts_batch_query(team_ids).await - } + async fn get_member_counts_batch( + &self, + team_ids: Vec, + ) -> Result, AppError> { + self.member_counts_batch_query(team_ids).await + } - async fn get_submitted_team_ids(&self, team_ids: Vec) -> Result, AppError> { - self.submitted_team_ids_query(team_ids).await - } + async fn get_submitted_team_ids( + &self, + team_ids: Vec, + ) -> Result, AppError> { + self.submitted_team_ids_query(team_ids).await + } - async fn update(&self, id: Uuid, input: UpdateTeamInput) -> Result { - self.update_query(id, input).await - } + async fn update( + &self, + id: Uuid, + input: UpdateTeamInput, + ) -> Result { + self.update_query(id, input).await + } - async fn delete(&self, id: Uuid) -> Result { - let result = sqlx::query("DELETE FROM hackathon_teams WHERE id = $1") - .bind(id).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(result.rows_affected() > 0) - } + async fn delete(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM hackathon_teams WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(result.rows_affected() > 0) + } - async fn browse(&self, input: BrowseTeamsInput) -> Result<(Vec, i64), AppError> { - self.browse_query(input).await - } + async fn browse( + &self, + input: BrowseTeamsInput, + ) -> Result<(Vec, i64), AppError> { + self.browse_query(input).await + } } diff --git a/imphnen-hackathon/src/teams/mod.rs b/imphnen-hackathon/src/teams/mod.rs index 82af820..f6d2610 100644 --- a/imphnen-hackathon/src/teams/mod.rs +++ b/imphnen-hackathon/src/teams/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::build_team_routes; diff --git a/imphnen-hackathon/src/users/application/user_service.rs b/imphnen-hackathon/src/users/application/user_service.rs index c39ae81..0b26b58 100644 --- a/imphnen-hackathon/src/users/application/user_service.rs +++ b/imphnen-hackathon/src/users/application/user_service.rs @@ -1,32 +1,39 @@ -use std::sync::Arc; -use uuid::Uuid; -use async_trait::async_trait; -use imphnen_utils::errors::AppError; use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput}; use crate::users::domain::repository::HackathonUserRepository; use crate::users::domain::service::HackathonUserService; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; pub struct HackathonUserServiceImpl { - repo: Arc, + repo: Arc, } impl HackathonUserServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl HackathonUserService for HackathonUserServiceImpl { - async fn get_user(&self, id: Uuid) -> Result { - self.repo.find_by_id(id).await - } + async fn get_user(&self, id: Uuid) -> Result { + self.repo.find_by_id(id).await + } - async fn update_user(&self, id: Uuid, input: UpdateUserInput) -> Result { - self.repo.update(id, input).await - } + async fn update_user( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result { + self.repo.update(id, input).await + } - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError> { - self.repo.get_user_teams(user_id).await - } + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError> { + self.repo.get_user_teams(user_id).await + } } diff --git a/imphnen-hackathon/src/users/domain/entity.rs b/imphnen-hackathon/src/users/domain/entity.rs index 7e1f404..ef26f00 100644 --- a/imphnen-hackathon/src/users/domain/entity.rs +++ b/imphnen-hackathon/src/users/domain/entity.rs @@ -1,27 +1,27 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct HackathonUserEntity { - pub id: Uuid, - pub email: String, - pub fullname: String, - pub avatar: Option, - pub phone_number: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, - pub is_active: Option, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub phone_number: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, + pub is_active: Option, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone, Default)] pub struct UpdateUserInput { - pub fullname: Option, - pub phone_number: Option, - pub avatar: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, + pub fullname: Option, + pub phone_number: Option, + pub avatar: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, } diff --git a/imphnen-hackathon/src/users/domain/repository.rs b/imphnen-hackathon/src/users/domain/repository.rs index 5a51b4e..fff4fe9 100644 --- a/imphnen-hackathon/src/users/domain/repository.rs +++ b/imphnen-hackathon/src/users/domain/repository.rs @@ -1,11 +1,18 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::{HackathonUserEntity, UpdateUserInput}; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait HackathonUserRepository: Send + Sync { - async fn find_by_id(&self, id: Uuid) -> Result; - async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result; - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError>; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn update( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result; + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError>; } diff --git a/imphnen-hackathon/src/users/domain/service.rs b/imphnen-hackathon/src/users/domain/service.rs index 4f5c829..06b51be 100644 --- a/imphnen-hackathon/src/users/domain/service.rs +++ b/imphnen-hackathon/src/users/domain/service.rs @@ -1,11 +1,18 @@ -use async_trait::async_trait; -use uuid::Uuid; -use imphnen_utils::errors::AppError; use super::entity::{HackathonUserEntity, UpdateUserInput}; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; #[async_trait] pub trait HackathonUserService: Send + Sync { - async fn get_user(&self, id: Uuid) -> Result; - async fn update_user(&self, id: Uuid, input: UpdateUserInput) -> Result; - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError>; + async fn get_user(&self, id: Uuid) -> Result; + async fn update_user( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result; + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError>; } diff --git a/imphnen-hackathon/src/users/infrastructure/http/dto.rs b/imphnen-hackathon/src/users/infrastructure/http/dto.rs index 56012ca..ab338e3 100644 --- a/imphnen-hackathon/src/users/infrastructure/http/dto.rs +++ b/imphnen-hackathon/src/users/infrastructure/http/dto.rs @@ -1,50 +1,61 @@ +use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput}; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UserResponse { - pub id: Uuid, - pub email: String, - pub fullname: String, - pub avatar: Option, - pub phone_number: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, - pub is_active: Option, - pub created_at: Option>, - pub updated_at: Option>, + pub id: Uuid, + pub email: String, + pub fullname: String, + pub avatar: Option, + pub phone_number: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, + pub is_active: Option, + pub created_at: Option>, + pub updated_at: Option>, } impl From for UserResponse { - fn from(e: HackathonUserEntity) -> Self { - Self { - id: e.id, email: e.email, fullname: e.fullname, avatar: e.avatar, - phone_number: e.phone_number, location: e.location, bio: e.bio, - skills: e.skills, is_active: e.is_active, - created_at: e.created_at, updated_at: e.updated_at, - } - } + fn from(e: HackathonUserEntity) -> Self { + Self { + id: e.id, + email: e.email, + fullname: e.fullname, + avatar: e.avatar, + phone_number: e.phone_number, + location: e.location, + bio: e.bio, + skills: e.skills, + is_active: e.is_active, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateUserRequest { - pub fullname: Option, - pub phone_number: Option, - pub avatar: Option, - pub location: Option, - pub bio: Option, - pub skills: Option>, + pub fullname: Option, + pub phone_number: Option, + pub avatar: Option, + pub location: Option, + pub bio: Option, + pub skills: Option>, } impl From for UpdateUserInput { - fn from(r: UpdateUserRequest) -> Self { - Self { - fullname: r.fullname, phone_number: r.phone_number, avatar: r.avatar, - location: r.location, bio: r.bio, skills: r.skills, - } - } + fn from(r: UpdateUserRequest) -> Self { + Self { + fullname: r.fullname, + phone_number: r.phone_number, + avatar: r.avatar, + location: r.location, + bio: r.bio, + skills: r.skills, + } + } } diff --git a/imphnen-hackathon/src/users/infrastructure/http/handlers.rs b/imphnen-hackathon/src/users/infrastructure/http/handlers.rs index d361227..7bf8bf6 100644 --- a/imphnen-hackathon/src/users/infrastructure/http/handlers.rs +++ b/imphnen-hackathon/src/users/infrastructure/http/handlers.rs @@ -1,40 +1,40 @@ -use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use std::sync::Arc; -use uuid::Uuid; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use super::dto::{UpdateUserRequest, UserResponse}; use crate::middleware::hackathon_auth::HackathonAuthUser; use crate::users::domain::service::HackathonUserService; -use super::dto::{UserResponse, UpdateUserRequest}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; pub async fn get_me_handler( - Extension(service): Extension>, - Extension(auth): Extension, + Extension(service): Extension>, + Extension(auth): Extension, ) -> Result { - let user = service.get_user(auth.user_id).await?; - Ok(ApiSuccess(UserResponse::from(user)).into_response()) + let user = service.get_user(auth.user_id).await?; + Ok(ApiSuccess(UserResponse::from(user)).into_response()) } pub async fn update_me_handler( - Extension(service): Extension>, - Extension(auth): Extension, - Json(body): Json, + Extension(service): Extension>, + Extension(auth): Extension, + Json(body): Json, ) -> Result { - let user = service.update_user(auth.user_id, body.into()).await?; - Ok(ApiSuccess(UserResponse::from(user)).into_response()) + let user = service.update_user(auth.user_id, body.into()).await?; + Ok(ApiSuccess(UserResponse::from(user)).into_response()) } pub async fn get_user_handler( - Extension(service): Extension>, - Path(user_id): Path, + Extension(service): Extension>, + Path(user_id): Path, ) -> Result { - let user = service.get_user(user_id).await?; - Ok(ApiSuccess(UserResponse::from(user)).into_response()) + let user = service.get_user(user_id).await?; + Ok(ApiSuccess(UserResponse::from(user)).into_response()) } pub async fn get_user_teams_handler( - Extension(service): Extension>, - Path(user_id): Path, + Extension(service): Extension>, + Path(user_id): Path, ) -> Result { - let teams = service.get_user_teams(user_id).await?; - Ok(ApiSuccess(teams).into_response()) + let teams = service.get_user_teams(user_id).await?; + Ok(ApiSuccess(teams).into_response()) } diff --git a/imphnen-hackathon/src/users/infrastructure/http/routes.rs b/imphnen-hackathon/src/users/infrastructure/http/routes.rs index 6de1e9a..d633fbd 100644 --- a/imphnen-hackathon/src/users/infrastructure/http/routes.rs +++ b/imphnen-hackathon/src/users/infrastructure/http/routes.rs @@ -1,24 +1,24 @@ -use axum::{middleware::from_fn, routing::get, Extension, Router}; -use sqlx::PgPool; -use std::sync::Arc; +use super::handlers::*; +use crate::middleware::hackathon_auth::hackathon_auth_middleware; use crate::users::application::user_service::HackathonUserServiceImpl; use crate::users::domain::service::HackathonUserService; use crate::users::infrastructure::persistence::PostgresHackathonUserRepository; -use crate::middleware::hackathon_auth::hackathon_auth_middleware; -use super::handlers::*; +use axum::{Extension, Router, middleware::from_fn, routing::get}; +use sqlx::PgPool; +use std::sync::Arc; fn build_service(pool: Arc) -> Arc { - let repo = Arc::new(PostgresHackathonUserRepository::new(pool)); - Arc::new(HackathonUserServiceImpl::new(repo)) + let repo = Arc::new(PostgresHackathonUserRepository::new(pool)); + Arc::new(HackathonUserServiceImpl::new(repo)) } pub fn hackathon_users_routes(pool: Arc) -> Router { - let service = build_service(pool.clone()); - Router::new() - .route("/users/me", get(get_me_handler).put(update_me_handler)) - .route("/users/:user_id", get(get_user_handler)) - .route("/users/:user_id/teams", get(get_user_teams_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(hackathon_auth_middleware)) + let service = build_service(pool.clone()); + Router::new() + .route("/users/me", get(get_me_handler).put(update_me_handler)) + .route("/users/:user_id", get(get_user_handler)) + .route("/users/:user_id/teams", get(get_user_teams_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) + .layer(from_fn(hackathon_auth_middleware)) } diff --git a/imphnen-hackathon/src/users/infrastructure/persistence/postgres_user_repository.rs b/imphnen-hackathon/src/users/infrastructure/persistence/postgres_user_repository.rs index 17408f5..4dfdf8e 100644 --- a/imphnen-hackathon/src/users/infrastructure/persistence/postgres_user_repository.rs +++ b/imphnen-hackathon/src/users/infrastructure/persistence/postgres_user_repository.rs @@ -1,52 +1,59 @@ -use std::sync::Arc; -use uuid::Uuid; -use chrono::Utc; -use async_trait::async_trait; -use sqlx::{PgPool, FromRow}; -use imphnen_utils::errors::AppError; use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput}; use crate::users::domain::repository::HackathonUserRepository; +use async_trait::async_trait; +use chrono::Utc; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; #[derive(FromRow)] struct UserRow { - id: Uuid, - email: String, - fullname: String, - avatar: Option, - phone_number: Option, - location: Option, - bio: Option, - skills: Option>, - is_active: Option, - created_at: Option>, - updated_at: Option>, + id: Uuid, + email: String, + fullname: String, + avatar: Option, + phone_number: Option, + location: Option, + bio: Option, + skills: Option>, + is_active: Option, + created_at: Option>, + updated_at: Option>, } impl From for HackathonUserEntity { - fn from(r: UserRow) -> Self { - Self { - id: r.id, email: r.email, fullname: r.fullname, avatar: r.avatar, - phone_number: r.phone_number, location: r.location, bio: r.bio, - skills: r.skills, is_active: r.is_active, - created_at: r.created_at, updated_at: r.updated_at, - } - } + fn from(r: UserRow) -> Self { + Self { + id: r.id, + email: r.email, + fullname: r.fullname, + avatar: r.avatar, + phone_number: r.phone_number, + location: r.location, + bio: r.bio, + skills: r.skills, + is_active: r.is_active, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } pub struct PostgresHackathonUserRepository { - pool: Arc, + pool: Arc, } impl PostgresHackathonUserRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } + pub fn new(pool: Arc) -> Self { + Self { pool } + } } #[async_trait] impl HackathonUserRepository for PostgresHackathonUserRepository { - async fn find_by_id(&self, id: Uuid) -> Result { - sqlx::query_as::<_, UserRow>( + async fn find_by_id(&self, id: Uuid) -> Result { + sqlx::query_as::<_, UserRow>( "SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id = $1" ) .bind(id) @@ -55,55 +62,103 @@ impl HackathonUserRepository for PostgresHackathonUserRepository { .map_err(|e| AppError::InternalServerError(e.to_string()))? .map(Into::into) .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) - } + } - async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result { - let mut sets = Vec::new(); - let mut idx = 1usize; - if input.fullname.is_some() { sets.push(format!("fullname = ${}", idx)); idx += 1; } - if input.phone_number.is_some() { sets.push(format!("phone_number = ${}", idx)); idx += 1; } - if input.avatar.is_some() { sets.push(format!("avatar = ${}", idx)); idx += 1; } - if input.location.is_some() { sets.push(format!("location = ${}", idx)); idx += 1; } - if input.bio.is_some() { sets.push(format!("bio = ${}", idx)); idx += 1; } - if input.skills.is_some() { sets.push(format!("skills = ${}", idx)); idx += 1; } - if sets.is_empty() { return self.find_by_id(id).await; } - sets.push(format!("updated_at = ${}", idx)); - let sql = format!( - "UPDATE hackathon_users SET {} WHERE id = ${} RETURNING id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at", - sets.join(", "), idx + 1 - ); - let mut q = sqlx::query_as::<_, UserRow>(&sql); - if let Some(v) = input.fullname { q = q.bind(v); } - if let Some(v) = input.phone_number { q = q.bind(v); } - if let Some(v) = input.avatar { q = q.bind(v); } - if let Some(v) = input.location { q = q.bind(v); } - if let Some(v) = input.bio { q = q.bind(v); } - if let Some(v) = input.skills { q = q.bind(v); } - q.bind(Utc::now()).bind(id) - .fetch_one(self.pool.as_ref()) - .await - .map(Into::into) - .map_err(|e| AppError::InternalServerError(e.to_string())) - } + async fn update( + &self, + id: Uuid, + input: UpdateUserInput, + ) -> Result { + let mut sets = Vec::new(); + let mut idx = 1usize; + if input.fullname.is_some() { + sets.push(format!("fullname = ${}", idx)); + idx += 1; + } + if input.phone_number.is_some() { + sets.push(format!("phone_number = ${}", idx)); + idx += 1; + } + if input.avatar.is_some() { + sets.push(format!("avatar = ${}", idx)); + idx += 1; + } + if input.location.is_some() { + sets.push(format!("location = ${}", idx)); + idx += 1; + } + if input.bio.is_some() { + sets.push(format!("bio = ${}", idx)); + idx += 1; + } + if input.skills.is_some() { + sets.push(format!("skills = ${}", idx)); + idx += 1; + } + if sets.is_empty() { + return self.find_by_id(id).await; + } + sets.push(format!("updated_at = ${}", idx)); + let sql = format!( + "UPDATE hackathon_users SET {} WHERE id = ${} RETURNING id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at", + sets.join(", "), + idx + 1 + ); + let mut q = sqlx::query_as::<_, UserRow>(&sql); + if let Some(v) = input.fullname { + q = q.bind(v); + } + if let Some(v) = input.phone_number { + q = q.bind(v); + } + if let Some(v) = input.avatar { + q = q.bind(v); + } + if let Some(v) = input.location { + q = q.bind(v); + } + if let Some(v) = input.bio { + q = q.bind(v); + } + if let Some(v) = input.skills { + q = q.bind(v); + } + q.bind(Utc::now()) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map(Into::into) + .map_err(|e| AppError::InternalServerError(e.to_string())) + } - async fn get_user_teams(&self, user_id: Uuid) -> Result, AppError> { - #[derive(FromRow)] - struct TeamRow { - id: Uuid, name: String, description: String, city: String, visibility: String, - logo: Option, banner: Option, leader_id: Uuid, - created_at: chrono::DateTime, updated_at: chrono::DateTime, - } - let rows = sqlx::query_as::<_, TeamRow>( + async fn get_user_teams( + &self, + user_id: Uuid, + ) -> Result, AppError> { + #[derive(FromRow)] + struct TeamRow { + id: Uuid, + name: String, + description: String, + city: String, + visibility: String, + logo: Option, + banner: Option, + leader_id: Uuid, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + } + let rows = sqlx::query_as::<_, TeamRow>( "SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at FROM hackathon_teams t JOIN hackathon_team_members tm ON t.id = tm.team_id WHERE tm.user_id = $1 AND tm.status = 'active' ORDER BY t.created_at DESC" ) .bind(user_id) .fetch_all(self.pool.as_ref()) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(rows.into_iter().map(|r| serde_json::json!({ + Ok(rows.into_iter().map(|r| serde_json::json!({ "id": r.id, "name": r.name, "description": r.description, "city": r.city, "visibility": r.visibility, "logo": r.logo, "banner": r.banner, "leader_id": r.leader_id, "created_at": r.created_at, "updated_at": r.updated_at })).collect()) - } + } } diff --git a/imphnen-hackathon/src/users/mod.rs b/imphnen-hackathon/src/users/mod.rs index 0aab70a..1354ebf 100644 --- a/imphnen-hackathon/src/users/mod.rs +++ b/imphnen-hackathon/src/users/mod.rs @@ -1,5 +1,5 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; pub use infrastructure::http::routes::hackathon_users_routes; diff --git a/imphnen-hackathon/src/winners/application/mod.rs b/imphnen-hackathon/src/winners/application/mod.rs new file mode 100644 index 0000000..cc93aa3 --- /dev/null +++ b/imphnen-hackathon/src/winners/application/mod.rs @@ -0,0 +1 @@ +pub mod winner_service; diff --git a/imphnen-hackathon/src/winners/application/winner_service.rs b/imphnen-hackathon/src/winners/application/winner_service.rs new file mode 100644 index 0000000..0d08f6b --- /dev/null +++ b/imphnen-hackathon/src/winners/application/winner_service.rs @@ -0,0 +1,23 @@ +use crate::winners::domain::entity::WinnerData; +use crate::winners::domain::repository::WinnerRepository; +use crate::winners::domain::service::WinnerService; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; + +pub struct WinnerServiceImpl { + repo: Arc, +} + +impl WinnerServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl WinnerService for WinnerServiceImpl { + async fn list_winners(&self) -> Result, AppError> { + self.repo.list_winners().await + } +} diff --git a/imphnen-hackathon/src/winners/domain/entity.rs b/imphnen-hackathon/src/winners/domain/entity.rs new file mode 100644 index 0000000..bd44907 --- /dev/null +++ b/imphnen-hackathon/src/winners/domain/entity.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct WinnerData { + pub id: Uuid, + pub team_id: Uuid, + pub team_name: String, + pub rank: i32, + pub prize: Option, + pub announced_at: Option>, + pub created_at: Option>, +} diff --git a/imphnen-hackathon/src/winners/domain/mod.rs b/imphnen-hackathon/src/winners/domain/mod.rs new file mode 100644 index 0000000..228c84e --- /dev/null +++ b/imphnen-hackathon/src/winners/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod repository; +pub mod service; diff --git a/imphnen-hackathon/src/winners/domain/repository.rs b/imphnen-hackathon/src/winners/domain/repository.rs new file mode 100644 index 0000000..9cf6c03 --- /dev/null +++ b/imphnen-hackathon/src/winners/domain/repository.rs @@ -0,0 +1,8 @@ +use super::entity::WinnerData; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; + +#[async_trait] +pub trait WinnerRepository: Send + Sync { + async fn list_winners(&self) -> Result, AppError>; +} diff --git a/imphnen-hackathon/src/winners/domain/service.rs b/imphnen-hackathon/src/winners/domain/service.rs new file mode 100644 index 0000000..d85a3d0 --- /dev/null +++ b/imphnen-hackathon/src/winners/domain/service.rs @@ -0,0 +1,8 @@ +use super::entity::WinnerData; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; + +#[async_trait] +pub trait WinnerService: Send + Sync { + async fn list_winners(&self) -> Result, AppError>; +} diff --git a/imphnen-hackathon/src/winners/infrastructure/http/dto.rs b/imphnen-hackathon/src/winners/infrastructure/http/dto.rs new file mode 100644 index 0000000..a4f8566 --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/http/dto.rs @@ -0,0 +1,30 @@ +use crate::winners::domain::entity::WinnerData; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, ToSchema)] +pub struct WinnerResponse { + pub id: Uuid, + pub team_id: Uuid, + pub team_name: String, + pub rank: i32, + pub prize: Option, + pub announced_at: Option>, + pub created_at: Option>, +} + +impl From for WinnerResponse { + fn from(d: WinnerData) -> Self { + Self { + id: d.id, + team_id: d.team_id, + team_name: d.team_name, + rank: d.rank, + prize: d.prize, + announced_at: d.announced_at, + created_at: d.created_at, + } + } +} diff --git a/imphnen-hackathon/src/winners/infrastructure/http/handlers.rs b/imphnen-hackathon/src/winners/infrastructure/http/handlers.rs new file mode 100644 index 0000000..c872cbb --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/http/handlers.rs @@ -0,0 +1,14 @@ +use super::dto::WinnerResponse; +use crate::winners::domain::service::WinnerService; +use axum::{Extension, response::IntoResponse}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; + +pub async fn list_winners_handler( + Extension(service): Extension>, +) -> Result { + let winners = service.list_winners().await?; + let response: Vec = + winners.into_iter().map(WinnerResponse::from).collect(); + Ok(ApiSuccess(response).into_response()) +} diff --git a/imphnen-hackathon/src/winners/infrastructure/http/mod.rs b/imphnen-hackathon/src/winners/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-hackathon/src/winners/infrastructure/http/routes.rs b/imphnen-hackathon/src/winners/infrastructure/http/routes.rs new file mode 100644 index 0000000..8d6649d --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/http/routes.rs @@ -0,0 +1,17 @@ +use super::handlers::list_winners_handler; +use crate::winners::application::winner_service::WinnerServiceImpl; +use crate::winners::domain::service::WinnerService; +use crate::winners::infrastructure::persistence::PostgresWinnerRepository; +use axum::{Extension, Router, routing::get}; +use sqlx::PgPool; +use std::sync::Arc; + +pub fn hackathon_winners_routes(pool: Arc) -> Router { + let service: Arc = Arc::new(WinnerServiceImpl::new(Arc::new( + PostgresWinnerRepository::new(pool.clone()), + ))); + Router::new() + .route("/winners", get(list_winners_handler)) + .layer(Extension(service)) + .layer(Extension(pool)) +} diff --git a/imphnen-hackathon/src/winners/infrastructure/mod.rs b/imphnen-hackathon/src/winners/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-hackathon/src/winners/infrastructure/persistence/mod.rs b/imphnen-hackathon/src/winners/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..d097894 --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod postgres_winner_repository; +pub use postgres_winner_repository::PostgresWinnerRepository; diff --git a/imphnen-hackathon/src/winners/infrastructure/persistence/postgres_winner_repository.rs b/imphnen-hackathon/src/winners/infrastructure/persistence/postgres_winner_repository.rs new file mode 100644 index 0000000..3e0af5e --- /dev/null +++ b/imphnen-hackathon/src/winners/infrastructure/persistence/postgres_winner_repository.rs @@ -0,0 +1,56 @@ +use crate::winners::domain::entity::WinnerData; +use crate::winners::domain::repository::WinnerRepository; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::errors::AppError; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(FromRow)] +struct WinnerRow { + id: Uuid, + team_id: Uuid, + team_name: String, + rank: i32, + prize: Option, + announced_at: Option>, + created_at: Option>, +} + +impl From for WinnerData { + fn from(r: WinnerRow) -> Self { + Self { + id: r.id, + team_id: r.team_id, + team_name: r.team_name, + rank: r.rank, + prize: r.prize, + announced_at: r.announced_at, + created_at: r.created_at, + } + } +} + +pub struct PostgresWinnerRepository { + pool: Arc, +} + +impl PostgresWinnerRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WinnerRepository for PostgresWinnerRepository { + async fn list_winners(&self) -> Result, AppError> { + let rows: Vec = sqlx::query_as( + "SELECT w.id, w.team_id, t.name as team_name, w.rank, w.prize, w.announced_at, w.created_at FROM hackathon_winners w JOIN hackathon_teams t ON w.team_id = t.id ORDER BY w.rank ASC" + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(Into::into).collect()) + } +} diff --git a/imphnen-hackathon/src/winners/mod.rs b/imphnen-hackathon/src/winners/mod.rs index bd38dd5..8dbe746 100644 --- a/imphnen-hackathon/src/winners/mod.rs +++ b/imphnen-hackathon/src/winners/mod.rs @@ -1,2 +1,5 @@ -pub mod routes; -pub use routes::hackathon_winners_routes; +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use infrastructure::http::routes::hackathon_winners_routes; diff --git a/imphnen-hackathon/src/winners/routes.rs b/imphnen-hackathon/src/winners/routes.rs deleted file mode 100644 index e90d2e8..0000000 --- a/imphnen-hackathon/src/winners/routes.rs +++ /dev/null @@ -1,37 +0,0 @@ -use axum::{response::IntoResponse, routing::get, Extension, Router}; -use sqlx::{PgPool, FromRow}; -use std::sync::Arc; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use utoipa::ToSchema; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; - -#[derive(Debug, Serialize, ToSchema, FromRow)] -pub struct WinnerResponse { - pub id: Uuid, - pub team_id: Uuid, - pub team_name: String, - pub rank: i32, - pub prize: Option, - pub announced_at: Option>, - pub created_at: Option>, -} - -async fn list_winners_handler( - Extension(pool): Extension>, -) -> Result { - let rows: Vec = sqlx::query_as( - "SELECT w.id, w.team_id, t.name as team_name, w.rank, w.prize, w.announced_at, w.created_at FROM hackathon_winners w JOIN hackathon_teams t ON w.team_id = t.id ORDER BY w.rank ASC" - ) - .fetch_all(pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(ApiSuccess(rows).into_response()) -} - -pub fn hackathon_winners_routes(pool: Arc) -> Router { - Router::new() - .route("/winners", get(list_winners_handler)) - .layer(Extension(pool)) -} diff --git a/imphnen-iam/Cargo.toml b/imphnen-iam/Cargo.toml index b0b5dcd..93d1bcb 100644 --- a/imphnen-iam/Cargo.toml +++ b/imphnen-iam/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "imphnen-iam" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] imphnen-libs.workspace = true imphnen-utils.workspace = true +imphnen-storage.workspace = true +imphnen-email.workspace = true imphnen-entities.workspace = true async-trait.workspace = true diff --git a/imphnen-iam/src/auth/application/mod.rs b/imphnen-iam/src/auth/application/mod.rs index d14373b..274969e 100644 --- a/imphnen-iam/src/auth/application/mod.rs +++ b/imphnen-iam/src/auth/application/mod.rs @@ -1,167 +1,301 @@ -use std::sync::Arc; +use crate::auth::domain::AuthService; +use crate::auth::domain::types::{ + AuthTokens, AuthUserDetail, LoginInput, LoginOutput, NewPasswordInput, + RefreshTokenInput, RegisterInput, ResendOtpInput, VerifyEmailInput, +}; +use crate::roles::domain::RoleRepository; +use crate::users::domain::{UserEntity, UserRepository}; use async_trait::async_trait; +use imphnen_email::send_email; +use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; +use imphnen_libs::{ + decode_access_token, decode_refresh_token, encode_access_token, + encode_refresh_token, encode_reset_password_token, environment, hash_password, + verify_password, +}; +use imphnen_utils::generate_otp::OtpManager; +use imphnen_utils::{AppError, get_iso_date}; +use std::sync::Arc; use tracing::error; use uuid::Uuid; -use imphnen_libs::{environment, encode_access_token, encode_refresh_token, encode_reset_password_token, - decode_access_token, decode_refresh_token, hash_password, send_email, verify_password}; -use imphnen_utils::{AppError, get_iso_date}; -use imphnen_utils::generate_otp::OtpManager; -use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; -use crate::auth::domain::AuthService; -use crate::auth::infrastructure::http::dto::{ - AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, - AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthResendOtpRequestDto, - AuthVerifyEmailRequestDto, TokenDto, -}; -use crate::users::domain::{UserEntity, UserRepository}; -use crate::users::infrastructure::http::dto::UsersDetailItemDto; -use crate::roles::domain::RoleRepository; pub struct AuthServiceImpl { - user_repo: Arc, - role_repo: Arc, + user_repo: Arc, + role_repo: Arc, } impl AuthServiceImpl { - pub fn new(user_repo: Arc, role_repo: Arc) -> Self { - Self { user_repo, role_repo } - } + pub fn new( + user_repo: Arc, + role_repo: Arc, + ) -> Self { + Self { + user_repo, + role_repo, + } + } +} + +fn entity_to_user_detail(user: UserEntity) -> AuthUserDetail { + AuthUserDetail { + id: user.id, + email: user.email, + fullname: user.fullname, + legal_name: user.legal_name, + avatar: user.avatar, + is_active: user.is_active, + role: user.role, + profile_extension: user.profile_extension, + created_at: user.created_at, + updated_at: user.updated_at, + } } #[async_trait] impl AuthService for AuthServiceImpl { - async fn login(&self, payload: AuthLoginRequestDto) -> Result { - let user = self.user_repo.find_by_email(payload.email.clone()).await - .map_err(|_| AppError::AuthenticationError("Email or password not correct".into()))?; - if !user.is_active { - return Err(AppError::AuthenticationError("Account not active, please verify your email".into())); - } - let valid = verify_password(&payload.password, &user.password) - .map_err(|_| AppError::InternalServerError("Password verification failed".into()))?; - if !valid { - return Err(AppError::AuthenticationError("Email or password not correct".into())); - } - let access_token = encode_access_token(payload.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate access token".into()))?; - let refresh_token = encode_refresh_token(payload.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate refresh token".into()))?; - Ok(AuthLoginResponsetDto { - user: UsersDetailItemDto::from(user), - token: TokenDto { access_token, refresh_token }, - }) - } + async fn login(&self, payload: LoginInput) -> Result { + let user = self + .user_repo + .find_by_email(payload.email.clone()) + .await + .map_err(|_| { + AppError::AuthenticationError("Email or password not correct".into()) + })?; + if !user.is_active { + return Err(AppError::AuthenticationError( + "Account not active, please verify your email".into(), + )); + } + let valid = + verify_password(&payload.password, &user.password).map_err(|_| { + AppError::InternalServerError("Password verification failed".into()) + })?; + if !valid { + return Err(AppError::AuthenticationError( + "Email or password not correct".into(), + )); + } + let access_token = encode_access_token(payload.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate access token".into()) + })?; + let refresh_token = encode_refresh_token(payload.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate refresh token".into()) + })?; + Ok(LoginOutput { + user: entity_to_user_detail(user), + token: AuthTokens { + access_token, + refresh_token, + }, + }) + } - async fn login_mentor(&self, payload: AuthLoginRequestDto) -> Result { - let user = self.user_repo.find_by_email(payload.email.clone()).await - .map_err(|_| AppError::AuthenticationError("Email or password not correct".into()))?; - if !user.is_active { - return Err(AppError::AuthenticationError("Account not active, please verify your email".into())); - } - let valid = verify_password(&payload.password, &user.password) - .map_err(|_| AppError::InternalServerError("Password verification failed".into()))?; - if !valid { - return Err(AppError::AuthenticationError("Email or password not correct".into())); - } - if user.role.name != "Mentor" { - return Err(AppError::ForbiddenError("User does not have mentor privileges".into())); - } - let access_token = encode_access_token(payload.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate access token".into()))?; - let refresh_token = encode_refresh_token(payload.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate refresh token".into()))?; - Ok(AuthLoginResponsetDto { - user: UsersDetailItemDto::from(user), - token: TokenDto { access_token, refresh_token }, - }) - } + async fn login_mentor( + &self, + payload: LoginInput, + ) -> Result { + let user = self + .user_repo + .find_by_email(payload.email.clone()) + .await + .map_err(|_| { + AppError::AuthenticationError("Email or password not correct".into()) + })?; + if !user.is_active { + return Err(AppError::AuthenticationError( + "Account not active, please verify your email".into(), + )); + } + let valid = + verify_password(&payload.password, &user.password).map_err(|_| { + AppError::InternalServerError("Password verification failed".into()) + })?; + if !valid { + return Err(AppError::AuthenticationError( + "Email or password not correct".into(), + )); + } + if user.role.name != "Mentor" { + return Err(AppError::ForbiddenError( + "User does not have mentor privileges".into(), + )); + } + let access_token = encode_access_token(payload.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate access token".into()) + })?; + let refresh_token = encode_refresh_token(payload.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate refresh token".into()) + })?; + Ok(LoginOutput { + user: entity_to_user_detail(user), + token: AuthTokens { + access_token, + refresh_token, + }, + }) + } - async fn register(&self, payload: AuthRegisterRequestDto) -> Result<(), AppError> { - let role = self.role_repo.find_by_name("User".into()).await - .map_err(|_| AppError::NotFoundError("Role not found".into()))?; - if self.user_repo.find_by_email(payload.email.clone()).await.is_ok() { - return Err(AppError::BadRequestError("User already exists".into())); - } - let hashed = hash_password(&payload.password) - .map_err(|e| { error!("Failed to hash password: {}", e); AppError::InternalServerError("Failed to hash password".into()) })?; - let otp = OtpManager::generate_otp(); - send_email(&payload.email, "OTP Verification", &format!("your otp code is {}", otp.code)) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - self.user_repo.create(UserEntity { - id: Uuid::new_v4().to_string(), - email: payload.email.clone(), - fullname: payload.fullname.clone(), - password: hashed, - is_active: false, - is_deleted: false, - role: RolesDetailQueryDto { id: role.id.to_string(), name: role.name, ..Default::default() }, - profile_extension: Some(UserProfileExtensionDto { phone_number: payload.phone_number, ..Default::default() }), - created_at: get_iso_date(), - updated_at: get_iso_date(), - ..Default::default() - }).await?; - Ok(()) - } + async fn register(&self, payload: RegisterInput) -> Result<(), AppError> { + let role = self + .role_repo + .find_by_name("User".into()) + .await + .map_err(|_| AppError::NotFoundError("Role not found".into()))?; + if self + .user_repo + .find_by_email(payload.email.clone()) + .await + .is_ok() + { + return Err(AppError::BadRequestError("User already exists".into())); + } + let hashed = hash_password(&payload.password).map_err(|e| { + error!("Failed to hash password: {}", e); + AppError::InternalServerError("Failed to hash password".into()) + })?; + let otp = OtpManager::generate_otp(); + send_email( + &payload.email, + "OTP Verification", + &format!("your otp code is {}", otp.code), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + self + .user_repo + .create(UserEntity { + id: Uuid::new_v4().to_string(), + email: payload.email.clone(), + fullname: payload.fullname.clone(), + password: hashed, + is_active: false, + is_deleted: false, + role: RolesDetailQueryDto { + id: role.id.to_string(), + name: role.name, + ..Default::default() + }, + profile_extension: Some(UserProfileExtensionDto { + phone_number: payload.phone_number, + ..Default::default() + }), + created_at: get_iso_date(), + updated_at: get_iso_date(), + ..Default::default() + }) + .await?; + Ok(()) + } - async fn resend_otp(&self, payload: AuthResendOtpRequestDto) -> Result<(), AppError> { - self.user_repo.find_by_email(payload.email.clone()).await - .map_err(|_| AppError::NotFoundError("User not found".into()))?; - let otp = OtpManager::generate_otp(); - send_email(&payload.email, "OTP Verification", &format!("Your OTP code is {}", otp.code)) - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - Ok(()) - } + async fn resend_otp(&self, payload: ResendOtpInput) -> Result<(), AppError> { + self + .user_repo + .find_by_email(payload.email.clone()) + .await + .map_err(|_| AppError::NotFoundError("User not found".into()))?; + let otp = OtpManager::generate_otp(); + send_email( + &payload.email, + "OTP Verification", + &format!("Your OTP code is {}", otp.code), + ) + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + Ok(()) + } - async fn refresh_token(&self, payload: AuthRefreshTokenRequestDto) -> Result { - let email = decode_refresh_token(&payload.refresh_token) - .map_err(|_| AppError::AuthenticationError("Invalid refresh token".into()))?.claims.sub; - let user = self.user_repo.find_by_email(email.clone()).await - .map_err(|_| AppError::AuthenticationError("User not found".into()))?; - let access_token = encode_access_token(user.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate access token".into()))?; - let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone()) - .map_err(|_| AppError::InternalServerError("Failed to generate refresh token".into()))?; - Ok(TokenDto { access_token, refresh_token }) - } + async fn refresh_token( + &self, + payload: RefreshTokenInput, + ) -> Result { + let email = decode_refresh_token(&payload.refresh_token) + .map_err(|_| AppError::AuthenticationError("Invalid refresh token".into()))? + .claims + .sub; + let user = self + .user_repo + .find_by_email(email.clone()) + .await + .map_err(|_| AppError::AuthenticationError("User not found".into()))?; + let access_token = encode_access_token(user.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate access token".into()) + })?; + let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone()) + .map_err(|_| { + AppError::InternalServerError("Failed to generate refresh token".into()) + })?; + Ok(AuthTokens { + access_token, + refresh_token, + }) + } - async fn forgot_password(&self, payload: AuthResendOtpRequestDto) -> Result<(), AppError> { - let user_repo = Arc::clone(&self.user_repo); - tokio::spawn(async move { - if let Ok(user) = user_repo.find_by_email(payload.email.clone()).await { - match encode_reset_password_token(user.email.clone(), user.id.clone()) { - Ok(token) => { - let fe_url = environment::ENV.fe_url.clone(); - let msg = format!( - "You have requested a password reset. Please click the link below: {fe_url}/auth/reset-password?token={token}" - ); - if let Err(e) = send_email(&payload.email, "Reset Password Request", &msg) { - error!("Failed to send reset password email: {}", e); - } - } - Err(e) => error!("Failed to generate reset token: {:?}", e), - } - } - }); - Ok(()) - } + async fn forgot_password(&self, payload: ResendOtpInput) -> Result<(), AppError> { + let user_repo = Arc::clone(&self.user_repo); + tokio::spawn(async move { + if let Ok(user) = user_repo.find_by_email(payload.email.clone()).await { + match encode_reset_password_token(user.email.clone(), user.id.clone()) { + Ok(token) => { + let fe_url = environment::ENV.fe_url.clone(); + let msg = format!( + "You have requested a password reset. Please click the link below: {fe_url}/auth/reset-password?token={token}" + ); + if let Err(e) = + send_email(&payload.email, "Reset Password Request", &msg) + { + error!("Failed to send reset password email: {}", e); + } + } + Err(e) => error!("Failed to generate reset token: {:?}", e), + } + } + }); + Ok(()) + } - async fn verify_email(&self, payload: AuthVerifyEmailRequestDto) -> Result<(), AppError> { - let user = self.user_repo.find_by_email(payload.email.clone()).await - .map_err(|_| AppError::NotFoundError("User not found".into()))?; - if user.is_active { - return Err(AppError::BadRequestError("User already active".into())); - } - self.user_repo.update(UserEntity { is_active: true, ..user }).await?; - Ok(()) - } + async fn verify_email(&self, payload: VerifyEmailInput) -> Result<(), AppError> { + let user = self + .user_repo + .find_by_email(payload.email.clone()) + .await + .map_err(|_| AppError::NotFoundError("User not found".into()))?; + if user.is_active { + return Err(AppError::BadRequestError("User already active".into())); + } + self + .user_repo + .update(UserEntity { + is_active: true, + ..user + }) + .await?; + Ok(()) + } - async fn new_password(&self, payload: AuthNewPasswordRequestDto) -> Result<(), AppError> { - let email = decode_access_token(&payload.token) - .map_err(|_| AppError::BadRequestError("Invalid or missing token".into()))?.claims.sub; - let user = self.user_repo.find_by_email(email).await - .map_err(|e| AppError::BadRequestError(e.to_string()))?; - let hashed = hash_password(&payload.password) - .map_err(|e| { error!("Failed to hash new password: {}", e); AppError::InternalServerError("Failed to hash password".into()) })?; - self.user_repo.update(UserEntity { password: hashed, ..user }).await?; - Ok(()) - } + async fn new_password(&self, payload: NewPasswordInput) -> Result<(), AppError> { + let email = decode_access_token(&payload.token) + .map_err(|_| AppError::BadRequestError("Invalid or missing token".into()))? + .claims + .sub; + let user = self + .user_repo + .find_by_email(email) + .await + .map_err(|e| AppError::BadRequestError(e.to_string()))?; + let hashed = hash_password(&payload.password).map_err(|e| { + error!("Failed to hash new password: {}", e); + AppError::InternalServerError("Failed to hash password".into()) + })?; + self + .user_repo + .update(UserEntity { + password: hashed, + ..user + }) + .await?; + Ok(()) + } } diff --git a/imphnen-iam/src/auth/domain/mod.rs b/imphnen-iam/src/auth/domain/mod.rs index ae9d586..c520630 100644 --- a/imphnen-iam/src/auth/domain/mod.rs +++ b/imphnen-iam/src/auth/domain/mod.rs @@ -1,19 +1,24 @@ +pub mod types; + use async_trait::async_trait; use imphnen_utils::AppError; -use crate::auth::infrastructure::http::dto::{ - AuthLoginRequestDto, AuthLoginResponsetDto, AuthRegisterRequestDto, - AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, AuthNewPasswordRequestDto, - AuthRefreshTokenRequestDto, TokenDto, +use types::{ + AuthTokens, LoginInput, LoginOutput, NewPasswordInput, RefreshTokenInput, + RegisterInput, ResendOtpInput, VerifyEmailInput, }; #[async_trait] pub trait AuthService: Send + Sync { - async fn login(&self, payload: AuthLoginRequestDto) -> Result; - async fn login_mentor(&self, payload: AuthLoginRequestDto) -> Result; - async fn register(&self, payload: AuthRegisterRequestDto) -> Result<(), AppError>; - async fn resend_otp(&self, payload: AuthResendOtpRequestDto) -> Result<(), AppError>; - async fn refresh_token(&self, payload: AuthRefreshTokenRequestDto) -> Result; - async fn forgot_password(&self, payload: AuthResendOtpRequestDto) -> Result<(), AppError>; - async fn verify_email(&self, payload: AuthVerifyEmailRequestDto) -> Result<(), AppError>; - async fn new_password(&self, payload: AuthNewPasswordRequestDto) -> Result<(), AppError>; + async fn login(&self, payload: LoginInput) -> Result; + async fn login_mentor(&self, payload: LoginInput) + -> Result; + async fn register(&self, payload: RegisterInput) -> Result<(), AppError>; + async fn resend_otp(&self, payload: ResendOtpInput) -> Result<(), AppError>; + async fn refresh_token( + &self, + payload: RefreshTokenInput, + ) -> Result; + async fn forgot_password(&self, payload: ResendOtpInput) -> Result<(), AppError>; + async fn verify_email(&self, payload: VerifyEmailInput) -> Result<(), AppError>; + async fn new_password(&self, payload: NewPasswordInput) -> Result<(), AppError>; } diff --git a/imphnen-iam/src/auth/domain/types.rs b/imphnen-iam/src/auth/domain/types.rs new file mode 100644 index 0000000..f77daa8 --- /dev/null +++ b/imphnen-iam/src/auth/domain/types.rs @@ -0,0 +1,63 @@ +use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; + +#[derive(Clone, Debug)] +pub struct LoginInput { + pub email: String, + pub password: String, +} + +#[derive(Clone, Debug)] +pub struct RegisterInput { + pub email: String, + pub password: String, + pub fullname: String, + pub phone_number: Option, +} + +#[derive(Clone, Debug)] +pub struct VerifyEmailInput { + pub email: String, + pub otp: u32, +} + +#[derive(Clone, Debug)] +pub struct ResendOtpInput { + pub email: String, +} + +#[derive(Clone, Debug)] +pub struct RefreshTokenInput { + pub refresh_token: String, +} + +#[derive(Clone, Debug)] +pub struct NewPasswordInput { + pub token: String, + pub password: String, +} + +#[derive(Clone, Debug, Default)] +pub struct AuthTokens { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Clone, Debug, Default)] +pub struct AuthUserDetail { + pub id: String, + pub email: String, + pub fullname: String, + pub legal_name: Option, + pub avatar: Option, + pub is_active: bool, + pub role: RolesDetailQueryDto, + pub profile_extension: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug)] +pub struct LoginOutput { + pub token: AuthTokens, + pub user: AuthUserDetail, +} diff --git a/imphnen-iam/src/auth/infrastructure/http/dto.rs b/imphnen-iam/src/auth/infrastructure/http/dto.rs index ac60c9d..b5c15af 100644 --- a/imphnen-iam/src/auth/infrastructure/http/dto.rs +++ b/imphnen-iam/src/auth/infrastructure/http/dto.rs @@ -6,95 +6,95 @@ use zod_rs::prelude::*; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthLoginRequestDto { - #[zod(email, min_length(1))] - pub email: String, - #[zod(min_length(1))] - pub password: String, + #[zod(email, min_length(1))] + pub email: String, + #[zod(min_length(1))] + pub password: String, } impl ZodValidate for AuthLoginRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct TokenDto { - pub access_token: String, - pub refresh_token: String, + pub access_token: String, + pub refresh_token: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct AuthLoginResponsetDto { - pub token: TokenDto, - pub user: UsersDetailItemDto, + pub token: TokenDto, + pub user: UsersDetailItemDto, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthRegisterRequestDto { - #[zod(email, min_length(1))] - pub email: String, - #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] - pub password: String, - #[zod(min_length(2))] - pub fullname: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone_number: Option, + #[zod(email, min_length(1))] + pub email: String, + #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] + pub password: String, + #[zod(min_length(2))] + pub fullname: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, } impl ZodValidate for AuthRegisterRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthVerifyEmailRequestDto { - #[zod(email, min_length(1))] - pub email: String, - pub otp: u32, + #[zod(email, min_length(1))] + pub email: String, + pub otp: u32, } impl ZodValidate for AuthVerifyEmailRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthResendOtpRequestDto { - #[zod(email, min_length(1))] - pub email: String, + #[zod(email, min_length(1))] + pub email: String, } impl ZodValidate for AuthResendOtpRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthRefreshTokenRequestDto { - #[zod(min_length(1))] - pub refresh_token: String, + #[zod(min_length(1))] + pub refresh_token: String, } impl ZodValidate for AuthRefreshTokenRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct AuthNewPasswordRequestDto { - #[zod(min_length(1))] - pub token: String, - #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] - pub password: String, + #[zod(min_length(1))] + pub token: String, + #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] + pub password: String, } impl ZodValidate for AuthNewPasswordRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } diff --git a/imphnen-iam/src/auth/infrastructure/http/handlers.rs b/imphnen-iam/src/auth/infrastructure/http/handlers.rs index 74e47a5..e72b1d4 100644 --- a/imphnen-iam/src/auth/infrastructure/http/handlers.rs +++ b/imphnen-iam/src/auth/infrastructure/http/handlers.rs @@ -1,12 +1,43 @@ -use std::sync::Arc; -use axum::{Extension, response::IntoResponse}; -use imphnen_libs::ValidatedJson; -use imphnen_utils::{ApiSuccess, ApiMessage, AppError}; use super::dto::{ - AuthLoginRequestDto, AuthRegisterRequestDto, AuthResendOtpRequestDto, - AuthVerifyEmailRequestDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, + AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, + AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthResendOtpRequestDto, + AuthVerifyEmailRequestDto, TokenDto, }; use crate::auth::domain::AuthService; +use crate::auth::domain::types::{ + LoginInput, NewPasswordInput, RefreshTokenInput, RegisterInput, ResendOtpInput, + VerifyEmailInput, +}; +use crate::users::infrastructure::http::dto::UsersDetailItemDto; +use axum::{Extension, response::IntoResponse}; +use imphnen_entities::RolesDetailItemDto; +use imphnen_libs::ValidatedJson; +use imphnen_utils::{ApiMessage, ApiSuccess, AppError}; +use std::sync::Arc; + +fn login_resp_to_dto( + output: crate::auth::domain::types::LoginOutput, +) -> AuthLoginResponsetDto { + let u = output.user; + AuthLoginResponsetDto { + token: TokenDto { + access_token: output.token.access_token, + refresh_token: output.token.refresh_token, + }, + user: UsersDetailItemDto { + id: u.id, + role: RolesDetailItemDto::from(&u.role), + fullname: u.fullname, + legal_name: u.legal_name, + email: u.email, + avatar: u.avatar, + is_active: u.is_active, + profile_extension: u.profile_extension, + created_at: u.created_at, + updated_at: u.updated_at, + }, + } +} #[utoipa::path( post, @@ -19,11 +50,15 @@ use crate::auth::domain::AuthService; tag = "Authentication" )] pub async fn post_login( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - let resp = service.login(payload).await?; - Ok(ApiSuccess(resp)) + let input = LoginInput { + email: payload.email, + password: payload.password, + }; + let resp = service.login(input).await?; + Ok(ApiSuccess(login_resp_to_dto(resp))) } #[utoipa::path( @@ -38,11 +73,15 @@ pub async fn post_login( tag = "Authentication" )] pub async fn post_login_mentor( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - let resp = service.login_mentor(payload).await?; - Ok(ApiSuccess(resp)) + let input = LoginInput { + email: payload.email, + password: payload.password, + }; + let resp = service.login_mentor(input).await?; + Ok(ApiSuccess(login_resp_to_dto(resp))) } #[utoipa::path( @@ -56,11 +95,17 @@ pub async fn post_login_mentor( tag = "Authentication" )] pub async fn post_register( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service.register(payload).await?; - Ok(ApiMessage::created("Registration successful")) + let input = RegisterInput { + email: payload.email, + password: payload.password, + fullname: payload.fullname, + phone_number: payload.phone_number, + }; + service.register(input).await?; + Ok(ApiMessage::created("Registration successful")) } #[utoipa::path( @@ -74,11 +119,15 @@ pub async fn post_register( tag = "Authentication" )] pub async fn post_verify_email( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service.verify_email(payload).await?; - Ok(ApiMessage::ok("Email verified successfully")) + let input = VerifyEmailInput { + email: payload.email, + otp: payload.otp, + }; + service.verify_email(input).await?; + Ok(ApiMessage::ok("Email verified successfully")) } #[utoipa::path( @@ -92,11 +141,14 @@ pub async fn post_verify_email( tag = "Authentication" )] pub async fn post_resend_otp( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service.resend_otp(payload).await?; - Ok(ApiMessage::ok("OTP sent")) + let input = ResendOtpInput { + email: payload.email, + }; + service.resend_otp(input).await?; + Ok(ApiMessage::ok("OTP sent")) } #[utoipa::path( @@ -109,13 +161,16 @@ pub async fn post_resend_otp( tag = "Authentication" )] pub async fn post_forgot_password( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service.forgot_password(payload).await?; - Ok(ApiMessage::ok( - "If your email is registered, you will receive a password reset link.", - )) + let input = ResendOtpInput { + email: payload.email, + }; + service.forgot_password(input).await?; + Ok(ApiMessage::ok( + "If your email is registered, you will receive a password reset link.", + )) } #[utoipa::path( @@ -129,11 +184,15 @@ pub async fn post_forgot_password( tag = "Authentication" )] pub async fn post_new_password( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service.new_password(payload).await?; - Ok(ApiMessage::ok("Password updated successfully")) + let input = NewPasswordInput { + token: payload.token, + password: payload.password, + }; + service.new_password(input).await?; + Ok(ApiMessage::ok("Password updated successfully")) } #[utoipa::path( @@ -147,10 +206,15 @@ pub async fn post_new_password( tag = "Authentication" )] pub async fn post_refresh_token( - Extension(service): Extension>, - ValidatedJson(payload): ValidatedJson, + Extension(service): Extension>, + ValidatedJson(payload): ValidatedJson, ) -> Result { - let resp = service.refresh_token(payload).await?; - Ok(ApiSuccess(resp)) + let input = RefreshTokenInput { + refresh_token: payload.refresh_token, + }; + let tokens = service.refresh_token(input).await?; + Ok(ApiSuccess(TokenDto { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + })) } - diff --git a/imphnen-iam/src/auth/infrastructure/http/routes.rs b/imphnen-iam/src/auth/infrastructure/http/routes.rs index 72de49d..5585018 100644 --- a/imphnen-iam/src/auth/infrastructure/http/routes.rs +++ b/imphnen-iam/src/auth/infrastructure/http/routes.rs @@ -1,29 +1,34 @@ -use std::sync::Arc; -use axum::{Router, routing::post, Extension}; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; -use crate::auth::domain::AuthService; -use crate::auth::application::AuthServiceImpl; -use crate::users::infrastructure::persistence::PostgresUserRepository; -use crate::roles::infrastructure::persistence::PostgresRoleRepository; use super::handlers::{ - post_login, post_login_mentor, post_register, post_verify_email, - post_resend_otp, post_forgot_password, post_new_password, post_refresh_token, + post_forgot_password, post_login, post_login_mentor, post_new_password, + post_refresh_token, post_register, post_resend_otp, post_verify_email, }; +use crate::auth::application::AuthServiceImpl; +use crate::auth::domain::AuthService; +use crate::roles::infrastructure::persistence::PostgresRoleRepository; +use crate::users::infrastructure::persistence::PostgresUserRepository; +use axum::{Extension, Router, routing::post}; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; pub fn auth_public_routes(_db: DatabaseConnection, state: Arc) -> Router { - let user_repo = Arc::new(PostgresUserRepository::new(state.postgres_connection.conn.clone())); - let role_repo = Arc::new(PostgresRoleRepository::new(state.postgres_connection.conn.clone())); - let auth_service: Arc = Arc::new(AuthServiceImpl::new(user_repo, role_repo)); - Router::new() - .route("/auth/login", post(post_login)) - .route("/auth/login-mentor", post(post_login_mentor)) - .route("/auth/register", post(post_register)) - .route("/auth/verify-email", post(post_verify_email)) - .route("/auth/send-otp", post(post_resend_otp)) - .route("/auth/forgot", post(post_forgot_password)) - .route("/auth/new-password", post(post_new_password)) - .route("/auth/refresh", post(post_refresh_token)) - .layer(Extension(auth_service)) - .layer(Extension((*state).clone())) + let user_repo = Arc::new(PostgresUserRepository::new( + state.postgres_connection.conn.clone(), + )); + let role_repo = Arc::new(PostgresRoleRepository::new( + state.postgres_connection.conn.clone(), + )); + let auth_service: Arc = + Arc::new(AuthServiceImpl::new(user_repo, role_repo)); + Router::new() + .route("/auth/login", post(post_login)) + .route("/auth/login-mentor", post(post_login_mentor)) + .route("/auth/register", post(post_register)) + .route("/auth/verify-email", post(post_verify_email)) + .route("/auth/send-otp", post(post_resend_otp)) + .route("/auth/forgot", post(post_forgot_password)) + .route("/auth/new-password", post(post_new_password)) + .route("/auth/refresh", post(post_refresh_token)) + .layer(Extension(auth_service)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-iam/src/auth/infrastructure/persistence/mod.rs b/imphnen-iam/src/auth/infrastructure/persistence/mod.rs index 8c26aaf..8b13789 100644 --- a/imphnen-iam/src/auth/infrastructure/persistence/mod.rs +++ b/imphnen-iam/src/auth/infrastructure/persistence/mod.rs @@ -1 +1 @@ -// Auth persistence - uses v1 AuthRepository directly + diff --git a/imphnen-iam/src/auth/mod.rs b/imphnen-iam/src/auth/mod.rs index 4e5d82a..c6d90ca 100644 --- a/imphnen-iam/src/auth/mod.rs +++ b/imphnen-iam/src/auth/mod.rs @@ -1,7 +1,7 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; -pub use domain::AuthService; pub use application::AuthServiceImpl; +pub use domain::AuthService; pub use infrastructure::http::routes::auth_public_routes; diff --git a/imphnen-iam/src/lib.rs b/imphnen-iam/src/lib.rs index 179b8a4..81db101 100644 --- a/imphnen-iam/src/lib.rs +++ b/imphnen-iam/src/lib.rs @@ -1,60 +1,39 @@ -pub mod permission_macros; -pub mod permissions_guard; - -pub mod permissions; -pub mod roles; -pub mod users; -pub mod auth; - -pub use permissions::{permissions_public_routes, permissions_protected_routes}; -pub use roles::{roles_public_routes, roles_protected_routes}; -pub use users::{users_public_routes, users_protected_routes}; -pub use auth::auth_public_routes; - -pub use imphnen_entities::{ - MessageResponseDto, - ResponseSuccessDto, - ResponseListSuccessDto, - UsersDetailQueryDto, - PermissionsEnum, - PermissionsItemDto, - PermissionsQueryDto, -}; - -// Explicitly export only the imphnen_libs types actually used in IAM -pub use imphnen_libs::{ - AppState, - decode_access_token, - decode_refresh_token, - encode_access_token, - encode_refresh_token, - encode_reset_password_token, - hash_password, - send_email, - verify_password, - Env, - UserLookupService, - AuthRepositoryTrait, - jsonwebtoken::Claims, -}; - -pub use imphnen_utils::{ - response_format::ApiSuccess, - response_format::ApiCreated, - response_format::ApiPaginated, - response_format::ApiMessage, - csrf_token::generate_oauth_csrf_token, - csrf_token::validate_oauth_csrf_token, - csrf_token::validate_csrf_token, - extract_email::extract_email_async, - generate_otp::OtpManager, - errors::AppError, - generate_date::get_iso_date, -}; -pub use paginator_axum::PaginationQuery; -pub use paginator_rs::PaginationParams; -pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; -pub use imphnen_libs::AppStatePostgresExt; - -pub use permissions_guard::permissions_guard; - +pub mod permission_macros; +pub mod permissions_guard; + +pub mod auth; +pub mod permissions; +pub mod roles; +pub mod users; + +pub use auth::auth_public_routes; +pub use permissions::{permissions_protected_routes, permissions_public_routes}; +pub use roles::{roles_protected_routes, roles_public_routes}; +pub use users::{users_protected_routes, users_public_routes}; + +pub use imphnen_entities::{ + MessageResponseDto, PermissionsEnum, PermissionsItemDto, PermissionsQueryDto, + ResponseListSuccessDto, ResponseSuccessDto, UsersDetailQueryDto, +}; + +pub use imphnen_libs::{ + AppState, AuthRepositoryTrait, Env, UserLookupService, decode_access_token, + decode_refresh_token, encode_access_token, encode_refresh_token, + encode_reset_password_token, hash_password, jsonwebtoken::Claims, verify_password, +}; + +pub use imphnen_email::send_email; +pub use imphnen_libs::AppStatePostgresExt; +pub use imphnen_utils::{ + csrf_token::generate_oauth_csrf_token, csrf_token::validate_csrf_token, + csrf_token::validate_oauth_csrf_token, errors::AppError, + extract_email::extract_email_async, generate_date::get_iso_date, + generate_otp::OtpManager, response_format::ApiCreated, + response_format::ApiMessage, response_format::ApiPaginated, + response_format::ApiSuccess, +}; +pub use paginator_axum::PaginationQuery; +pub use paginator_rs::PaginationParams; +pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; + +pub use permissions_guard::permissions_guard; diff --git a/imphnen-iam/src/permission_macros.rs b/imphnen-iam/src/permission_macros.rs index e6e2dc5..c78dfd5 100644 --- a/imphnen-iam/src/permission_macros.rs +++ b/imphnen-iam/src/permission_macros.rs @@ -1,28 +1,25 @@ -use axum::{ - extract::Extension, - http::HeaderMap, -}; -use imphnen_entities::PermissionsEnum; use crate::AppState; use crate::permissions_guard; +use axum::{extract::Extension, http::HeaderMap}; +use imphnen_entities::PermissionsEnum; use imphnen_libs::jsonwebtoken::Claims; use imphnen_utils::AppError; pub type PermissionGuardResult = Result<(T, AppState), AppError>; pub async fn check_permissions( - headers: HeaderMap, - state: Extension, - required_permissions: Vec, + headers: HeaderMap, + state: Extension, + required_permissions: Vec, ) -> PermissionGuardResult { - permissions_guard(headers, state, required_permissions).await + permissions_guard(headers, state, required_permissions).await } pub async fn check_authenticated( - headers: HeaderMap, - state: Extension, + headers: HeaderMap, + state: Extension, ) -> PermissionGuardResult { - check_permissions(headers, state, vec![]).await + check_permissions(headers, state, vec![]).await } #[macro_export] @@ -43,11 +40,14 @@ macro_rules! require_permissions { #[macro_export] macro_rules! require_auth { - ($headers:expr, $state:expr, $body:block) => { - { - let state_clone = $state.clone(); - $crate::permissions_guard($headers, axum::extract::Extension(state_clone), vec![]).await?; - $body - } - }; + ($headers:expr, $state:expr, $body:block) => {{ + let state_clone = $state.clone(); + $crate::permissions_guard( + $headers, + axum::extract::Extension(state_clone), + vec![], + ) + .await?; + $body + }}; } diff --git a/imphnen-iam/src/permissions/application/permission_service.rs b/imphnen-iam/src/permissions/application/permission_service.rs index ab979cb..e59319c 100644 --- a/imphnen-iam/src/permissions/application/permission_service.rs +++ b/imphnen-iam/src/permissions/application/permission_service.rs @@ -1,53 +1,61 @@ -use std::sync::Arc; +use crate::permissions::domain::{ + PermissionEntity, PermissionRepository, PermissionService, +}; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use crate::permissions::domain::{PermissionEntity, PermissionRepository, PermissionService}; pub struct PermissionServiceImpl { - repo: Arc, + repo: Arc, } impl PermissionServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl PermissionService for PermissionServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: String) -> Result { - self.repo.find_by_id(id).await - } + async fn get(&self, id: String) -> Result { + self.repo.find_by_id(id).await + } - async fn create(&self, name: String) -> Result { - // Check for name conflict - match self.repo.find_by_name(name.clone()).await { - Ok(_) => return Err(AppError::ConflictError("Permission name already exists".into())), - Err(AppError::NotFoundError(_)) => {} - Err(e) => return Err(e), - } - let entity = PermissionEntity { - id: Uuid::new_v4(), - name, - is_deleted: false, - created_at: None, - updated_at: None, - }; - self.repo.create(entity).await - } + async fn create(&self, name: String) -> Result { + match self.repo.find_by_name(name.clone()).await { + Ok(_) => { + return Err(AppError::ConflictError( + "Permission name already exists".into(), + )); + } + Err(AppError::NotFoundError(_)) => {} + Err(e) => return Err(e), + } + let entity = PermissionEntity { + id: Uuid::new_v4(), + name, + is_deleted: false, + created_at: None, + updated_at: None, + }; + self.repo.create(entity).await + } - async fn update(&self, entity: PermissionEntity) -> Result { - self.repo.update(entity).await - } + async fn update(&self, entity: PermissionEntity) -> Result { + self.repo.update(entity).await + } - async fn delete(&self, id: String) -> Result { - self.repo.delete(id).await - } + async fn delete(&self, id: String) -> Result { + self.repo.delete(id).await + } } diff --git a/imphnen-iam/src/permissions/domain/permission.rs b/imphnen-iam/src/permissions/domain/permission.rs index 616518c..51c3901 100644 --- a/imphnen-iam/src/permissions/domain/permission.rs +++ b/imphnen-iam/src/permissions/domain/permission.rs @@ -2,9 +2,9 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub struct PermissionEntity { - pub id: Uuid, - pub name: String, - pub is_deleted: bool, - pub created_at: Option, - pub updated_at: Option, + pub id: Uuid, + pub name: String, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, } diff --git a/imphnen-iam/src/permissions/domain/repository.rs b/imphnen-iam/src/permissions/domain/repository.rs index 43b1b07..02439cb 100644 --- a/imphnen-iam/src/permissions/domain/repository.rs +++ b/imphnen-iam/src/permissions/domain/repository.rs @@ -1,15 +1,18 @@ +use super::permission::PermissionEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::permission::PermissionEntity; #[async_trait] pub trait PermissionRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: String) -> Result; - async fn find_by_name(&self, name: String) -> Result; - async fn create(&self, entity: PermissionEntity) -> Result; - async fn update(&self, entity: PermissionEntity) -> Result; - async fn delete(&self, id: String) -> Result; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: String) -> Result; + async fn find_by_name(&self, name: String) -> Result; + async fn create(&self, entity: PermissionEntity) -> Result; + async fn update(&self, entity: PermissionEntity) -> Result; + async fn delete(&self, id: String) -> Result; } diff --git a/imphnen-iam/src/permissions/domain/service.rs b/imphnen-iam/src/permissions/domain/service.rs index 6e5b67e..0cfdad1 100644 --- a/imphnen-iam/src/permissions/domain/service.rs +++ b/imphnen-iam/src/permissions/domain/service.rs @@ -1,14 +1,17 @@ +use super::permission::PermissionEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::permission::PermissionEntity; #[async_trait] pub trait PermissionService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: String) -> Result; - async fn create(&self, name: String) -> Result; - async fn update(&self, entity: PermissionEntity) -> Result; - async fn delete(&self, id: String) -> Result; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: String) -> Result; + async fn create(&self, name: String) -> Result; + async fn update(&self, entity: PermissionEntity) -> Result; + async fn delete(&self, id: String) -> Result; } diff --git a/imphnen-iam/src/permissions/infrastructure/http/dto.rs b/imphnen-iam/src/permissions/infrastructure/http/dto.rs index bfa781f..7b77907 100644 --- a/imphnen-iam/src/permissions/infrastructure/http/dto.rs +++ b/imphnen-iam/src/permissions/infrastructure/http/dto.rs @@ -1,59 +1,63 @@ +use crate::permissions::domain::PermissionEntity; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use zod_rs::prelude::*; use uuid::Uuid; -use crate::permissions::domain::PermissionEntity; +use zod_rs::prelude::*; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct PermissionsCreateRequestDto { - #[zod(min_length(1))] - pub name: String, + #[zod(min_length(1))] + pub name: String, } impl ZodValidate for PermissionsCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct PermissionsUpdateRequestDto { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, } impl ZodValidate for PermissionsUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct PermissionsItemDto { - pub id: String, - pub name: String, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub name: String, + pub created_at: Option, + pub updated_at: Option, } impl From for PermissionsItemDto { - fn from(e: PermissionEntity) -> Self { - Self { - id: e.id.to_string(), - name: e.name, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + fn from(e: PermissionEntity) -> Self { + Self { + id: e.id.to_string(), + name: e.name, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } impl PermissionsUpdateRequestDto { - pub fn apply_to(self, mut entity: PermissionEntity, id: String) -> PermissionEntity { - entity.id = Uuid::parse_str(&id).unwrap_or(entity.id); - if let Some(name) = self.name { - entity.name = name; - } - entity - } + pub fn apply_to( + self, + mut entity: PermissionEntity, + id: String, + ) -> PermissionEntity { + entity.id = Uuid::parse_str(&id).unwrap_or(entity.id); + if let Some(name) = self.name { + entity.name = name; + } + entity + } } diff --git a/imphnen-iam/src/permissions/infrastructure/http/handlers.rs b/imphnen-iam/src/permissions/infrastructure/http/handlers.rs index c69c94e..a9c6805 100644 --- a/imphnen-iam/src/permissions/infrastructure/http/handlers.rs +++ b/imphnen-iam/src/permissions/infrastructure/http/handlers.rs @@ -1,19 +1,20 @@ -use crate::require_permissions; -use std::sync::Arc; -use axum::{ - Extension, Json, - extract::Path, - http::HeaderMap, - response::IntoResponse, +use super::dto::{ + PermissionsCreateRequestDto, PermissionsItemDto, PermissionsUpdateRequestDto, }; +use crate::permissions::domain::PermissionService; +use crate::require_permissions; +use axum::{ + Extension, Json, extract::Path, http::HeaderMap, response::IntoResponse, +}; +use imphnen_entities::{ + PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, +}; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess}; use paginator_axum::PaginationQuery; use paginator_utils::PaginatorResponse; -use imphnen_libs::AppState; -use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage}; -use imphnen_entities::{ResponseSuccessDto, ResponseListSuccessDto, PermissionsEnum}; -use imphnen_utils::AppError; -use crate::permissions::domain::PermissionService; -use super::dto::{PermissionsCreateRequestDto, PermissionsItemDto, PermissionsUpdateRequestDto}; +use std::sync::Arc; #[utoipa::path( get, @@ -34,19 +35,23 @@ use super::dto::{PermissionsCreateRequestDto, PermissionsItemDto, PermissionsUpd tag = "Permissions" )] pub async fn get_permission_list( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadListPermissions], { - let result = service.list(params).await?; - let mapped = PaginatorResponse { - data: result.data.into_iter().map(PermissionsItemDto::from).collect::>(), - meta: result.meta, - }; - Ok(ApiPaginated(mapped)) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadListPermissions], { + let result = service.list(params).await?; + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(PermissionsItemDto::from) + .collect::>(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) + }) } #[utoipa::path( @@ -60,15 +65,15 @@ pub async fn get_permission_list( tag = "Permissions" )] pub async fn get_permission_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailPermissions], { - let perm = service.get(id).await?; - Ok(ApiSuccess(PermissionsItemDto::from(perm))) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadDetailPermissions], { + let perm = service.get(id).await?; + Ok(ApiSuccess(PermissionsItemDto::from(perm))) + }) } #[utoipa::path( @@ -82,15 +87,15 @@ pub async fn get_permission_by_id( tag = "Permissions" )] pub async fn post_create_permission( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Json(payload): Json, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Json(payload): Json, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::CreatePermissions], { - let msg = service.create(payload.name).await?; - Ok(ApiMessage::created(&msg)) - }) + require_permissions!(headers, state, [PermissionsEnum::CreatePermissions], { + let msg = service.create(payload.name).await?; + Ok(ApiMessage::created(&msg)) + }) } #[utoipa::path( @@ -105,19 +110,21 @@ pub async fn post_create_permission( tag = "Permissions" )] pub async fn put_update_permission( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - Json(payload): Json, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(payload): Json, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::UpdatePermissions], { - let current = service.get(id.clone()).await - .map_err(|_| AppError::NotFoundError("Permission not found".to_string()))?; - let updated = payload.apply_to(current, id); - let msg = service.update(updated).await?; - Ok(ApiMessage::ok(&msg)) - }) + require_permissions!(headers, state, [PermissionsEnum::UpdatePermissions], { + let current = service + .get(id.clone()) + .await + .map_err(|_| AppError::NotFoundError("Permission not found".to_string()))?; + let updated = payload.apply_to(current, id); + let msg = service.update(updated).await?; + Ok(ApiMessage::ok(&msg)) + }) } #[utoipa::path( @@ -131,13 +138,13 @@ pub async fn put_update_permission( tag = "Permissions" )] pub async fn delete_permission( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::DeletePermissions], { - let msg = service.delete(id).await?; - Ok(ApiMessage::ok(&msg)) - }) + require_permissions!(headers, state, [PermissionsEnum::DeletePermissions], { + let msg = service.delete(id).await?; + Ok(ApiMessage::ok(&msg)) + }) } diff --git a/imphnen-iam/src/permissions/infrastructure/http/mod.rs b/imphnen-iam/src/permissions/infrastructure/http/mod.rs index 4f2fc35..f2ef405 100644 --- a/imphnen-iam/src/permissions/infrastructure/http/mod.rs +++ b/imphnen-iam/src/permissions/infrastructure/http/mod.rs @@ -2,4 +2,4 @@ pub mod dto; pub mod handlers; pub mod routes; -pub use routes::{permissions_public_routes, permissions_protected_routes}; +pub use routes::{permissions_protected_routes, permissions_public_routes}; diff --git a/imphnen-iam/src/permissions/infrastructure/http/routes.rs b/imphnen-iam/src/permissions/infrastructure/http/routes.rs index fa47b62..9ce6c83 100644 --- a/imphnen-iam/src/permissions/infrastructure/http/routes.rs +++ b/imphnen-iam/src/permissions/infrastructure/http/routes.rs @@ -1,32 +1,38 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, post, put}, Extension}; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; +use super::handlers::{ + delete_permission, get_permission_by_id, get_permission_list, + post_create_permission, put_update_permission, +}; use crate::permissions::application::PermissionServiceImpl; use crate::permissions::domain::PermissionService; use crate::permissions::infrastructure::persistence::PostgresPermissionRepository; -use super::handlers::{ - get_permission_list, get_permission_by_id, post_create_permission, - put_update_permission, delete_permission, +use axum::{ + Extension, Router, + routing::{delete, get, post, put}, }; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresPermissionRepository::new(db)); - Arc::new(PermissionServiceImpl::new(repo)) + let repo = Arc::new(PostgresPermissionRepository::new(db)); + Arc::new(PermissionServiceImpl::new(repo)) } pub fn permissions_public_routes(_db: DatabaseConnection) -> Router { - Router::new() + Router::new() } -pub fn permissions_protected_routes(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db); - Router::new() - .route("/permissions", get(get_permission_list)) - .route("/permissions/detail/{id}", get(get_permission_by_id)) - .route("/permissions/create", post(post_create_permission)) - .route("/permissions/update/{id}", put(put_update_permission)) - .route("/permissions/delete/{id}", delete(delete_permission)) - .layer(Extension(service)) - .layer(Extension((*state).clone())) +pub fn permissions_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/permissions", get(get_permission_list)) + .route("/permissions/detail/{id}", get(get_permission_by_id)) + .route("/permissions/create", post(post_create_permission)) + .route("/permissions/update/{id}", put(put_update_permission)) + .route("/permissions/delete/{id}", delete(delete_permission)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-iam/src/permissions/infrastructure/persistence/postgres_permission_repository.rs b/imphnen-iam/src/permissions/infrastructure/persistence/postgres_permission_repository.rs index a8592f3..bd5f5e5 100644 --- a/imphnen-iam/src/permissions/infrastructure/persistence/postgres_permission_repository.rs +++ b/imphnen-iam/src/permissions/infrastructure/persistence/postgres_permission_repository.rs @@ -1,158 +1,175 @@ -use std::sync::Arc; +use crate::permissions::domain::{PermissionEntity, PermissionRepository}; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, QuerySelect, PaginatorTrait}; +use imphnen_entities::seaorm::auth::permissions::{ + ActiveModel as PermissionsActiveModel, Column as PermissionsColumn, + Entity as PermissionsEntity, Model as PermissionsModel, +}; +use imphnen_utils::AppError; use paginator_rs::{PaginationParams, SortDirection}; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, Order, PaginatorTrait, QueryOrder, QuerySelect}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::auth::permissions::{ - Entity as PermissionsEntity, Column as PermissionsColumn, - ActiveModel as PermissionsActiveModel, Model as PermissionsModel, -}; -use crate::permissions::domain::{PermissionEntity, PermissionRepository}; fn to_entity(model: PermissionsModel) -> PermissionEntity { - PermissionEntity { - id: model.id, - name: model.name, - is_deleted: model.is_deleted, - created_at: Some(model.created_at.to_rfc3339()), - updated_at: Some(model.updated_at.to_rfc3339()), - } + PermissionEntity { + id: model.id, + name: model.name, + is_deleted: model.is_deleted, + created_at: Some(model.created_at.to_rfc3339()), + updated_at: Some(model.updated_at.to_rfc3339()), + } } pub struct PostgresPermissionRepository { - db: Arc, + db: Arc, } impl PostgresPermissionRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl PermissionRepository for PostgresPermissionRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); - let mut query = PermissionsEntity::find() - .filter(PermissionsColumn::IsDeleted.eq(false)); + let mut query = + PermissionsEntity::find().filter(PermissionsColumn::IsDeleted.eq(false)); - if let Some(ref search) = params.search { - query = query.filter(PermissionsColumn::Name.contains(&search.query)); - } + if let Some(ref search) = params.search { + query = query.filter(PermissionsColumn::Name.contains(&search.query)); + } - let order_column = match params.sort_by.as_deref() { - Some("name") => PermissionsColumn::Name, - _ => PermissionsColumn::CreatedAt, - }; - query = match params.sort_direction { - Some(SortDirection::Desc) => query.order_by(order_column, Order::Desc), - _ => query.order_by(order_column, Order::Asc), - }; + let order_column = match params.sort_by.as_deref() { + Some("name") => PermissionsColumn::Name, + _ => PermissionsColumn::CreatedAt, + }; + query = match params.sort_direction { + Some(SortDirection::Desc) => query.order_by(order_column, Order::Desc), + _ => query.order_by(order_column, Order::Asc), + }; - let total_count = query.clone().count(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let offset = ((page - 1) * per_page) as u64; - let permissions = query.offset(offset).limit(per_page as u64).all(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let total_count = query + .clone() + .count(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let offset = ((page - 1) * per_page) as u64; + let permissions = query + .offset(offset) + .limit(per_page as u64) + .all(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let data = permissions.into_iter().map(to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total_count as u32); - Ok(PaginatorResponse { data, meta }) - } + let data = permissions.into_iter().map(to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total_count as u32); + Ok(PaginatorResponse { data, meta }) + } - async fn find_by_id(&self, id: String) -> Result { - let perm_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid permission ID".into()))?; + async fn find_by_id(&self, id: String) -> Result { + let perm_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid permission ID".into()))?; - let model = PermissionsEntity::find_by_id(perm_id) - .filter(PermissionsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; + let model = PermissionsEntity::find_by_id(perm_id) + .filter(PermissionsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; - Ok(to_entity(model)) - } + Ok(to_entity(model)) + } - async fn find_by_name(&self, name: String) -> Result { - let model = PermissionsEntity::find() - .filter(PermissionsColumn::Name.eq(&name)) - .filter(PermissionsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; + async fn find_by_name(&self, name: String) -> Result { + let model = PermissionsEntity::find() + .filter(PermissionsColumn::Name.eq(&name)) + .filter(PermissionsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; - Ok(to_entity(model)) - } + Ok(to_entity(model)) + } - async fn create(&self, entity: PermissionEntity) -> Result { - let active_model = PermissionsActiveModel { - id: ActiveValue::Set(entity.id), - name: ActiveValue::Set(entity.name), - is_deleted: ActiveValue::Set(false), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - deleted_at: ActiveValue::NotSet, - }; + async fn create(&self, entity: PermissionEntity) -> Result { + let active_model = PermissionsActiveModel { + id: ActiveValue::Set(entity.id), + name: ActiveValue::Set(entity.name), + is_deleted: ActiveValue::Set(false), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + deleted_at: ActiveValue::NotSet, + }; - let result = PermissionsEntity::insert(active_model) - .exec(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let result = PermissionsEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(format!("Success create permission with id: {}", result.last_insert_id)) - } + Ok(format!( + "Success create permission with id: {}", + result.last_insert_id + )) + } - async fn update(&self, entity: PermissionEntity) -> Result { - let existing = PermissionsEntity::find_by_id(entity.id) - .filter(PermissionsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; + async fn update(&self, entity: PermissionEntity) -> Result { + let existing = PermissionsEntity::find_by_id(entity.id) + .filter(PermissionsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; - let active_model = PermissionsActiveModel { - id: ActiveValue::Set(entity.id), - name: ActiveValue::Set(entity.name), - is_deleted: ActiveValue::Set(entity.is_deleted), - created_at: ActiveValue::Unchanged(existing.created_at), - updated_at: ActiveValue::Set(chrono::Utc::now()), - deleted_at: ActiveValue::NotSet, - }; + let active_model = PermissionsActiveModel { + id: ActiveValue::Set(entity.id), + name: ActiveValue::Set(entity.name), + is_deleted: ActiveValue::Set(entity.is_deleted), + created_at: ActiveValue::Unchanged(existing.created_at), + updated_at: ActiveValue::Set(chrono::Utc::now()), + deleted_at: ActiveValue::NotSet, + }; - let result = active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let result = active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(format!("Success update permission with id: {}", result.id)) - } + Ok(format!("Success update permission with id: {}", result.id)) + } - async fn delete(&self, id: String) -> Result { - let perm_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid permission ID".into()))?; + async fn delete(&self, id: String) -> Result { + let perm_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid permission ID".into()))?; - let _existing = PermissionsEntity::find_by_id(perm_id) - .filter(PermissionsColumn::IsDeleted.eq(false)) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; + let _existing = PermissionsEntity::find_by_id(perm_id) + .filter(PermissionsColumn::IsDeleted.eq(false)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Permission not found".into()))?; - let active_model = PermissionsActiveModel { - id: ActiveValue::Set(perm_id), - is_deleted: ActiveValue::Set(true), - deleted_at: ActiveValue::Set(Some(chrono::Utc::now())), - ..Default::default() - }; + let active_model = PermissionsActiveModel { + id: ActiveValue::Set(perm_id), + is_deleted: ActiveValue::Set(true), + deleted_at: ActiveValue::Set(Some(chrono::Utc::now())), + ..Default::default() + }; - let result = active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let result = active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(format!("Success delete permission with id: {}", result.id)) - } + Ok(format!("Success delete permission with id: {}", result.id)) + } } diff --git a/imphnen-iam/src/permissions/mod.rs b/imphnen-iam/src/permissions/mod.rs index 426b951..c3023f0 100644 --- a/imphnen-iam/src/permissions/mod.rs +++ b/imphnen-iam/src/permissions/mod.rs @@ -1,5 +1,7 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; -pub use infrastructure::http::routes::{permissions_public_routes, permissions_protected_routes}; +pub use infrastructure::http::routes::{ + permissions_protected_routes, permissions_public_routes, +}; diff --git a/imphnen-iam/src/permissions_guard.rs b/imphnen-iam/src/permissions_guard.rs index 0eea2d9..8769845 100644 --- a/imphnen-iam/src/permissions_guard.rs +++ b/imphnen-iam/src/permissions_guard.rs @@ -1,69 +1,90 @@ -use imphnen_entities::PermissionsEnum; use crate::{AppState, decode_access_token}; -use axum::{ - http::HeaderMap, - Extension, -}; -use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt}; +use axum::{Extension, http::HeaderMap}; +use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer}; +use imphnen_entities::PermissionsEnum; use imphnen_utils::AppError; use uuid::Uuid; pub async fn permissions_guard( - headers: HeaderMap, - Extension(state): Extension, - required_permissions: Vec, + headers: HeaderMap, + Extension(state): Extension, + required_permissions: Vec, ) -> Result<(imphnen_libs::jsonwebtoken::Claims, AppState), AppError> { - let auth_header = headers - .typed_get::>() - .ok_or_else(|| AppError::AuthenticationError("Invalid or missing authorization token".to_string()))?; + let auth_header = + headers + .typed_get::>() + .ok_or_else(|| { + AppError::AuthenticationError( + "Invalid or missing authorization token".to_string(), + ) + })?; - let token = auth_header.token(); + let token = auth_header.token(); - let claims = decode_access_token(token) - .map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string()))? - .claims; + let claims = decode_access_token(token) + .map_err(|_| { + AppError::AuthenticationError("Invalid or expired token".to_string()) + })? + .claims; - let user_info = { - let by_email = state.user_lookup_service.get_user_by_email(&claims.sub, &state).await; - match by_email { - Ok(info) => info, - Err(_) => { - let user_id = Uuid::parse_str(&claims.sub) - .map_err(|_| AppError::AuthenticationError("Invalid user ID format".to_string()))?; - state.user_lookup_service.get_user_by_id(user_id, &state).await - .map_err(|_| AppError::AuthenticationError("User not found".to_string()))? - } - } - }; + let user_info = { + let by_email = state + .user_lookup_service + .get_user_by_email(&claims.sub, &state) + .await; + match by_email { + Ok(info) => info, + Err(_) => { + let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { + AppError::AuthenticationError("Invalid user ID format".to_string()) + })?; + state + .user_lookup_service + .get_user_by_id(user_id, &state) + .await + .map_err(|_| AppError::AuthenticationError("User not found".to_string()))? + } + } + }; - let user_permissions: Vec = user_info.basic_info.role - .permissions - .as_ref() - .unwrap_or(&vec![]) - .iter() - .filter_map(|p| p.as_ref()) - .flat_map(|pp| { - let mut res: Vec = Vec::new(); - if let Some(name) = pp.name.clone() { res.push(name); } - if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) { res.push(id); } - res - }) - .collect(); + let user_permissions: Vec = user_info + .basic_info + .role + .permissions + .as_ref() + .unwrap_or(&vec![]) + .iter() + .filter_map(|p| p.as_ref()) + .flat_map(|pp| { + let mut res: Vec = Vec::new(); + if let Some(name) = pp.name.clone() { + res.push(name); + } + if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) { + res.push(id); + } + res + }) + .collect(); - let admin_name = PermissionsEnum::Administrator.to_string(); - let admin_id = PermissionsEnum::Administrator.id(); + let admin_name = PermissionsEnum::Administrator.to_string(); + let admin_id = PermissionsEnum::Administrator.id(); - if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) { - return Ok((claims, state)); - } + if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) { + return Ok((claims, state)); + } - for required in &required_permissions { - let required_str = required.to_string(); - let required_id = required.id(); - if !user_permissions.contains(&required_str) && !user_permissions.contains(&required_id) { - return Err(AppError::ForbiddenError("You don't have the required permissions".to_string())); - } - } + for required in &required_permissions { + let required_str = required.to_string(); + let required_id = required.id(); + if !user_permissions.contains(&required_str) + && !user_permissions.contains(&required_id) + { + return Err(AppError::ForbiddenError( + "You don't have the required permissions".to_string(), + )); + } + } - Ok((claims, state)) + Ok((claims, state)) } diff --git a/imphnen-iam/src/roles/application/role_service.rs b/imphnen-iam/src/roles/application/role_service.rs index fa4318f..4a47539 100644 --- a/imphnen-iam/src/roles/application/role_service.rs +++ b/imphnen-iam/src/roles/application/role_service.rs @@ -1,69 +1,78 @@ -use std::sync::Arc; +use crate::roles::domain::{RoleEntity, RoleRepository, RoleService}; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use crate::roles::domain::{RoleEntity, RoleRepository, RoleService}; pub struct RoleServiceImpl { - repo: Arc, + repo: Arc, } impl RoleServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl RoleService for RoleServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: String) -> Result { - self.repo.find_by_id(id).await - } + async fn get(&self, id: String) -> Result { + self.repo.find_by_id(id).await + } - async fn create(&self, name: String, permissions: Vec) -> Result { - // Check for name conflict - match self.repo.find_by_name(name.clone()).await { - Ok(_) => return Err(AppError::ConflictError("Role name already exists".into())), - Err(AppError::NotFoundError(_)) => {} - Err(e) => return Err(e), - } - let entity = RoleEntity { - id: Uuid::new_v4(), - name, - permissions, - ..Default::default() - }; - self.repo.create(entity).await - } + async fn create( + &self, + name: String, + permissions: Vec, + ) -> Result { + match self.repo.find_by_name(name.clone()).await { + Ok(_) => { + return Err(AppError::ConflictError("Role name already exists".into())); + } + Err(AppError::NotFoundError(_)) => {} + Err(e) => return Err(e), + } + let entity = RoleEntity { + id: Uuid::new_v4(), + name, + permissions, + ..Default::default() + }; + self.repo.create(entity).await + } - async fn update(&self, id: String, name: Option, permissions: Option>) -> Result { - // Validate the role exists - let existing = self.repo.find_by_id(id.clone()).await?; + async fn update( + &self, + id: String, + name: Option, + permissions: Option>, + ) -> Result { + let existing = self.repo.find_by_id(id.clone()).await?; + if let Some(ref new_name) = name { + match self.repo.find_by_name(new_name.clone()).await { + Ok(found) if found.id != existing.id => { + return Err(AppError::ConflictError("Role name already exists".into())); + } + Ok(_) => {} + Err(AppError::NotFoundError(_)) => {} + Err(e) => return Err(e), + } + } - // Check name uniqueness if name is being changed - if let Some(ref new_name) = name { - match self.repo.find_by_name(new_name.clone()).await { - Ok(found) if found.id != existing.id => { - return Err(AppError::ConflictError("Role name already exists".into())); - } - Ok(_) => {} - Err(AppError::NotFoundError(_)) => {} - Err(e) => return Err(e), - } - } + self.repo.update(id, name, permissions).await + } - self.repo.update(id, name, permissions).await - } - - async fn delete(&self, id: String) -> Result { - // Validate the role exists - self.repo.find_by_id(id.clone()).await?; - self.repo.delete(id).await - } + async fn delete(&self, id: String) -> Result { + self.repo.find_by_id(id.clone()).await?; + self.repo.delete(id).await + } } diff --git a/imphnen-iam/src/roles/domain/mod.rs b/imphnen-iam/src/roles/domain/mod.rs index a5cd0b7..24c66fd 100644 --- a/imphnen-iam/src/roles/domain/mod.rs +++ b/imphnen-iam/src/roles/domain/mod.rs @@ -1,7 +1,7 @@ -pub mod role; pub mod repository; +pub mod role; pub mod service; -pub use role::RoleEntity; pub use repository::RoleRepository; +pub use role::RoleEntity; pub use service::RoleService; diff --git a/imphnen-iam/src/roles/domain/repository.rs b/imphnen-iam/src/roles/domain/repository.rs index ff5bcce..198f2b2 100644 --- a/imphnen-iam/src/roles/domain/repository.rs +++ b/imphnen-iam/src/roles/domain/repository.rs @@ -1,15 +1,23 @@ +use super::role::RoleEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::role::RoleEntity; #[async_trait] pub trait RoleRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: String) -> Result; - async fn find_by_name(&self, name: String) -> Result; - async fn create(&self, entity: RoleEntity) -> Result; - async fn update(&self, id: String, name: Option, permissions: Option>) -> Result; - async fn delete(&self, id: String) -> Result; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: String) -> Result; + async fn find_by_name(&self, name: String) -> Result; + async fn create(&self, entity: RoleEntity) -> Result; + async fn update( + &self, + id: String, + name: Option, + permissions: Option>, + ) -> Result; + async fn delete(&self, id: String) -> Result; } diff --git a/imphnen-iam/src/roles/domain/role.rs b/imphnen-iam/src/roles/domain/role.rs index d8d119b..a14d5e3 100644 --- a/imphnen-iam/src/roles/domain/role.rs +++ b/imphnen-iam/src/roles/domain/role.rs @@ -2,13 +2,13 @@ use uuid::Uuid; #[derive(Clone, Debug, Default)] pub struct RoleEntity { - pub id: Uuid, - pub name: String, - pub description: String, - pub is_system_role: bool, - pub is_default: bool, - pub permissions: Vec, - pub created_at: Option, - pub updated_at: Option, - pub deleted_at: Option, + pub id: Uuid, + pub name: String, + pub description: String, + pub is_system_role: bool, + pub is_default: bool, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, + pub deleted_at: Option, } diff --git a/imphnen-iam/src/roles/domain/service.rs b/imphnen-iam/src/roles/domain/service.rs index 9d87d4a..11dd73c 100644 --- a/imphnen-iam/src/roles/domain/service.rs +++ b/imphnen-iam/src/roles/domain/service.rs @@ -1,14 +1,26 @@ +use super::role::RoleEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::role::RoleEntity; #[async_trait] pub trait RoleService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: String) -> Result; - async fn create(&self, name: String, permissions: Vec) -> Result; - async fn update(&self, id: String, name: Option, permissions: Option>) -> Result; - async fn delete(&self, id: String) -> Result; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: String) -> Result; + async fn create( + &self, + name: String, + permissions: Vec, + ) -> Result; + async fn update( + &self, + id: String, + name: Option, + permissions: Option>, + ) -> Result; + async fn delete(&self, id: String) -> Result; } diff --git a/imphnen-iam/src/roles/infrastructure/http/dto.rs b/imphnen-iam/src/roles/infrastructure/http/dto.rs index 6539e8c..e9c157d 100644 --- a/imphnen-iam/src/roles/infrastructure/http/dto.rs +++ b/imphnen-iam/src/roles/infrastructure/http/dto.rs @@ -1,101 +1,105 @@ +use crate::roles::domain::RoleEntity; use imphnen_entities::{PermissionsEnum, PermissionsItemDto}; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use utoipa::ToSchema; use zod_rs::prelude::*; -use crate::roles::domain::RoleEntity; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct RolesCreateRequestDto { - #[zod(min_length(1))] - pub name: String, - pub permissions: Vec, + #[zod(min_length(1))] + pub name: String, + pub permissions: Vec, } impl ZodValidate for RolesCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct RolesUpdateRequestDto { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub permissions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option>, } impl ZodValidate for RolesUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct RolesListItemDto { - pub id: String, - pub name: String, - pub permissions_count: usize, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub name: String, + pub permissions_count: usize, + pub created_at: Option, + pub updated_at: Option, } impl From for RolesListItemDto { - fn from(e: RoleEntity) -> Self { - Self { - permissions_count: e.permissions.len(), - id: e.id.to_string(), - name: e.name, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + fn from(e: RoleEntity) -> Self { + Self { + permissions_count: e.permissions.len(), + id: e.id.to_string(), + name: e.name, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct RolesDetailItemDto { - pub id: String, - pub name: String, - pub description: String, - pub is_system_role: bool, - pub is_default: bool, - pub permissions: Vec, - pub created_at: Option, - pub updated_at: Option, + pub id: String, + pub name: String, + pub description: String, + pub is_system_role: bool, + pub is_default: bool, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, } impl From for RolesDetailItemDto { - fn from(e: RoleEntity) -> Self { - let permissions_dto = e.permissions.iter().map(|p_str| { - for enum_val in PermissionsEnum::iter() { - if enum_val.to_string() == *p_str { - return PermissionsItemDto { - id: enum_val.id(), - name: p_str.clone(), - created_at: None, - updated_at: None, - }; - } - } - PermissionsItemDto { - id: String::new(), - name: p_str.clone(), - created_at: None, - updated_at: None, - } - }).collect(); + fn from(e: RoleEntity) -> Self { + let permissions_dto = e + .permissions + .iter() + .map(|p_str| { + for enum_val in PermissionsEnum::iter() { + if enum_val.to_string() == *p_str { + return PermissionsItemDto { + id: enum_val.id(), + name: p_str.clone(), + created_at: None, + updated_at: None, + }; + } + } + PermissionsItemDto { + id: String::new(), + name: p_str.clone(), + created_at: None, + updated_at: None, + } + }) + .collect(); - Self { - id: e.id.to_string(), - name: e.name, - description: e.description, - is_system_role: e.is_system_role, - is_default: e.is_default, - permissions: permissions_dto, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + Self { + id: e.id.to_string(), + name: e.name, + description: e.description, + is_system_role: e.is_system_role, + is_default: e.is_default, + permissions: permissions_dto, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } diff --git a/imphnen-iam/src/roles/infrastructure/http/handlers.rs b/imphnen-iam/src/roles/infrastructure/http/handlers.rs index d8cd604..b570037 100644 --- a/imphnen-iam/src/roles/infrastructure/http/handlers.rs +++ b/imphnen-iam/src/roles/infrastructure/http/handlers.rs @@ -1,19 +1,20 @@ -use crate::require_permissions; -use std::sync::Arc; -use axum::{ - Extension, Json, - extract::Path, - http::HeaderMap, - response::IntoResponse, +use super::dto::{ + RolesCreateRequestDto, RolesDetailItemDto, RolesListItemDto, RolesUpdateRequestDto, }; +use crate::require_permissions; +use crate::roles::domain::RoleService; +use axum::{ + Extension, Json, extract::Path, http::HeaderMap, response::IntoResponse, +}; +use imphnen_entities::{ + PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, +}; +use imphnen_libs::AppState; +use imphnen_utils::AppError; +use imphnen_utils::{ApiCreated, ApiMessage, ApiPaginated, ApiSuccess}; use paginator_axum::PaginationQuery; use paginator_utils::PaginatorResponse; -use imphnen_libs::AppState; -use imphnen_utils::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage}; -use imphnen_entities::{ResponseSuccessDto, ResponseListSuccessDto, PermissionsEnum}; -use imphnen_utils::AppError; -use crate::roles::domain::RoleService; -use super::dto::{RolesCreateRequestDto, RolesDetailItemDto, RolesListItemDto, RolesUpdateRequestDto}; +use std::sync::Arc; #[utoipa::path( get, @@ -34,19 +35,23 @@ use super::dto::{RolesCreateRequestDto, RolesDetailItemDto, RolesListItemDto, Ro tag = "Roles" )] pub async fn get_role_list( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadListRoles], { - let result = service.list(params).await?; - let mapped = PaginatorResponse { - data: result.data.into_iter().map(RolesListItemDto::from).collect::>(), - meta: result.meta, - }; - Ok(ApiPaginated(mapped)) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadListRoles], { + let result = service.list(params).await?; + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(RolesListItemDto::from) + .collect::>(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) + }) } #[utoipa::path( @@ -60,15 +65,15 @@ pub async fn get_role_list( tag = "Roles" )] pub async fn get_role_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailRoles], { - let role = service.get(id).await?; - Ok(ApiSuccess(RolesDetailItemDto::from(role))) - }) + require_permissions!(headers, state, [PermissionsEnum::ReadDetailRoles], { + let role = service.get(id).await?; + Ok(ApiSuccess(RolesDetailItemDto::from(role))) + }) } #[utoipa::path( @@ -82,15 +87,15 @@ pub async fn get_role_by_id( tag = "Roles" )] pub async fn post_create_role( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Json(payload): Json, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Json(payload): Json, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::CreateRoles], { - let role = service.create(payload.name, payload.permissions).await?; - Ok(ApiCreated(RolesDetailItemDto::from(role))) - }) + require_permissions!(headers, state, [PermissionsEnum::CreateRoles], { + let role = service.create(payload.name, payload.permissions).await?; + Ok(ApiCreated(RolesDetailItemDto::from(role))) + }) } #[utoipa::path( @@ -105,16 +110,18 @@ pub async fn post_create_role( tag = "Roles" )] pub async fn put_update_role( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - Json(payload): Json, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(payload): Json, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::UpdateRoles], { - let msg = service.update(id, payload.name, payload.permissions).await?; - Ok(ApiMessage::ok(&msg)) - }) + require_permissions!(headers, state, [PermissionsEnum::UpdateRoles], { + let msg = service + .update(id, payload.name, payload.permissions) + .await?; + Ok(ApiMessage::ok(&msg)) + }) } #[utoipa::path( @@ -128,13 +135,13 @@ pub async fn put_update_role( tag = "Roles" )] pub async fn delete_role( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, ) -> Result { - require_permissions!(headers, state, [PermissionsEnum::DeleteRoles], { - let msg = service.delete(id).await?; - Ok(ApiMessage::ok(&msg)) - }) + require_permissions!(headers, state, [PermissionsEnum::DeleteRoles], { + let msg = service.delete(id).await?; + Ok(ApiMessage::ok(&msg)) + }) } diff --git a/imphnen-iam/src/roles/infrastructure/http/mod.rs b/imphnen-iam/src/roles/infrastructure/http/mod.rs index 074b3eb..af5f31a 100644 --- a/imphnen-iam/src/roles/infrastructure/http/mod.rs +++ b/imphnen-iam/src/roles/infrastructure/http/mod.rs @@ -2,4 +2,4 @@ pub mod dto; pub mod handlers; pub mod routes; -pub use routes::{roles_public_routes, roles_protected_routes}; +pub use routes::{roles_protected_routes, roles_public_routes}; diff --git a/imphnen-iam/src/roles/infrastructure/http/routes.rs b/imphnen-iam/src/roles/infrastructure/http/routes.rs index 9fb048b..a650436 100644 --- a/imphnen-iam/src/roles/infrastructure/http/routes.rs +++ b/imphnen-iam/src/roles/infrastructure/http/routes.rs @@ -1,31 +1,37 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, post, put}, Extension}; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; +use super::handlers::{ + delete_role, get_role_by_id, get_role_list, post_create_role, put_update_role, +}; use crate::roles::application::RoleServiceImpl; use crate::roles::domain::RoleService; use crate::roles::infrastructure::persistence::PostgresRoleRepository; -use super::handlers::{ - get_role_list, get_role_by_id, post_create_role, put_update_role, delete_role, +use axum::{ + Extension, Router, + routing::{delete, get, post, put}, }; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresRoleRepository::new(db)); - Arc::new(RoleServiceImpl::new(repo)) + let repo = Arc::new(PostgresRoleRepository::new(db)); + Arc::new(RoleServiceImpl::new(repo)) } pub fn roles_public_routes(_db: DatabaseConnection) -> Router { - Router::new() + Router::new() } -pub fn roles_protected_routes(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db); - Router::new() - .route("/roles", get(get_role_list)) - .route("/roles/detail/{id}", get(get_role_by_id)) - .route("/roles/create", post(post_create_role)) - .route("/roles/update/{id}", put(put_update_role)) - .route("/roles/delete/{id}", delete(delete_role)) - .layer(Extension(service)) - .layer(Extension((*state).clone())) +pub fn roles_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/roles", get(get_role_list)) + .route("/roles/detail/{id}", get(get_role_by_id)) + .route("/roles/create", post(post_create_role)) + .route("/roles/update/{id}", put(put_update_role)) + .route("/roles/delete/{id}", delete(delete_role)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-iam/src/roles/infrastructure/persistence/postgres_role_repository.rs b/imphnen-iam/src/roles/infrastructure/persistence/postgres_role_repository.rs index 30b8afc..d6d618b 100644 --- a/imphnen-iam/src/roles/infrastructure/persistence/postgres_role_repository.rs +++ b/imphnen-iam/src/roles/infrastructure/persistence/postgres_role_repository.rs @@ -1,164 +1,193 @@ -use std::sync::Arc; +use crate::roles::domain::{RoleEntity, RoleRepository}; use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, QuerySelect, PaginatorTrait}; +use imphnen_entities::seaorm::auth::roles::{ + ActiveModel as RolesActiveModel, Column as RolesColumn, Entity as RolesEntity, + Model as RolesModel, +}; +use imphnen_utils::AppError; use paginator_rs::{PaginationParams, SortDirection}; use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{ActiveValue, Order, PaginatorTrait, QueryOrder, QuerySelect}; +use std::sync::Arc; use uuid::Uuid; -use imphnen_utils::AppError; -use imphnen_entities::seaorm::auth::roles::{ - Entity as RolesEntity, Column as RolesColumn, - ActiveModel as RolesActiveModel, Model as RolesModel, -}; -use crate::roles::domain::{RoleEntity, RoleRepository}; fn to_entity(model: RolesModel) -> RoleEntity { - let permissions = model.permissions.as_ref() - .and_then(|p| serde_json::from_value::>(p.clone()).ok()) - .unwrap_or_default(); + let permissions = model + .permissions + .as_ref() + .and_then(|p| serde_json::from_value::>(p.clone()).ok()) + .unwrap_or_default(); - RoleEntity { - id: model.id, - name: model.name, - description: model.description, - is_system_role: model.is_system_role, - is_default: model.is_default, - permissions, - created_at: Some(model.created_at.to_rfc3339()), - updated_at: Some(model.updated_at.to_rfc3339()), - deleted_at: model.deleted_at.map(|d| d.to_rfc3339()), - } + RoleEntity { + id: model.id, + name: model.name, + description: model.description, + is_system_role: model.is_system_role, + is_default: model.is_default, + permissions, + created_at: Some(model.created_at.to_rfc3339()), + updated_at: Some(model.updated_at.to_rfc3339()), + deleted_at: model.deleted_at.map(|d| d.to_rfc3339()), + } } pub struct PostgresRoleRepository { - db: Arc, + db: Arc, } impl PostgresRoleRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl RoleRepository for PostgresRoleRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); - let mut query = RolesEntity::find() - .filter(RolesColumn::DeletedAt.is_null()); + let mut query = RolesEntity::find().filter(RolesColumn::DeletedAt.is_null()); - if let Some(ref search) = params.search { - query = query.filter(RolesColumn::Name.contains(&search.query)); - } + if let Some(ref search) = params.search { + query = query.filter(RolesColumn::Name.contains(&search.query)); + } - let sort_column = match params.sort_by.as_deref() { - Some("name") => RolesColumn::Name, - _ => RolesColumn::CreatedAt, - }; - query = match params.sort_direction { - Some(SortDirection::Desc) => query.order_by(sort_column, Order::Desc), - _ => query.order_by(sort_column, Order::Asc), - }; + let sort_column = match params.sort_by.as_deref() { + Some("name") => RolesColumn::Name, + _ => RolesColumn::CreatedAt, + }; + query = match params.sort_direction { + Some(SortDirection::Desc) => query.order_by(sort_column, Order::Desc), + _ => query.order_by(sort_column, Order::Asc), + }; - let total_count = query.clone().count(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let offset = ((page - 1) * per_page) as u64; - let roles = query.offset(offset).limit(per_page as u64).all(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let total_count = query + .clone() + .count(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let offset = ((page - 1) * per_page) as u64; + let roles = query + .offset(offset) + .limit(per_page as u64) + .all(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let data = roles.into_iter().map(to_entity).collect(); - let meta = PaginatorResponseMeta::new(page, per_page, total_count as u32); - Ok(PaginatorResponse { data, meta }) - } + let data = roles.into_iter().map(to_entity).collect(); + let meta = PaginatorResponseMeta::new(page, per_page, total_count as u32); + Ok(PaginatorResponse { data, meta }) + } - async fn find_by_id(&self, id: String) -> Result { - let role_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; + async fn find_by_id(&self, id: String) -> Result { + let role_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; - let model = RolesEntity::find_by_id(role_id) - .filter(RolesColumn::DeletedAt.is_null()) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Role not found".into()))?; + let model = RolesEntity::find_by_id(role_id) + .filter(RolesColumn::DeletedAt.is_null()) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Role not found".into()))?; - Ok(to_entity(model)) - } + Ok(to_entity(model)) + } - async fn find_by_name(&self, name: String) -> Result { - let model = RolesEntity::find() - .filter(RolesColumn::Name.eq(&name)) - .filter(RolesColumn::DeletedAt.is_null()) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("Role not found".into()))?; + async fn find_by_name(&self, name: String) -> Result { + let model = RolesEntity::find() + .filter(RolesColumn::Name.eq(&name)) + .filter(RolesColumn::DeletedAt.is_null()) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Role not found".into()))?; - Ok(to_entity(model)) - } + Ok(to_entity(model)) + } - async fn create(&self, entity: RoleEntity) -> Result { - let permissions_json = serde_json::to_value(&entity.permissions) - .map_err(|e| AppError::InternalServerError(format!("Failed to serialize permissions: {e}")))?; + async fn create(&self, entity: RoleEntity) -> Result { + let permissions_json = + serde_json::to_value(&entity.permissions).map_err(|e| { + AppError::InternalServerError(format!( + "Failed to serialize permissions: {e}" + )) + })?; - let active_model = RolesActiveModel { - id: ActiveValue::Set(entity.id), - name: ActiveValue::Set(entity.name), - description: ActiveValue::Set(entity.description), - is_system_role: ActiveValue::Set(entity.is_system_role), - is_default: ActiveValue::Set(entity.is_default), - permissions: ActiveValue::Set(Some(permissions_json)), - created_at: ActiveValue::Set(chrono::Utc::now()), - updated_at: ActiveValue::Set(chrono::Utc::now()), - deleted_at: ActiveValue::NotSet, - }; + let active_model = RolesActiveModel { + id: ActiveValue::Set(entity.id), + name: ActiveValue::Set(entity.name), + description: ActiveValue::Set(entity.description), + is_system_role: ActiveValue::Set(entity.is_system_role), + is_default: ActiveValue::Set(entity.is_default), + permissions: ActiveValue::Set(Some(permissions_json)), + created_at: ActiveValue::Set(chrono::Utc::now()), + updated_at: ActiveValue::Set(chrono::Utc::now()), + deleted_at: ActiveValue::NotSet, + }; - let created = active_model.insert(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let created = active_model + .insert(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(to_entity(created)) - } + Ok(to_entity(created)) + } - async fn update(&self, id: String, name: Option, permissions: Option>) -> Result { - let role_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; + async fn update( + &self, + id: String, + name: Option, + permissions: Option>, + ) -> Result { + let role_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; - let mut active_model = RolesActiveModel { - id: ActiveValue::Unchanged(role_id), - ..Default::default() - }; + let mut active_model = RolesActiveModel { + id: ActiveValue::Unchanged(role_id), + ..Default::default() + }; - if let Some(n) = name { - active_model.name = ActiveValue::Set(n); - } - if let Some(perms) = permissions { - let permissions_json = serde_json::to_value(&perms) - .map_err(|e| AppError::InternalServerError(format!("Failed to serialize permissions: {e}")))?; - active_model.permissions = ActiveValue::Set(Some(permissions_json)); - } - active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + if let Some(n) = name { + active_model.name = ActiveValue::Set(n); + } + if let Some(perms) = permissions { + let permissions_json = serde_json::to_value(&perms).map_err(|e| { + AppError::InternalServerError(format!( + "Failed to serialize permissions: {e}" + )) + })?; + active_model.permissions = ActiveValue::Set(Some(permissions_json)); + } + active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok("Success update role".into()) - } + Ok("Success update role".into()) + } - async fn delete(&self, id: String) -> Result { - let role_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; + async fn delete(&self, id: String) -> Result { + let role_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid role ID".into()))?; - let active_model = RolesActiveModel { - id: ActiveValue::Unchanged(role_id), - deleted_at: ActiveValue::Set(Some(chrono::Utc::now())), - ..Default::default() - }; + let active_model = RolesActiveModel { + id: ActiveValue::Unchanged(role_id), + deleted_at: ActiveValue::Set(Some(chrono::Utc::now())), + ..Default::default() + }; - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok("Success delete role".into()) - } + Ok("Success delete role".into()) + } } diff --git a/imphnen-iam/src/roles/mod.rs b/imphnen-iam/src/roles/mod.rs index bb823f2..38b9ae1 100644 --- a/imphnen-iam/src/roles/mod.rs +++ b/imphnen-iam/src/roles/mod.rs @@ -1,5 +1,7 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; -pub use infrastructure::http::routes::{roles_public_routes, roles_protected_routes}; +pub use infrastructure::http::routes::{ + roles_protected_routes, roles_public_routes, +}; diff --git a/imphnen-iam/src/users/application/user_service.rs b/imphnen-iam/src/users/application/user_service.rs index 0fa3064..4c56358 100644 --- a/imphnen-iam/src/users/application/user_service.rs +++ b/imphnen-iam/src/users/application/user_service.rs @@ -1,95 +1,113 @@ -use std::sync::Arc; +use crate::users::domain::{UserEntity, UserListItem, UserRepository, UserService}; use async_trait::async_trait; +use imphnen_libs::{hash_password, verify_password}; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use imphnen_libs::{hash_password, verify_password}; -use crate::users::domain::{UserEntity, UserListItem, UserRepository, UserService}; +use std::sync::Arc; pub struct UserServiceImpl { - repo: Arc, + repo: Arc, } impl UserServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } + pub fn new(repo: Arc) -> Self { + Self { repo } + } } #[async_trait] impl UserService for UserServiceImpl { - async fn list(&self, params: PaginationParams) -> Result, AppError> { - self.repo.find_all(params).await - } + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError> { + self.repo.find_all(params).await + } - async fn get(&self, id: String) -> Result { - self.repo.find_by_id(&id).await - } + async fn get(&self, id: String) -> Result { + self.repo.find_by_id(&id).await + } - async fn get_me(&self, user_id: String) -> Result { - self.repo.find_by_id(&user_id).await - } + async fn get_me(&self, user_id: String) -> Result { + self.repo.find_by_id(&user_id).await + } - async fn get_by_email(&self, email: String) -> Result { - self.repo.find_by_email(email).await - } + async fn get_by_email(&self, email: String) -> Result { + self.repo.find_by_email(email).await + } - async fn create(&self, entity: UserEntity) -> Result { - // Check for email conflict - match self.repo.find_by_email(entity.email.clone()).await { - Ok(_) => return Err(AppError::ConflictError("User already exists".into())), - Err(AppError::NotFoundError(_)) => {} - Err(e) => return Err(e), - } - let email = entity.email.clone(); - self.repo.create(entity).await?; - self.repo.find_by_email(email).await - } + async fn create(&self, entity: UserEntity) -> Result { + match self.repo.find_by_email(entity.email.clone()).await { + Ok(_) => return Err(AppError::ConflictError("User already exists".into())), + Err(AppError::NotFoundError(_)) => {} + Err(e) => return Err(e), + } + let email = entity.email.clone(); + self.repo.create(entity).await?; + self.repo.find_by_email(email).await + } - async fn update(&self, entity: UserEntity) -> Result { - let existing = self.repo.find_by_id(&entity.id).await?; - if existing.is_deleted { - return Err(AppError::NotFoundError("User not found".into())); - } - self.repo.update(entity).await - } + async fn update(&self, entity: UserEntity) -> Result { + let existing = self.repo.find_by_id(&entity.id).await?; + if existing.is_deleted { + return Err(AppError::NotFoundError("User not found".into())); + } + self.repo.update(entity).await + } - async fn delete(&self, id: String) -> Result { - let user = self.repo.find_by_id(&id).await?; - if user.is_deleted { - return Err(AppError::NotFoundError("User not found".into())); - } - self.repo.delete(id).await - } + async fn delete(&self, id: String) -> Result { + let user = self.repo.find_by_id(&id).await?; + if user.is_deleted { + return Err(AppError::NotFoundError("User not found".into())); + } + self.repo.delete(id).await + } - async fn set_active_status(&self, id: String, is_active: bool) -> Result { - let mut user = self.repo.find_by_id(&id).await?; - if user.is_deleted { - return Err(AppError::NotFoundError("User not found".into())); - } - user.is_active = is_active; - self.repo.update(user).await - } + async fn set_active_status( + &self, + id: String, + is_active: bool, + ) -> Result { + let mut user = self.repo.find_by_id(&id).await?; + if user.is_deleted { + return Err(AppError::NotFoundError("User not found".into())); + } + user.is_active = is_active; + self.repo.update(user).await + } - async fn update_password(&self, email: String, old_password: String, new_password: String) -> Result { - let user = self.repo.find_by_email(email.clone()).await - .map_err(|_| AppError::NotFoundError("User not found".into()))?; + async fn update_password( + &self, + email: String, + old_password: String, + new_password: String, + ) -> Result { + let user = self + .repo + .find_by_email(email.clone()) + .await + .map_err(|_| AppError::NotFoundError("User not found".into()))?; - if user.is_deleted { - return Err(AppError::NotFoundError("User not found".into())); - } + if user.is_deleted { + return Err(AppError::NotFoundError("User not found".into())); + } - let is_valid = verify_password(&old_password, &user.password) - .map_err(|_| AppError::BadRequestError("Password verification failed".into()))?; - if !is_valid { - return Err(AppError::BadRequestError("Old password is incorrect".into())); - } + let is_valid = verify_password(&old_password, &user.password).map_err(|_| { + AppError::BadRequestError("Password verification failed".into()) + })?; + if !is_valid { + return Err(AppError::BadRequestError( + "Old password is incorrect".into(), + )); + } - let new_hash = hash_password(&new_password) - .map_err(|_| AppError::InternalServerError("Failed to hash password".into()))?; + let new_hash = hash_password(&new_password).map_err(|_| { + AppError::InternalServerError("Failed to hash password".into()) + })?; - let mut updated = user; - updated.password = new_hash; - self.repo.update(updated).await - } + let mut updated = user; + updated.password = new_hash; + self.repo.update(updated).await + } } diff --git a/imphnen-iam/src/users/domain/mod.rs b/imphnen-iam/src/users/domain/mod.rs index e14a64b..c03a33d 100644 --- a/imphnen-iam/src/users/domain/mod.rs +++ b/imphnen-iam/src/users/domain/mod.rs @@ -1,7 +1,7 @@ -pub mod user; pub mod repository; pub mod service; +pub mod user; -pub use user::UserEntity; -pub use repository::{UserRepository, UserListItem}; +pub use repository::{UserListItem, UserRepository}; pub use service::UserService; +pub use user::UserEntity; diff --git a/imphnen-iam/src/users/domain/repository.rs b/imphnen-iam/src/users/domain/repository.rs index ce2d56f..0fd3892 100644 --- a/imphnen-iam/src/users/domain/repository.rs +++ b/imphnen-iam/src/users/domain/repository.rs @@ -1,28 +1,30 @@ +use super::user::UserEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::user::UserEntity; -/// Lightweight list item returned by list queries #[derive(Clone, Debug)] pub struct UserListItem { - pub id: String, - pub role: String, - pub fullname: String, - pub email: String, - pub avatar: Option, - pub is_active: bool, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub role: String, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub is_active: bool, + pub created_at: String, + pub updated_at: String, } #[async_trait] pub trait UserRepository: Send + Sync { - async fn find_all(&self, params: PaginationParams) -> Result, AppError>; - async fn find_by_id(&self, id: &str) -> Result; - async fn find_by_email(&self, email: String) -> Result; - async fn create(&self, entity: UserEntity) -> Result; - async fn update(&self, entity: UserEntity) -> Result; - async fn delete(&self, id: String) -> Result; + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn find_by_id(&self, id: &str) -> Result; + async fn find_by_email(&self, email: String) -> Result; + async fn create(&self, entity: UserEntity) -> Result; + async fn update(&self, entity: UserEntity) -> Result; + async fn delete(&self, id: String) -> Result; } diff --git a/imphnen-iam/src/users/domain/service.rs b/imphnen-iam/src/users/domain/service.rs index 1603392..baf14cc 100644 --- a/imphnen-iam/src/users/domain/service.rs +++ b/imphnen-iam/src/users/domain/service.rs @@ -1,19 +1,31 @@ +use super::repository::UserListItem; +use super::user::UserEntity; use async_trait::async_trait; +use imphnen_utils::AppError; use paginator_rs::PaginationParams; use paginator_utils::PaginatorResponse; -use imphnen_utils::AppError; -use super::user::UserEntity; -use super::repository::UserListItem; #[async_trait] pub trait UserService: Send + Sync { - async fn list(&self, params: PaginationParams) -> Result, AppError>; - async fn get(&self, id: String) -> Result; - async fn get_me(&self, user_id: String) -> Result; - async fn get_by_email(&self, email: String) -> Result; - async fn create(&self, entity: UserEntity) -> Result; - async fn update(&self, entity: UserEntity) -> Result; - async fn delete(&self, id: String) -> Result; - async fn set_active_status(&self, id: String, is_active: bool) -> Result; - async fn update_password(&self, email: String, old_password: String, new_password: String) -> Result; + async fn list( + &self, + params: PaginationParams, + ) -> Result, AppError>; + async fn get(&self, id: String) -> Result; + async fn get_me(&self, user_id: String) -> Result; + async fn get_by_email(&self, email: String) -> Result; + async fn create(&self, entity: UserEntity) -> Result; + async fn update(&self, entity: UserEntity) -> Result; + async fn delete(&self, id: String) -> Result; + async fn set_active_status( + &self, + id: String, + is_active: bool, + ) -> Result; + async fn update_password( + &self, + email: String, + old_password: String, + new_password: String, + ) -> Result; } diff --git a/imphnen-iam/src/users/domain/user.rs b/imphnen-iam/src/users/domain/user.rs index a3789fa..9a6efb4 100644 --- a/imphnen-iam/src/users/domain/user.rs +++ b/imphnen-iam/src/users/domain/user.rs @@ -2,17 +2,17 @@ use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto}; #[derive(Clone, Debug, Default)] pub struct UserEntity { - pub id: String, - pub email: String, - pub fullname: String, - pub legal_name: Option, - pub password: String, - pub avatar: Option, - pub is_active: bool, - pub is_deleted: bool, - pub role: RolesDetailQueryDto, - pub profile_extension: Option, - pub created_at: String, - pub updated_at: String, - pub mentor_id: Option, + pub id: String, + pub email: String, + pub fullname: String, + pub legal_name: Option, + pub password: String, + pub avatar: Option, + pub is_active: bool, + pub is_deleted: bool, + pub role: RolesDetailQueryDto, + pub profile_extension: Option, + pub created_at: String, + pub updated_at: String, + pub mentor_id: Option, } diff --git a/imphnen-iam/src/users/infrastructure/http/dto.rs b/imphnen-iam/src/users/infrastructure/http/dto.rs index 8d71977..077ff63 100644 --- a/imphnen-iam/src/users/infrastructure/http/dto.rs +++ b/imphnen-iam/src/users/infrastructure/http/dto.rs @@ -1,145 +1,147 @@ -use imphnen_entities::{RolesDetailItemDto, UsersDetailQueryDto, users::UserProfileExtensionDto}; +use crate::users::domain::{UserEntity, UserListItem}; +use imphnen_entities::{ + RolesDetailItemDto, UsersDetailQueryDto, users::UserProfileExtensionDto, +}; use imphnen_libs::ZodValidate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use zod_rs::prelude::*; -use crate::users::domain::{UserEntity, UserListItem}; #[derive(Serialize, Deserialize, ToSchema)] #[schema(description = "File upload form data for multipart/form-data")] pub struct FileUploadSchema { - #[schema(format = "binary")] - pub file: String, + #[schema(format = "binary")] + pub file: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersActiveInactiveRequestDto { - pub is_active: bool, + pub is_active: bool, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersSetNewPasswordRequestDto { - pub password: String, - pub old_password: String, + pub password: String, + pub old_password: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] pub struct UsersCreateRequestDto { - #[zod(email, min_length(1))] - pub email: String, - #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] - pub password: String, - #[zod(min_length(2))] - pub fullname: String, - pub is_active: bool, - pub role_id: String, - pub avatar: Option, + #[zod(email, min_length(1))] + pub email: String, + #[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))] + pub password: String, + #[zod(min_length(2))] + pub fullname: String, + pub is_active: bool, + pub role_id: String, + pub avatar: Option, } impl ZodValidate for UsersCreateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - Self::validate_and_parse(value).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersUpdateRequestDto { - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub password: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub fullname: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub legal_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_active: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub avatar: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub role_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_extension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fullname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_active: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_extension: Option, } impl ZodValidate for UsersUpdateRequestDto { - fn zod_validate(value: &serde_json::Value) -> Result { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + fn zod_validate(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct UsersDetailItemDto { - pub id: String, - pub role: RolesDetailItemDto, - pub fullname: String, - pub legal_name: Option, - pub email: String, - pub avatar: Option, - pub is_active: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_extension: Option, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub role: RolesDetailItemDto, + pub fullname: String, + pub legal_name: Option, + pub email: String, + pub avatar: Option, + pub is_active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_extension: Option, + pub created_at: String, + pub updated_at: String, } impl From for UsersDetailItemDto { - fn from(e: UserEntity) -> Self { - Self { - id: e.id, - role: RolesDetailItemDto::from(&e.role), - fullname: e.fullname, - legal_name: e.legal_name, - email: e.email, - avatar: e.avatar, - is_active: e.is_active, - profile_extension: e.profile_extension, - created_at: e.created_at, - updated_at: e.updated_at, - } - } + fn from(e: UserEntity) -> Self { + Self { + id: e.id, + role: RolesDetailItemDto::from(&e.role), + fullname: e.fullname, + legal_name: e.legal_name, + email: e.email, + avatar: e.avatar, + is_active: e.is_active, + profile_extension: e.profile_extension, + created_at: e.created_at, + updated_at: e.updated_at, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersListItemDto { - pub id: String, - pub role: String, - pub fullname: String, - pub email: String, - pub avatar: Option, - pub is_active: bool, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub role: String, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub is_active: bool, + pub created_at: String, + pub updated_at: String, } impl From<&UsersDetailQueryDto> for UsersDetailItemDto { - fn from(dto: &UsersDetailQueryDto) -> Self { - Self { - id: dto.id.clone(), - role: RolesDetailItemDto::from(&dto.role), - fullname: dto.fullname.clone(), - legal_name: dto.legal_name.clone(), - email: dto.email.clone(), - avatar: dto.avatar.clone(), - is_active: dto.is_active, - profile_extension: dto.profile_extension.clone(), - created_at: dto.created_at.clone(), - updated_at: dto.updated_at.clone(), - } - } + fn from(dto: &UsersDetailQueryDto) -> Self { + Self { + id: dto.id.clone(), + role: RolesDetailItemDto::from(&dto.role), + fullname: dto.fullname.clone(), + legal_name: dto.legal_name.clone(), + email: dto.email.clone(), + avatar: dto.avatar.clone(), + is_active: dto.is_active, + profile_extension: dto.profile_extension.clone(), + created_at: dto.created_at.clone(), + updated_at: dto.updated_at.clone(), + } + } } impl From for UsersListItemDto { - fn from(item: UserListItem) -> Self { - Self { - id: item.id, - role: item.role, - fullname: item.fullname, - email: item.email, - avatar: item.avatar, - is_active: item.is_active, - created_at: item.created_at, - updated_at: item.updated_at, - } - } + fn from(item: UserListItem) -> Self { + Self { + id: item.id, + role: item.role, + fullname: item.fullname, + email: item.email, + avatar: item.avatar, + is_active: item.is_active, + created_at: item.created_at, + updated_at: item.updated_at, + } + } } diff --git a/imphnen-iam/src/users/infrastructure/http/handlers.rs b/imphnen-iam/src/users/infrastructure/http/handlers.rs deleted file mode 100644 index fff7a34..0000000 --- a/imphnen-iam/src/users/infrastructure/http/handlers.rs +++ /dev/null @@ -1,418 +0,0 @@ -use crate::require_permissions; -use std::sync::Arc; -use axum::{ - Extension, Json, - extract::{Path, Multipart}, - http::HeaderMap, - response::IntoResponse, -}; -use paginator_axum::PaginationQuery; -use paginator_utils::PaginatorResponse; -use imphnen_libs::{AppState, MinioConfig, FileType, decode_base64_file, extract_content_type_from_data_url, create_minio_service_from_config}; -use imphnen_utils::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage}; -use imphnen_entities::{ResponseSuccessDto, ResponseListSuccessDto, PermissionsEnum, RolesDetailQueryDto}; -use imphnen_utils::AppError; -use crate::users::domain::{UserEntity, UserService}; -use super::dto::{ - FileUploadSchema, UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, - UsersListItemDto, UsersUpdateRequestDto, -}; -use imphnen_libs::hash_password; -use serde_json::json; -use tracing::error; -use uuid::Uuid; - -#[utoipa::path( - get, - path = "/v1/users", - security(("Bearer" = [])), - params( - ("page" = Option, Query, description = "Page number"), - ("per_page" = Option, Query, description = "Items per page"), - ("search" = Option, Query, description = "Search keyword"), - ("sort_by" = Option, Query, description = "Sort by field"), - ("order" = Option, Query, description = "Order ASC or DESC"), - ("filter" = Option, Query, description = "Filter value"), - ("filter_by" = Option, Query, description = "Field to filter by"), - ), - responses( - (status = 200, description = "[ADMIN] Get user list", body = ResponseListSuccessDto>) - ), - tag = "Users" -)] -pub async fn get_user_list( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - PaginationQuery(params): PaginationQuery, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadListUsers], { - let result = service.list(params).await?; - let mapped = PaginatorResponse { - data: result.data.into_iter().map(UsersListItemDto::from).collect::>(), - meta: result.meta, - }; - Ok(ApiPaginated(mapped)) - }) -} - -#[utoipa::path( - get, - path = "/v1/users/detail/{id}", - security(("Bearer" = [])), - params(("id" = String, Path, description = "User ID")), - responses( - (status = 200, description = "[ADMIN] Get user by ID", body = ResponseSuccessDto) - ), - tag = "Users" -)] -pub async fn get_user_by_id( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ReadDetailUsers], { - Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid User ID format".to_string()))?; - let user = service.get(id).await?; - if user.is_deleted { - return Err(AppError::NotFoundError("User not found".to_string())); - } - Ok(ApiSuccess(UsersDetailItemDto::from(user))) - }) -} - -#[utoipa::path( - get, - path = "/v1/users/me", - security(("Bearer" = [])), - responses( - (status = 200, description = "[USER] Get current user", body = ResponseSuccessDto) - ), - tag = "Users" -)] -pub async fn get_user_me( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, -) -> Result { - let (claims, _) = crate::permissions_guard(headers, axum::extract::Extension(state.clone()), vec![]).await?; - let user = service.get_me(claims.user_id).await?; - if user.is_deleted { - return Err(AppError::NotFoundError("User not found".to_string())); - } - Ok(ApiSuccess(UsersDetailItemDto::from(user))) -} - -#[utoipa::path( - post, - path = "/v1/users/create", - security(("Bearer" = [])), - request_body = UsersCreateRequestDto, - responses( - (status = 201, description = "[ADMIN] Create new user", body = ResponseSuccessDto) - ), - tag = "Users" -)] -pub async fn post_create_user( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Json(payload): Json, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::CreateUsers], { - let password_hash = hash_password(&payload.password) - .map_err(|_| AppError::InternalServerError("Failed to hash password".to_string()))?; - let role_id = payload.role_id.clone(); - let entity = UserEntity { - id: Uuid::new_v4().to_string(), - email: payload.email, - fullname: payload.fullname, - password: password_hash, - is_active: payload.is_active, - avatar: payload.avatar, - role: RolesDetailQueryDto { - id: role_id, - ..Default::default() - }, - ..Default::default() - }; - let user = service.create(entity).await?; - Ok(ApiCreated(UsersDetailItemDto::from(user))) - }) -} - -#[utoipa::path( - put, - path = "/v1/users/update/{id}", - security(("Bearer" = [])), - params(("id" = String, Path, description = "User ID")), - request_body = UsersUpdateRequestDto, - responses( - (status = 200, description = "[ADMIN] Update user") - ), - tag = "Users" -)] -pub async fn put_update_user( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - Json(payload): Json, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::UpdateUsers], { - Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid User ID format".to_string()))?; - let current = service.get(id.clone()).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - - let password = if let Some(ref pw) = payload.password { - hash_password(pw).unwrap_or_else(|_| current.password.clone()) - } else { - current.password.clone() - }; - - let role_id = payload.role_id.clone().unwrap_or(current.role.id.clone()); - let entity = UserEntity { - id: id.clone(), - email: payload.email.unwrap_or(current.email), - fullname: payload.fullname.unwrap_or(current.fullname), - legal_name: payload.legal_name.or(current.legal_name), - password, - avatar: payload.avatar.or(current.avatar), - is_active: payload.is_active.unwrap_or(current.is_active), - is_deleted: current.is_deleted, - role: RolesDetailQueryDto { id: role_id, ..current.role }, - profile_extension: payload.profile_extension.or(current.profile_extension), - created_at: current.created_at, - updated_at: current.updated_at, - mentor_id: current.mentor_id, - }; - let msg = service.update(entity).await?; - Ok(ApiMessage::ok(&msg)) - }) -} - -#[utoipa::path( - put, - path = "/v1/users/update/me", - security(("Bearer" = [])), - request_body = UsersUpdateRequestDto, - responses( - (status = 200, description = "[USER] Update current user") - ), - tag = "Users" -)] -pub async fn put_update_user_me( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Json(payload): Json, -) -> Result { - let (claims, _) = crate::permissions_guard(headers, axum::extract::Extension(state.clone()), vec![]).await?; - let user_id = claims.user_id.clone(); - - let current = service.get_me(user_id).await - .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; - - let password = if let Some(ref pw) = payload.password { - hash_password(pw).unwrap_or_else(|_| current.password.clone()) - } else { - current.password.clone() - }; - - let role_id = payload.role_id.clone().unwrap_or(current.role.id.clone()); - let entity = UserEntity { - id: current.id.clone(), - email: payload.email.unwrap_or(current.email), - fullname: payload.fullname.unwrap_or(current.fullname), - legal_name: payload.legal_name.or(current.legal_name), - password, - avatar: payload.avatar.or(current.avatar), - is_active: payload.is_active.unwrap_or(current.is_active), - is_deleted: current.is_deleted, - role: RolesDetailQueryDto { id: role_id, ..current.role }, - profile_extension: payload.profile_extension.or(current.profile_extension), - created_at: current.created_at, - updated_at: current.updated_at, - mentor_id: current.mentor_id, - }; - let msg = service.update(entity).await?; - Ok(ApiMessage::ok(&msg)) -} - -#[utoipa::path( - put, - path = "/v1/users/activate/{id}", - security(("Bearer" = [])), - params(("id" = String, Path, description = "User ID")), - request_body = UsersActiveInactiveRequestDto, - responses( - (status = 200, description = "[ADMIN] Set user active status") - ), - tag = "Users" -)] -pub async fn patch_user_active_status( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, - Json(payload): Json, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::ActivateUsers], { - Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid User ID format".to_string()))?; - let msg = service.set_active_status(id, payload.is_active).await?; - Ok(ApiMessage::ok(&msg)) - }) -} - -#[utoipa::path( - delete, - path = "/v1/users/delete/{id}", - security(("Bearer" = [])), - params(("id" = String, Path, description = "User ID")), - responses( - (status = 200, description = "[ADMIN] Soft delete user") - ), - tag = "Users" -)] -pub async fn delete_user( - headers: HeaderMap, - Extension(state): Extension, - Extension(service): Extension>, - Path(id): Path, -) -> Result { - require_permissions!(headers, state, [PermissionsEnum::DeleteUsers], { - Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid User ID format".to_string()))?; - let msg = service.delete(id).await?; - Ok(ApiMessage::ok(&msg)) - }) -} - -#[utoipa::path( - post, - path = "/v1/users/upload", - security(("Bearer" = [])), - request_body( - content = FileUploadSchema, - description = "Upload file with multipart form data", - content_type = "multipart/form-data" - ), - responses( - (status = 200, description = "[USER] Upload file successfully", body = ResponseSuccessDto), - (status = 400, description = "[USER] Bad request"), - (status = 401, description = "[USER] Unauthorized"), - (status = 500, description = "[USER] Internal server error") - ), - tag = "Users" -)] -pub async fn upload_file( - headers: HeaderMap, - Extension(state): Extension, - mut multipart: Multipart, -) -> Result { - let (claims, _) = crate::permissions_guard(headers, axum::extract::Extension(state.clone()), vec![]).await?; - let user_id = claims.user_id.clone(); - - let minio_config = MinioConfig::from_env() - .map_err(|e| { - error!("Failed to load MinIO config: {}", e); - AppError::InternalServerError("MinIO configuration error".to_string()) - })?; - let bucket_name = minio_config.bucket_name.clone(); - let minio_service = create_minio_service_from_config(minio_config).await - .map_err(|e| { - error!("Failed to initialize MinIO service: {}", e); - AppError::InternalServerError("MinIO service initialization error".to_string()) - })?; - - let mut file_data: Option> = None; - let mut filename: Option = None; - let mut content_type: Option = None; - - while let Some(field) = multipart.next_field().await.unwrap_or(None) { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "file" => { - filename = field.file_name().map(|s| s.to_string()); - content_type = field.content_type().map(|s| s.to_string()); - match field.bytes().await { - Ok(bytes) => file_data = Some(bytes.to_vec()), - Err(e) => { - error!("Failed to read file data: {}", e); - return Err(AppError::BadRequestError("Failed to read file data".to_string())); - } - } - } - "base64_data" => { - let base64_str = field.text().await.unwrap_or_default(); - if !base64_str.is_empty() { - match decode_base64_file(&base64_str) { - Ok(decoded) => { - file_data = Some(decoded); - if let Some(ct) = extract_content_type_from_data_url(&base64_str) { - content_type = Some(ct); - } - } - Err(e) => { - error!("Failed to decode base64 data: {}", e); - return Err(AppError::BadRequestError("Invalid base64 data".to_string())); - } - } - } - } - "filename" => filename = Some(field.text().await.unwrap_or_default()), - "content_type" => content_type = Some(field.text().await.unwrap_or_default()), - _ => {} - } - } - - let file_data = file_data - .ok_or_else(|| AppError::BadRequestError("file data is required".to_string()))?; - let filename = filename.unwrap_or_else(|| "unnamed_file".to_string()); - let content_type = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); - - let file_type = { - let ft = FileType::from_content_type(&content_type); - if matches!(ft, FileType::Unknown) { FileType::from_filename(&filename) } else { ft } - }; - if matches!(file_type, FileType::Unknown) { - return Err(AppError::BadRequestError("Unsupported file type".to_string())); - } - if !file_type.allowed_types().contains(&content_type.as_str()) { - return Err(AppError::BadRequestError(format!("File type does not match content type '{content_type}'"))); - } - if file_data.len() > file_type.max_size() { - return Err(AppError::BadRequestError(format!( - "File too large. Maximum size for {:?} is {} bytes", - file_type, - file_type.max_size() - ))); - } - - let sanitized = user_id.replace('%', "").replace(':', "_").replace('@', "_at_").replace('.', "_"); - let folder = format!("{}/{sanitized}", file_type.as_folder()); - - let object_path = minio_service - .upload_file_with_deduplication(&file_data, &content_type, &folder, &filename) - .await - .map_err(|e| { - error!("Failed to upload file: {}", e); - AppError::InternalServerError(format!("Upload failed: {e}")) - })?; - - let permanent_url = format!("https://cdn.asepharyana.tech/{}/{}", bucket_name, object_path); - let response_data = json!({ - "filename": filename, - "uploaded_path": object_path, - "url": permanent_url, - "size": file_data.len(), - "content_type": content_type, - "file_type": format!("{:?}", file_type).to_lowercase(), - "user_id": user_id, - }); - Ok(ApiSuccess(response_data)) -} diff --git a/imphnen-iam/src/users/infrastructure/http/handlers/get_handlers.rs b/imphnen-iam/src/users/infrastructure/http/handlers/get_handlers.rs new file mode 100644 index 0000000..257ff93 --- /dev/null +++ b/imphnen-iam/src/users/infrastructure/http/handlers/get_handlers.rs @@ -0,0 +1,106 @@ +use super::super::dto::{UsersDetailItemDto, UsersListItemDto}; +use crate::require_permissions; +use crate::users::domain::UserService; +use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; +use imphnen_entities::{ + PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, +}; +use imphnen_libs::AppState; +use imphnen_utils::{ApiPaginated, ApiSuccess, AppError}; +use paginator_axum::PaginationQuery; +use paginator_utils::PaginatorResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[utoipa::path( + get, + path = "/v1/users", + security(("Bearer" = [])), + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ("filter" = Option, Query, description = "Filter value"), + ("filter_by" = Option, Query, description = "Field to filter by"), + ), + responses( + (status = 200, description = "[ADMIN] Get user list", body = ResponseListSuccessDto>) + ), + tag = "Users" +)] +pub async fn get_user_list( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + PaginationQuery(params): PaginationQuery, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::ReadListUsers], { + let result = service.list(params).await?; + let mapped = PaginatorResponse { + data: result + .data + .into_iter() + .map(UsersListItemDto::from) + .collect::>(), + meta: result.meta, + }; + Ok(ApiPaginated(mapped)) + }) +} + +#[utoipa::path( + get, + path = "/v1/users/detail/{id}", + security(("Bearer" = [])), + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "[ADMIN] Get user by ID", body = ResponseSuccessDto) + ), + tag = "Users" +)] +pub async fn get_user_by_id( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::ReadDetailUsers], { + Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError("Invalid User ID format".to_string()) + })?; + let user = service.get(id).await?; + if user.is_deleted { + return Err(AppError::NotFoundError("User not found".to_string())); + } + Ok(ApiSuccess(UsersDetailItemDto::from(user))) + }) +} + +#[utoipa::path( + get, + path = "/v1/users/me", + security(("Bearer" = [])), + responses( + (status = 200, description = "[USER] Get current user", body = ResponseSuccessDto) + ), + tag = "Users" +)] +pub async fn get_user_me( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, +) -> Result { + let (claims, _) = crate::permissions_guard( + headers, + axum::extract::Extension(state.clone()), + vec![], + ) + .await?; + let user = service.get_me(claims.user_id).await?; + if user.is_deleted { + return Err(AppError::NotFoundError("User not found".to_string())); + } + Ok(ApiSuccess(UsersDetailItemDto::from(user))) +} diff --git a/imphnen-iam/src/users/infrastructure/http/handlers/mod.rs b/imphnen-iam/src/users/infrastructure/http/handlers/mod.rs new file mode 100644 index 0000000..ebbf6c6 --- /dev/null +++ b/imphnen-iam/src/users/infrastructure/http/handlers/mod.rs @@ -0,0 +1,9 @@ +pub mod get_handlers; +pub mod mutation_handlers; +pub mod profile_handlers; + +pub use get_handlers::{get_user_by_id, get_user_list, get_user_me}; +pub use mutation_handlers::{ + delete_user, patch_user_active_status, post_create_user, put_update_user, +}; +pub use profile_handlers::{put_update_user_me, upload_file}; diff --git a/imphnen-iam/src/users/infrastructure/http/handlers/mutation_handlers.rs b/imphnen-iam/src/users/infrastructure/http/handlers/mutation_handlers.rs new file mode 100644 index 0000000..d3a1967 --- /dev/null +++ b/imphnen-iam/src/users/infrastructure/http/handlers/mutation_handlers.rs @@ -0,0 +1,159 @@ +use super::super::dto::{ + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, + UsersUpdateRequestDto, +}; +use crate::require_permissions; +use crate::users::domain::{UserEntity, UserService}; +use axum::{ + Extension, Json, extract::Path, http::HeaderMap, response::IntoResponse, +}; +use imphnen_entities::{PermissionsEnum, RolesDetailQueryDto}; +use imphnen_libs::{AppState, hash_password}; +use imphnen_utils::{ApiCreated, ApiMessage, AppError}; +use std::sync::Arc; +use uuid::Uuid; + +#[utoipa::path( + post, + path = "/v1/users/create", + security(("Bearer" = [])), + request_body = UsersCreateRequestDto, + responses( + (status = 201, description = "[ADMIN] Create new user", body = imphnen_entities::ResponseSuccessDto) + ), + tag = "Users" +)] +pub async fn post_create_user( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Json(payload): Json, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::CreateUsers], { + let password_hash = hash_password(&payload.password).map_err(|_| { + AppError::InternalServerError("Failed to hash password".to_string()) + })?; + let role_id = payload.role_id.clone(); + let entity = UserEntity { + id: Uuid::new_v4().to_string(), + email: payload.email, + fullname: payload.fullname, + password: password_hash, + is_active: payload.is_active, + avatar: payload.avatar, + role: RolesDetailQueryDto { + id: role_id, + ..Default::default() + }, + ..Default::default() + }; + let user = service.create(entity).await?; + Ok(ApiCreated(UsersDetailItemDto::from(user))) + }) +} + +#[utoipa::path( + put, + path = "/v1/users/update/{id}", + security(("Bearer" = [])), + params(("id" = String, Path, description = "User ID")), + request_body = UsersUpdateRequestDto, + responses( + (status = 200, description = "[ADMIN] Update user") + ), + tag = "Users" +)] +pub async fn put_update_user( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(payload): Json, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::UpdateUsers], { + Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError("Invalid User ID format".to_string()) + })?; + let current = service + .get(id.clone()) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let password = match payload.password { + Some(ref pw) => hash_password(pw).unwrap_or_else(|_| current.password.clone()), + None => current.password.clone(), + }; + let role_id = payload.role_id.clone().unwrap_or(current.role.id.clone()); + let entity = UserEntity { + id: id.clone(), + email: payload.email.unwrap_or(current.email), + fullname: payload.fullname.unwrap_or(current.fullname), + legal_name: payload.legal_name.or(current.legal_name), + password, + avatar: payload.avatar.or(current.avatar), + is_active: payload.is_active.unwrap_or(current.is_active), + is_deleted: current.is_deleted, + role: RolesDetailQueryDto { + id: role_id, + ..current.role + }, + profile_extension: payload.profile_extension.or(current.profile_extension), + created_at: current.created_at, + updated_at: current.updated_at, + mentor_id: current.mentor_id, + }; + let msg = service.update(entity).await?; + Ok(ApiMessage::ok(&msg)) + }) +} + +#[utoipa::path( + put, + path = "/v1/users/activate/{id}", + security(("Bearer" = [])), + params(("id" = String, Path, description = "User ID")), + request_body = UsersActiveInactiveRequestDto, + responses( + (status = 200, description = "[ADMIN] Set user active status") + ), + tag = "Users" +)] +pub async fn patch_user_active_status( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(payload): Json, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::ActivateUsers], { + Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError("Invalid User ID format".to_string()) + })?; + let msg = service.set_active_status(id, payload.is_active).await?; + Ok(ApiMessage::ok(&msg)) + }) +} + +#[utoipa::path( + delete, + path = "/v1/users/delete/{id}", + security(("Bearer" = [])), + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "[ADMIN] Soft delete user") + ), + tag = "Users" +)] +pub async fn delete_user( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + require_permissions!(headers, state, [PermissionsEnum::DeleteUsers], { + Uuid::parse_str(&id).map_err(|_| { + AppError::BadRequestError("Invalid User ID format".to_string()) + })?; + let msg = service.delete(id).await?; + Ok(ApiMessage::ok(&msg)) + }) +} diff --git a/imphnen-iam/src/users/infrastructure/http/handlers/profile_handlers.rs b/imphnen-iam/src/users/infrastructure/http/handlers/profile_handlers.rs new file mode 100644 index 0000000..82d6e3e --- /dev/null +++ b/imphnen-iam/src/users/infrastructure/http/handlers/profile_handlers.rs @@ -0,0 +1,216 @@ +use super::super::dto::UsersUpdateRequestDto; +use crate::users::domain::{UserEntity, UserService}; +use axum::{ + Extension, Json, extract::Multipart, http::HeaderMap, response::IntoResponse, +}; +use imphnen_entities::{ResponseSuccessDto, RolesDetailQueryDto}; +use imphnen_libs::{AppState, ENV, hash_password}; +use imphnen_storage::{ + FileType, MinioConfig, create_minio_service_from_config, decode_base64_file, + extract_content_type_from_data_url, +}; +use imphnen_utils::{ApiMessage, ApiSuccess, AppError}; +use serde_json::json; +use std::sync::Arc; +use tracing::error; + +#[utoipa::path( + put, + path = "/v1/users/update/me", + security(("Bearer" = [])), + request_body = UsersUpdateRequestDto, + responses( + (status = 200, description = "[USER] Update current user") + ), + tag = "Users" +)] +pub async fn put_update_user_me( + headers: HeaderMap, + Extension(state): Extension, + Extension(service): Extension>, + Json(payload): Json, +) -> Result { + let (claims, _) = crate::permissions_guard( + headers, + axum::extract::Extension(state.clone()), + vec![], + ) + .await?; + let user_id = claims.user_id.clone(); + let current = service + .get_me(user_id) + .await + .map_err(|_| AppError::NotFoundError("User not found".to_string()))?; + let password = match payload.password { + Some(ref pw) => hash_password(pw).unwrap_or_else(|_| current.password.clone()), + None => current.password.clone(), + }; + let role_id = payload.role_id.clone().unwrap_or(current.role.id.clone()); + let entity = UserEntity { + id: current.id.clone(), + email: payload.email.unwrap_or(current.email), + fullname: payload.fullname.unwrap_or(current.fullname), + legal_name: payload.legal_name.or(current.legal_name), + password, + avatar: payload.avatar.or(current.avatar), + is_active: payload.is_active.unwrap_or(current.is_active), + is_deleted: current.is_deleted, + role: RolesDetailQueryDto { + id: role_id, + ..current.role + }, + profile_extension: payload.profile_extension.or(current.profile_extension), + created_at: current.created_at, + updated_at: current.updated_at, + mentor_id: current.mentor_id, + }; + let msg = service.update(entity).await?; + Ok(ApiMessage::ok(&msg)) +} + +#[utoipa::path( + post, + path = "/v1/users/upload", + security(("Bearer" = [])), + request_body( + content = super::super::dto::FileUploadSchema, + description = "Upload file with multipart form data", + content_type = "multipart/form-data" + ), + responses( + (status = 200, description = "[USER] Upload file successfully", body = ResponseSuccessDto), + (status = 400, description = "[USER] Bad request"), + (status = 401, description = "[USER] Unauthorized"), + (status = 500, description = "[USER] Internal server error") + ), + tag = "Users" +)] +pub async fn upload_file( + headers: HeaderMap, + Extension(state): Extension, + mut multipart: Multipart, +) -> Result { + let (claims, _) = crate::permissions_guard( + headers, + axum::extract::Extension(state.clone()), + vec![], + ) + .await?; + let user_id = claims.user_id.clone(); + + let minio_config = MinioConfig::from_env().map_err(|e| { + error!("Failed to load MinIO config: {}", e); + AppError::InternalServerError("MinIO configuration error".to_string()) + })?; + let bucket_name = minio_config.bucket_name.clone(); + let minio_service = create_minio_service_from_config(minio_config) + .await + .map_err(|e| { + error!("Failed to initialize MinIO service: {}", e); + AppError::InternalServerError("MinIO service initialization error".to_string()) + })?; + + let mut file_data: Option> = None; + let mut filename: Option = None; + let mut content_type: Option = None; + + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + filename = field.file_name().map(|s| s.to_string()); + content_type = field.content_type().map(|s| s.to_string()); + match field.bytes().await { + Ok(bytes) => file_data = Some(bytes.to_vec()), + Err(e) => { + error!("Failed to read file data: {}", e); + return Err(AppError::BadRequestError( + "Failed to read file data".to_string(), + )); + } + } + } + "base64_data" => { + let base64_str = field.text().await.unwrap_or_default(); + if !base64_str.is_empty() { + match decode_base64_file(&base64_str) { + Ok(decoded) => { + file_data = Some(decoded); + if let Some(ct) = extract_content_type_from_data_url(&base64_str) { + content_type = Some(ct); + } + } + Err(e) => { + error!("Failed to decode base64 data: {}", e); + return Err(AppError::BadRequestError( + "Invalid base64 data".to_string(), + )); + } + } + } + } + "filename" => filename = Some(field.text().await.unwrap_or_default()), + "content_type" => content_type = Some(field.text().await.unwrap_or_default()), + _ => {} + } + } + + let file_data = file_data + .ok_or_else(|| AppError::BadRequestError("file data is required".to_string()))?; + let filename = filename.unwrap_or_else(|| "unnamed_file".to_string()); + let content_type = + content_type.unwrap_or_else(|| "application/octet-stream".to_string()); + + let file_type = { + let ft = FileType::from_content_type(&content_type); + if matches!(ft, FileType::Unknown) { + FileType::from_filename(&filename) + } else { + ft + } + }; + if matches!(file_type, FileType::Unknown) { + return Err(AppError::BadRequestError( + "Unsupported file type".to_string(), + )); + } + if !file_type.allowed_types().contains(&content_type.as_str()) { + return Err(AppError::BadRequestError(format!( + "File type does not match content type '{content_type}'" + ))); + } + if file_data.len() > file_type.max_size() { + return Err(AppError::BadRequestError(format!( + "File too large. Maximum size for {:?} is {} bytes", + file_type, + file_type.max_size() + ))); + } + + let sanitized = user_id + .replace('%', "") + .replace(':', "_") + .replace('@', "_at_") + .replace('.', "_"); + let folder = format!("{}/{sanitized}", file_type.as_folder()); + + let object_path = minio_service + .upload_file_with_deduplication(&file_data, &content_type, &folder, &filename) + .await + .map_err(|e| { + error!("Failed to upload file: {}", e); + AppError::InternalServerError(format!("Upload failed: {e}")) + })?; + + let permanent_url = format!("{}/{}/{}", ENV.cdn_url, bucket_name, object_path); + let response_data = json!({ + "filename": filename, + "uploaded_path": object_path, + "url": permanent_url, + "size": file_data.len(), + "content_type": content_type, + "file_type": format!("{:?}", file_type).to_lowercase(), + "user_id": user_id, + }); + Ok(ApiSuccess(response_data)) +} diff --git a/imphnen-iam/src/users/infrastructure/http/mod.rs b/imphnen-iam/src/users/infrastructure/http/mod.rs index 05dcc0f..57b70f6 100644 --- a/imphnen-iam/src/users/infrastructure/http/mod.rs +++ b/imphnen-iam/src/users/infrastructure/http/mod.rs @@ -2,4 +2,4 @@ pub mod dto; pub mod handlers; pub mod routes; -pub use routes::{users_public_routes, users_protected_routes}; +pub use routes::{users_protected_routes, users_public_routes}; diff --git a/imphnen-iam/src/users/infrastructure/http/routes.rs b/imphnen-iam/src/users/infrastructure/http/routes.rs index 5cb8c1f..2fa37d4 100644 --- a/imphnen-iam/src/users/infrastructure/http/routes.rs +++ b/imphnen-iam/src/users/infrastructure/http/routes.rs @@ -1,37 +1,42 @@ -use std::sync::Arc; -use axum::{Router, routing::{delete, get, post, put}, Extension}; -use sea_orm::DatabaseConnection; -use imphnen_libs::AppState; +use super::handlers::{ + delete_user, get_user_by_id, get_user_list, get_user_me, patch_user_active_status, + post_create_user, put_update_user, put_update_user_me, upload_file, +}; use crate::users::application::UserServiceImpl; use crate::users::domain::UserService; use crate::users::infrastructure::persistence::PostgresUserRepository; -use super::handlers::{ - get_user_list, get_user_by_id, get_user_me, post_create_user, - put_update_user, put_update_user_me, patch_user_active_status, - delete_user, upload_file, +use axum::{ + Extension, Router, + routing::{delete, get, post, put}, }; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresUserRepository::new(db)); - Arc::new(UserServiceImpl::new(repo)) + let repo = Arc::new(PostgresUserRepository::new(db)); + Arc::new(UserServiceImpl::new(repo)) } pub fn users_public_routes(_db: DatabaseConnection) -> Router { - Router::new() + Router::new() } -pub fn users_protected_routes(db: DatabaseConnection, state: Arc) -> Router { - let service = build_service(db); - Router::new() - .route("/users", get(get_user_list)) - .route("/users/detail/{id}", get(get_user_by_id)) - .route("/users/me", get(get_user_me)) - .route("/users/create", post(post_create_user)) - .route("/users/update/{id}", put(put_update_user)) - .route("/users/update/me", put(put_update_user_me)) - .route("/users/activate/{id}", put(patch_user_active_status)) - .route("/users/delete/{id}", delete(delete_user)) - .route("/users/upload", post(upload_file)) - .layer(Extension(service)) - .layer(Extension((*state).clone())) +pub fn users_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/users", get(get_user_list)) + .route("/users/detail/{id}", get(get_user_by_id)) + .route("/users/me", get(get_user_me)) + .route("/users/create", post(post_create_user)) + .route("/users/update/{id}", put(put_update_user)) + .route("/users/update/me", put(put_update_user_me)) + .route("/users/activate/{id}", put(patch_user_active_status)) + .route("/users/delete/{id}", delete(delete_user)) + .route("/users/upload", post(upload_file)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) } diff --git a/imphnen-iam/src/users/infrastructure/persistence/mod.rs b/imphnen-iam/src/users/infrastructure/persistence/mod.rs index 0e058cc..eacd578 100644 --- a/imphnen-iam/src/users/infrastructure/persistence/mod.rs +++ b/imphnen-iam/src/users/infrastructure/persistence/mod.rs @@ -1,2 +1,3 @@ +pub mod postgres_user_queries; pub mod postgres_user_repository; pub use postgres_user_repository::PostgresUserRepository; diff --git a/imphnen-iam/src/users/infrastructure/persistence/postgres_user_queries.rs b/imphnen-iam/src/users/infrastructure/persistence/postgres_user_queries.rs new file mode 100644 index 0000000..82175a2 --- /dev/null +++ b/imphnen-iam/src/users/infrastructure/persistence/postgres_user_queries.rs @@ -0,0 +1,152 @@ +#![allow(clippy::field_reassign_with_default)] +use crate::users::domain::UserListItem; +use imphnen_entities::{ + PermissionsQueryDto, RolesDetailQueryDto, UsersDetailQueryDto, + seaorm::auth::roles::Entity as RolesEntity, + seaorm::auth::users::{Column as UserColumn, Entity as UsersEntity}, +}; +use imphnen_utils::AppError; +use paginator_rs::{PaginationParams, SortDirection}; +use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +use sea_orm::prelude::*; +use sea_orm::{Order, PaginatorTrait, QueryOrder}; +use std::sync::Arc; + +pub fn build_role_dto( + role: Option, +) -> RolesDetailQueryDto { + role.map_or_else(RolesDetailQueryDto::default, |r| RolesDetailQueryDto { + id: r.id.to_string(), + name: r.name, + permissions: r.permissions.clone().and_then(|json| { + serde_json::from_value::>(json) + .ok() + .map(|list| { + list + .into_iter() + .map(|p| { + Some(PermissionsQueryDto { + id: Some(p.clone()), + name: Some(p), + created_at: None, + updated_at: None, + }) + }) + .collect() + }) + }), + is_deleted: r.deleted_at.is_some(), + created_at: Some(r.created_at.to_rfc3339()), + updated_at: Some(r.updated_at.to_rfc3339()), + }) +} + +pub fn build_user_dto( + user: imphnen_entities::seaorm::auth::users::Model, + role_dto: RolesDetailQueryDto, +) -> UsersDetailQueryDto { + let mut dto = UsersDetailQueryDto::default(); + dto.id = user.id.to_string(); + dto.fullname = format!( + "{} {}", + user.first_name.as_deref().unwrap_or(""), + user.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(); + dto.legal_name = None; + dto.email = user.email; + dto.avatar = user.avatar_url; + dto.is_active = user.is_active; + dto.is_deleted = user.deleted_at.is_some(); + dto.profile_extension = user.metadata.and_then(|m| serde_json::from_value(m).ok()); + dto.password = user.password_hash; + dto.role = role_dto; + dto.created_at = user.created_at.to_rfc3339(); + dto.updated_at = user.updated_at.to_rfc3339(); + dto.mentor_id = None; + dto +} + +pub async fn query_user_list( + db: &Arc, + params: PaginationParams, +) -> Result, AppError> { + let page = params.page.max(1); + let per_page = params.per_page.clamp(1, 100); + + let mut query = UsersEntity::find() + .filter(UserColumn::DeletedAt.is_null()) + .filter(UserColumn::IsActive.eq(true)); + + if let Some(ref search) = params.search { + query = query.filter( + UserColumn::Email + .contains(&search.query) + .or(UserColumn::FirstName.contains(&search.query)) + .or(UserColumn::LastName.contains(&search.query)), + ); + } + + let order = match params.sort_direction { + Some(SortDirection::Desc) => Order::Desc, + _ => Order::Asc, + }; + query = match params.sort_by.as_deref() { + Some("email") => query.order_by(UserColumn::Email, order), + _ => query.order_by(UserColumn::CreatedAt, order), + }; + + let paginator = query.paginate(db.as_ref(), per_page as u64); + let users = paginator + .fetch_page((page - 1) as u64) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let role_ids: Vec = users.iter().filter_map(|u| u.role_id).collect(); + let roles = if !role_ids.is_empty() { + RolesEntity::find() + .filter(imphnen_entities::seaorm::auth::roles::Column::Id.is_in(role_ids)) + .all(db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .into_iter() + .map(|r| (r.id, r.name)) + .collect::>() + } else { + std::collections::HashMap::new() + }; + + let data: Vec = users + .into_iter() + .map(|user| { + let role_name = user + .role_id + .and_then(|rid| roles.get(&rid).cloned()) + .unwrap_or_default(); + UserListItem { + id: user.id.to_string(), + role: role_name, + fullname: format!( + "{} {}", + user.first_name.as_deref().unwrap_or(""), + user.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(), + email: user.email, + avatar: user.avatar_url, + is_active: user.is_active, + created_at: user.created_at.to_rfc3339(), + updated_at: user.updated_at.to_rfc3339(), + } + }) + .collect(); + + let total = paginator + .num_items() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let meta = PaginatorResponseMeta::new(page, per_page, total as u32); + Ok(PaginatorResponse { data, meta }) +} diff --git a/imphnen-iam/src/users/infrastructure/persistence/postgres_user_repository.rs b/imphnen-iam/src/users/infrastructure/persistence/postgres_user_repository.rs index c62c790..029c55c 100644 --- a/imphnen-iam/src/users/infrastructure/persistence/postgres_user_repository.rs +++ b/imphnen-iam/src/users/infrastructure/persistence/postgres_user_repository.rs @@ -1,306 +1,192 @@ #![allow(clippy::field_reassign_with_default)] -use std::sync::Arc; -use async_trait::async_trait; -use sea_orm::prelude::*; -use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait}; -use paginator_rs::{PaginationParams, SortDirection}; -use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; -use uuid::Uuid; -use chrono::Utc; -use imphnen_utils::AppError; -use imphnen_entities::{ - UsersDetailQueryDto, RolesDetailQueryDto, PermissionsQueryDto, - seaorm::auth::users::{Entity as UsersEntity, ActiveModel as UserActiveModel, Column as UserColumn}, - seaorm::auth::roles::Entity as RolesEntity, +use super::postgres_user_queries::{ + build_role_dto, build_user_dto, query_user_list, }; use crate::users::domain::{UserEntity, UserListItem, UserRepository}; +use async_trait::async_trait; +use chrono::Utc; +use imphnen_entities::{ + UsersDetailQueryDto, + seaorm::auth::roles::Entity as RolesEntity, + seaorm::auth::users::{ + ActiveModel as UserActiveModel, Column as UserColumn, Entity as UsersEntity, + }, +}; +use imphnen_utils::AppError; +use paginator_rs::PaginationParams; +use paginator_utils::PaginatorResponse; +use sea_orm::ActiveValue; +use sea_orm::prelude::*; +use std::sync::Arc; +use uuid::Uuid; fn user_detail_to_entity(dto: UsersDetailQueryDto) -> UserEntity { - UserEntity { - id: dto.id, - email: dto.email, - fullname: dto.fullname, - legal_name: dto.legal_name, - password: dto.password, - avatar: dto.avatar, - is_active: dto.is_active, - is_deleted: dto.is_deleted, - role: dto.role, - profile_extension: dto.profile_extension, - created_at: dto.created_at, - updated_at: dto.updated_at, - mentor_id: dto.mentor_id, - } -} - -fn build_role_dto(role: Option) -> RolesDetailQueryDto { - role.map_or_else(RolesDetailQueryDto::default, |r| RolesDetailQueryDto { - id: r.id.to_string(), - name: r.name, - permissions: r.permissions.clone().and_then(|json| { - serde_json::from_value::>(json).ok().map(|list| { - list.into_iter().map(|p| Some(PermissionsQueryDto { - id: Some(p.clone()), - name: Some(p), - created_at: None, - updated_at: None, - })).collect() - }) - }), - is_deleted: r.deleted_at.is_some(), - created_at: Some(r.created_at.to_rfc3339()), - updated_at: Some(r.updated_at.to_rfc3339()), - }) + UserEntity { + id: dto.id, + email: dto.email, + fullname: dto.fullname, + legal_name: dto.legal_name, + password: dto.password, + avatar: dto.avatar, + is_active: dto.is_active, + is_deleted: dto.is_deleted, + role: dto.role, + profile_extension: dto.profile_extension, + created_at: dto.created_at, + updated_at: dto.updated_at, + mentor_id: dto.mentor_id, + } } pub struct PostgresUserRepository { - db: Arc, + db: Arc, } impl PostgresUserRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } - } + pub fn new(db: DatabaseConnection) -> Self { + Self { db: Arc::new(db) } + } } #[async_trait] impl UserRepository for PostgresUserRepository { - async fn find_all(&self, params: PaginationParams) -> Result, AppError> { - let page = params.page.max(1); - let per_page = params.per_page.clamp(1, 100); + async fn find_all( + &self, + params: PaginationParams, + ) -> Result, AppError> { + query_user_list(&self.db, params).await + } - let mut query = UsersEntity::find() - .filter(UserColumn::DeletedAt.is_null()) - .filter(UserColumn::IsActive.eq(true)); + async fn find_by_id(&self, id: &str) -> Result { + let user_id = Uuid::parse_str(id) + .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; + let (user, role) = UsersEntity::find_by_id(user_id) + .filter(UserColumn::DeletedAt.is_null()) + .find_also_related(RolesEntity) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("User not found in database".into()))?; + Ok(user_detail_to_entity( + build_user_dto(user, build_role_dto(role)).from_profile_extension(), + )) + } - if let Some(ref search) = params.search { - query = query.filter( - UserColumn::Email.contains(&search.query) - .or(UserColumn::FirstName.contains(&search.query)) - .or(UserColumn::LastName.contains(&search.query)) - ); - } + async fn find_by_email(&self, email: String) -> Result { + let (user, role) = UsersEntity::find() + .filter(UserColumn::Email.eq(&email)) + .filter(UserColumn::DeletedAt.is_null()) + .find_also_related(RolesEntity) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("User not found".into()))?; + Ok(user_detail_to_entity( + build_user_dto(user, build_role_dto(role)).from_profile_extension(), + )) + } - let order = match params.sort_direction { - Some(SortDirection::Desc) => Order::Desc, - _ => Order::Asc, - }; - query = match params.sort_by.as_deref() { - Some("email") => query.order_by(UserColumn::Email, order), - _ => query.order_by(UserColumn::CreatedAt, order), - }; + async fn create(&self, entity: UserEntity) -> Result { + let existing = UsersEntity::find() + .filter(UserColumn::Email.eq(entity.email.clone())) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + if existing.is_some() { + return Err(AppError::ConflictError( + "User with this email already exists".into(), + )); + } + let full_name = entity.fullname.clone(); + let (first_name, last_name) = + full_name.split_once(' ').unwrap_or((&full_name, "")); + let role_id = entity + .role + .id + .parse::() + .ok() + .or_else(|| entity.role.id.is_empty().then_some(Uuid::nil())); + let active_model = UserActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + email: ActiveValue::Set(entity.email.clone()), + password_hash: ActiveValue::Set(entity.password), + username: ActiveValue::Set(entity.email.clone()), + first_name: ActiveValue::Set(Some(first_name.to_string())), + last_name: ActiveValue::Set(Some(last_name.to_string())), + avatar_url: ActiveValue::Set(entity.avatar), + is_verified: ActiveValue::Set(false), + is_active: ActiveValue::Set(entity.is_active), + metadata: ActiveValue::Set( + entity + .profile_extension + .map(|p| serde_json::to_value(p).unwrap_or_default()), + ), + created_at: ActiveValue::Set(Utc::now()), + updated_at: ActiveValue::Set(Utc::now()), + deleted_at: ActiveValue::Set(None), + role_id: ActiveValue::Set(role_id.filter(|id| !id.is_nil())), + }; + UsersEntity::insert(active_model) + .exec(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok("Successfully created user".into()) + } - let paginator = query.paginate(self.db.as_ref(), per_page as u64); - let users = paginator.fetch_page((page - 1) as u64).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; + async fn update(&self, entity: UserEntity) -> Result { + let user_id = Uuid::parse_str(&entity.id) + .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; + let mut active_model: UserActiveModel = UsersEntity::find_by_id(user_id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("User not found".into()))? + .into(); + let full_name = entity.fullname.clone(); + let (first_name, last_name) = + full_name.split_once(' ').unwrap_or((&full_name, "")); + active_model.email = ActiveValue::Set(entity.email); + active_model.first_name = ActiveValue::Set(Some(first_name.to_string())); + active_model.last_name = ActiveValue::Set(Some(last_name.to_string())); + active_model.avatar_url = ActiveValue::Set(entity.avatar); + active_model.is_active = ActiveValue::Set(entity.is_active); + active_model.updated_at = ActiveValue::Set(Utc::now()); + if !entity.password.is_empty() { + active_model.password_hash = ActiveValue::Set(entity.password); + } + let role_id = entity.role.id.parse::().ok(); + if role_id.is_some() { + active_model.role_id = ActiveValue::Set(role_id); + } + if entity.profile_extension.is_some() { + active_model.metadata = ActiveValue::Set( + entity + .profile_extension + .map(|p| serde_json::to_value(p).unwrap_or_default()), + ); + } + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok("Success update user".into()) + } - let role_ids: Vec = users.iter().filter_map(|u| u.role_id).collect(); - let roles = if !role_ids.is_empty() { - RolesEntity::find() - .filter(imphnen_entities::seaorm::auth::roles::Column::Id.is_in(role_ids)) - .all(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .into_iter() - .map(|r| (r.id, r.name)) - .collect::>() - } else { - std::collections::HashMap::new() - }; - - let data: Vec = users.into_iter().map(|user| { - let role_name = user.role_id.and_then(|rid| roles.get(&rid).cloned()).unwrap_or_default(); - UserListItem { - id: user.id.to_string(), - role: role_name, - fullname: format!("{} {}", - user.first_name.as_deref().unwrap_or(""), - user.last_name.as_deref().unwrap_or("") - ).trim().to_string(), - email: user.email, - avatar: user.avatar_url, - is_active: user.is_active, - created_at: user.created_at.to_rfc3339(), - updated_at: user.updated_at.to_rfc3339(), - } - }).collect(); - - let total = paginator.num_items().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let meta = PaginatorResponseMeta::new(page, per_page, total as u32); - Ok(PaginatorResponse { data, meta }) - } - - async fn find_by_id(&self, id: &str) -> Result { - let user_id = Uuid::parse_str(id) - .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; - - let (user, role) = UsersEntity::find_by_id(user_id) - .filter(UserColumn::DeletedAt.is_null()) - .find_also_related(RolesEntity) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("User not found in database".into()))?; - - let role_dto = build_role_dto(role); - - let mut dto = UsersDetailQueryDto::default(); - dto.id = user.id.to_string(); - dto.fullname = format!("{} {}", - user.first_name.as_deref().unwrap_or(""), - user.last_name.as_deref().unwrap_or("") - ).trim().to_string(); - dto.legal_name = None; - dto.email = user.email; - dto.avatar = user.avatar_url; - dto.is_active = user.is_active; - dto.is_deleted = user.deleted_at.is_some(); - dto.profile_extension = user.metadata.and_then(|m| serde_json::from_value(m).ok()); - dto.password = user.password_hash; - dto.role = role_dto; - dto.created_at = user.created_at.to_rfc3339(); - dto.updated_at = user.updated_at.to_rfc3339(); - dto.mentor_id = None; - - Ok(user_detail_to_entity(dto.from_profile_extension())) - } - - async fn find_by_email(&self, email: String) -> Result { - let (user, role) = UsersEntity::find() - .filter(UserColumn::Email.eq(&email)) - .filter(UserColumn::DeletedAt.is_null()) - .find_also_related(RolesEntity) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("User not found".into()))?; - - let role_dto = build_role_dto(role); - - let mut dto = UsersDetailQueryDto::default(); - dto.id = user.id.to_string(); - dto.fullname = format!("{} {}", - user.first_name.as_deref().unwrap_or(""), - user.last_name.as_deref().unwrap_or("") - ).trim().to_string(); - dto.legal_name = None; - dto.email = user.email; - dto.avatar = user.avatar_url; - dto.is_active = user.is_active; - dto.is_deleted = user.deleted_at.is_some(); - dto.profile_extension = user.metadata.and_then(|m| serde_json::from_value(m).ok()); - dto.password = user.password_hash; - dto.role = role_dto; - dto.created_at = user.created_at.to_rfc3339(); - dto.updated_at = user.updated_at.to_rfc3339(); - dto.mentor_id = None; - - Ok(user_detail_to_entity(dto.from_profile_extension())) - } - - async fn create(&self, entity: UserEntity) -> Result { - // Check for existing user - let existing = UsersEntity::find() - .filter(UserColumn::Email.eq(entity.email.clone())) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - if existing.is_some() { - return Err(AppError::ConflictError("User with this email already exists".into())); - } - - let full_name = entity.fullname.clone(); - let (first_name, last_name) = full_name.split_once(' ').unwrap_or((&full_name, "")); - - let role_id = entity.role.id.parse::().ok() - .or_else(|| entity.role.id.is_empty().then_some(Uuid::nil())); - - let active_model = UserActiveModel { - id: ActiveValue::Set(Uuid::new_v4()), - email: ActiveValue::Set(entity.email.clone()), - password_hash: ActiveValue::Set(entity.password), - username: ActiveValue::Set(entity.email.clone()), - first_name: ActiveValue::Set(Some(first_name.to_string())), - last_name: ActiveValue::Set(Some(last_name.to_string())), - avatar_url: ActiveValue::Set(entity.avatar), - is_verified: ActiveValue::Set(false), - is_active: ActiveValue::Set(entity.is_active), - metadata: ActiveValue::Set( - entity.profile_extension.map(|p| serde_json::to_value(p).unwrap_or_default()) - ), - created_at: ActiveValue::Set(Utc::now()), - updated_at: ActiveValue::Set(Utc::now()), - deleted_at: ActiveValue::Set(None), - role_id: ActiveValue::Set(role_id.filter(|id| !id.is_nil())), - }; - - UsersEntity::insert(active_model).exec(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok("Successfully created user".into()) - } - - async fn update(&self, entity: UserEntity) -> Result { - let user_id = Uuid::parse_str(&entity.id) - .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; - - let mut active_model: UserActiveModel = UsersEntity::find_by_id(user_id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("User not found".into()))? - .into(); - - let full_name = entity.fullname.clone(); - let (first_name, last_name) = full_name.split_once(' ').unwrap_or((&full_name, "")); - - active_model.email = ActiveValue::Set(entity.email); - active_model.first_name = ActiveValue::Set(Some(first_name.to_string())); - active_model.last_name = ActiveValue::Set(Some(last_name.to_string())); - active_model.avatar_url = ActiveValue::Set(entity.avatar); - active_model.is_active = ActiveValue::Set(entity.is_active); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - if !entity.password.is_empty() { - active_model.password_hash = ActiveValue::Set(entity.password); - } - - let role_id = entity.role.id.parse::().ok(); - if role_id.is_some() { - active_model.role_id = ActiveValue::Set(role_id); - } - - if entity.profile_extension.is_some() { - active_model.metadata = ActiveValue::Set( - entity.profile_extension.map(|p| serde_json::to_value(p).unwrap_or_default()) - ); - } - - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok("Success update user".into()) - } - - async fn delete(&self, id: String) -> Result { - let user_id = Uuid::parse_str(&id) - .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; - - let mut active_model: UserActiveModel = UsersEntity::find_by_id(user_id) - .one(self.db.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))? - .ok_or_else(|| AppError::NotFoundError("User not found".into()))? - .into(); - - active_model.deleted_at = ActiveValue::Set(Some(Utc::now())); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - active_model.update(self.db.as_ref()).await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok("Success delete user".into()) - } + async fn delete(&self, id: String) -> Result { + let user_id = Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid user ID".into()))?; + let mut active_model: UserActiveModel = UsersEntity::find_by_id(user_id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("User not found".into()))? + .into(); + active_model.deleted_at = ActiveValue::Set(Some(Utc::now())); + active_model.updated_at = ActiveValue::Set(Utc::now()); + active_model + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok("Success delete user".into()) + } } diff --git a/imphnen-iam/src/users/mod.rs b/imphnen-iam/src/users/mod.rs index 7447b5d..867834d 100644 --- a/imphnen-iam/src/users/mod.rs +++ b/imphnen-iam/src/users/mod.rs @@ -1,5 +1,7 @@ -pub mod domain; pub mod application; +pub mod domain; pub mod infrastructure; -pub use infrastructure::http::routes::{users_public_routes, users_protected_routes}; +pub use infrastructure::http::routes::{ + users_protected_routes, users_public_routes, +}; diff --git a/imphnen-iam/src/v2/.gitkeep b/imphnen-iam/src/v2/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/imphnen-libs/Cargo.toml b/imphnen-libs/Cargo.toml index 8bf6cc3..58090de 100644 --- a/imphnen-libs/Cargo.toml +++ b/imphnen-libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-libs" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] @@ -14,18 +14,12 @@ serde.workspace = true serde_json.workspace = true zod-rs.workspace = true argon2.workspace = true -lettre.workspace = true chrono.workspace = true jsonwebtoken.workspace = true dotenvy.workspace = true anyhow.workspace = true uuid.workspace = true -base64.workspace = true reqwest.workspace = true -sha2.workspace = true -hmac.workspace = true -hex.workspace = true -urlencoding.workspace = true async-trait.workspace = true thiserror.workspace = true env_logger.workspace = true diff --git a/imphnen-libs/src/argon/mod.rs b/imphnen-libs/src/argon/mod.rs index 3b1d2ad..6367b7b 100644 --- a/imphnen-libs/src/argon/mod.rs +++ b/imphnen-libs/src/argon/mod.rs @@ -1,72 +1,25 @@ -//! Argon2 password hashing utilities. -//! -//! This module provides secure password hashing and verification using the Argon2 algorithm. -//! The hashing parameters are configured for a balance between security and performance. - -use argon2::{ - password_hash::{ - rand_core::OsRng, Error, PasswordHash, PasswordHasher, PasswordVerifier, - SaltString, - }, - Argon2, -}; - -/// Hash a password using Argon2id algorithm. -/// -/// This function generates a cryptographically secure salt and hashes the password -/// with predefined parameters optimized for a balance of security and performance. -/// -/// # Arguments -/// * `password` - The plain text password to hash -/// -/// # Returns -/// * `Ok(String)` - The hashed password in PHC string format -/// * `Err(Error)` - If hashing fails -/// -/// # Example -/// ``` -/// use imphnen_libs::hash_password; -/// -/// let hash = hash_password("my_password")?; -/// assert!(hash.starts_with("$argon2id$")); -/// # Ok::<(), argon2::password_hash::Error>(()) -/// ``` -pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(password.as_bytes(), &salt)? - .to_string(); - Ok(password_hash) -} - -/// Verify a password against its hash. -/// -/// This function checks if the provided password matches the given hash. -/// Returns false for both incorrect passwords and invalid hash formats. -/// -/// # Arguments -/// * `password` - The plain text password to verify -/// * `hash` - The hashed password in PHC string format -/// -/// # Returns -/// * `Ok(bool)` - true if password matches, false otherwise -/// * `Err(Error)` - If hash parsing fails -/// -/// # Example -/// ``` -/// use imphnen_libs::{hash_password, verify_password}; -/// -/// let hash = hash_password("my_password")?; -/// assert!(verify_password("my_password", &hash)?); -/// assert!(!verify_password("wrong_password", &hash)?); -/// # Ok::<(), argon2::password_hash::Error>(()) -/// ``` -pub fn verify_password(password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash)?; - let argon2 = Argon2::default(); - match argon2.verify_password(password.as_bytes(), &parsed_hash) { - Ok(_) => Ok(true), - Err(_) => Ok(false), - } -} +use argon2::{ + Argon2, + password_hash::{ + Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, + rand_core::OsRng, + }, +}; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + Ok(password_hash) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash)?; + let argon2 = Argon2::default(); + match argon2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} diff --git a/imphnen-libs/src/axum/app_state.rs b/imphnen-libs/src/axum/app_state.rs new file mode 100644 index 0000000..d2ba483 --- /dev/null +++ b/imphnen-libs/src/axum/app_state.rs @@ -0,0 +1,59 @@ +use crate::postgres::{ + AppStatePostgresExt, PostgresConfig, PostgresConnection, PostgresError, +}; +use crate::services::{AuthRepositoryTrait, UserLookupService}; +use std::sync::Arc; + +pub struct PostgresClients { + pub main: Arc, + pub read_only: Option>, + pub test: Option>, +} + +impl PostgresClients { + pub fn new(main: Arc) -> Self { + Self { + main, + read_only: None, + test: None, + } + } + + pub fn with_read_only(mut self, read_only: Arc) -> Self { + self.read_only = Some(read_only); + self + } + + pub fn with_test(mut self, test: Arc) -> Self { + self.test = Some(test); + self + } +} + +#[derive(Clone)] +pub struct AppState { + pub postgres_connection: Arc, + pub user_lookup_service: Arc, + pub auth_repository: Arc, +} + +impl AppState { + pub async fn new( + postgres_config: PostgresConfig, + user_lookup_service: Arc, + auth_repository: Arc, + ) -> Result { + let postgres_connection = PostgresConnection::new(postgres_config).await?; + Ok(Self { + postgres_connection: Arc::new(postgres_connection), + user_lookup_service, + auth_repository, + }) + } +} + +impl AppStatePostgresExt for AppState { + fn postgres_connection(&self) -> &PostgresConnection { + &self.postgres_connection + } +} diff --git a/imphnen-libs/src/axum/mod.rs b/imphnen-libs/src/axum/mod.rs index aec02e5..b2c3a3f 100644 --- a/imphnen-libs/src/axum/mod.rs +++ b/imphnen-libs/src/axum/mod.rs @@ -1,333 +1,209 @@ -//! Axum server initialization utilities. -//! -//! This module provides utilities for initializing and running an Axum web server -//! with PostgreSQL database connections and comprehensive error handling. - -pub mod validated_json; -pub mod zod_validate; - -use axum::{Router, serve}; -use std::{future::Future, net::SocketAddr}; -use tokio::net::TcpListener; -use crate::environment::ENV; -use crate::postgres::{PostgresConnection, PostgresConfig, PostgresError}; -use sea_orm::DbErr; -use std::sync::Arc; - -pub use validated_json::ValidatedJson; -pub use zod_validate::ZodValidate; - -/// PostgreSQL database clients for different connection types -pub struct PostgresClients { - /// Main PostgreSQL connection for production use - pub main: Arc, - /// Read-only PostgreSQL connection for read-heavy operations - pub read_only: Option>, - /// Test PostgreSQL connection for testing scenarios - pub test: Option>, -} - -impl PostgresClients { - /// Create new PostgreSQL clients with main connection - pub fn new(main: Arc) -> Self { - Self { - main, - read_only: None, - test: None, - } - } - - /// Add read-only connection - pub fn with_read_only(mut self, read_only: Arc) -> Self { - self.read_only = Some(read_only); - self - } - - /// Add test connection - pub fn with_test(mut self, test: Arc) -> Self { - self.test = Some(test); - self - } -} - -/// Comprehensive server configuration -pub struct ServerConfig { - /// Server port - pub port: u16, - /// Server host - pub host: String, - /// Maximum request body size in bytes - pub max_request_size: usize, - /// Request timeout in seconds - pub request_timeout: u64, - /// Number of worker threads - pub worker_threads: usize, - /// Enable request logging - pub enable_logging: bool, - /// Enable request tracing - pub enable_tracing: bool, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - port: 3000, - host: "0.0.0.0".to_string(), - max_request_size: 10 * 1024 * 1024, // 10MB - request_timeout: 30, - worker_threads: std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4), - enable_logging: true, - enable_tracing: true, - } - } -} - -/// Server initialization error -#[derive(Debug, thiserror::Error)] -pub enum ServerInitError { - #[error("Database connection failed: {0}")] - DatabaseConnectionFailed(#[from] PostgresError), - - #[error("Network binding failed: {0}")] - NetworkBindingFailed(String), - - #[error("Configuration error: {0}")] - ConfigurationError(String), - - #[error("Server startup failed: {0}")] - ServerStartupFailed(String), -} - -/// Initialize and start the Axum server with PostgreSQL connections. -/// -/// This function provides a robust server initialization with comprehensive error handling, -/// multiple database connection support, and extensive logging. -/// -/// # Arguments -/// * `router_fn` - A function that takes PostgreSQL clients and returns a Router -/// * `config` - Optional server configuration (uses defaults if None) -/// * `postgres_config` - PostgreSQL configuration -/// -/// # Returns -/// Result indicating success or detailed error information -/// -/// # Example -/// ```no_run -/// use axum::Router; -/// use imphnen_libs::axum::{axum_init_advanced, PostgresClients, ServerConfig}; -/// use imphnen_libs::postgres::PostgresConfig; -/// use std::sync::Arc; -/// -/// async fn create_router(clients: PostgresClients) -> Router { -/// Router::new() -/// // Add your routes here -/// } -/// -/// #[tokio::main] -/// async fn main() -> Result<(), Box> { -/// let postgres_config = PostgresConfig::from_env()?; -/// let server_config = ServerConfig::default(); -/// -/// axum_init_advanced(create_router, Some(server_config), postgres_config).await?; -/// Ok(()) -/// } -/// ``` -pub async fn axum_init_advanced( - router_fn: F, - config: Option, - postgres_config: PostgresConfig, -) -> Result<(), ServerInitError> -where - F: FnOnce(PostgresClients) -> Fut, - Fut: Future, -{ - let server_config = config.unwrap_or_default(); - let _env = &ENV; - - // Initialize logging if enabled - - - // Initialize tracing if enabled - - - log::info!("Starting server initialization with PostgreSQL support"); - - // Initialize PostgreSQL connections with retry logic - let main_connection = match PostgresConnection::new(postgres_config.clone()).await { - Ok(conn) => { - log::info!("Main PostgreSQL connection established successfully"); - Arc::new(conn) - } - Err(e) => { - log::error!("Failed to establish main PostgreSQL connection: {}", e); - return Err(ServerInitError::DatabaseConnectionFailed(e)); - } - }; - - // Test the connection - match test_postgres_connection(&main_connection).await { - Ok(()) => log::info!("PostgreSQL connection test passed"), - Err(e) => { - log::error!("PostgreSQL connection test failed: {}", e); - return Err(ServerInitError::DatabaseConnectionFailed(e)); - } - } - - // Create PostgreSQL clients - let postgres_clients = PostgresClients::new(main_connection); - - log::info!("PostgreSQL clients initialized successfully"); - - // Build the router - let router = router_fn(postgres_clients).await; - - // Configure the server - let port = server_config.port; - let host = server_config.host.clone(); - let addr = format!("{host}:{port}"); - let socket_addr: SocketAddr = addr.parse() - .map_err(|e| ServerInitError::ConfigurationError(format!("Invalid address '{addr}': {e}")))?; - - log::info!("Configuring server to listen on {}", socket_addr); - - // Bind to the address - let listener = TcpListener::bind(&socket_addr) - .await - .map_err(|e| ServerInitError::NetworkBindingFailed(format!("Failed to bind to {socket_addr}: {e}")))?; - - log::info!("Server successfully bound to {}", socket_addr); - - // Start the server with graceful shutdown - log::info!("Server starting on {}", socket_addr); - - // Set up graceful shutdown - let shutdown_handle = setup_graceful_shutdown(); - - // Run the server - let server_handle = tokio::spawn(async move { - if let Err(err) = serve(listener, router).await { - log::error!("Server encountered an error: {}", err); - Err(ServerInitError::ServerStartupFailed(err.to_string())) - } else { - Ok(()) - } - }); - - // Wait for shutdown signal or server error - tokio::select! { - result = server_handle => { - match result { - Ok(Ok(())) => { - log::info!("Server stopped gracefully"); - Ok(()) - } - Ok(Err(e)) => { - log::error!("Server error: {}", e); - Err(e) - } - Err(e) => { - log::error!("Server task panicked: {}", e); - Err(ServerInitError::ServerStartupFailed("Server task panicked".to_string())) - } - } - } - _ = shutdown_handle => { - log::info!("Received shutdown signal, stopping server gracefully"); - Ok(()) - } - } -} - -/// Simple server initialization (backward compatibility) -pub async fn axum_init(router_fn: F) -> Result<(), ServerInitError> -where - F: FnOnce(PostgresClients) -> Fut, - Fut: Future, -{ - let postgres_config = PostgresConfig::from_env() - .map_err(|e| ServerInitError::ConfigurationError(format!("Failed to load PostgreSQL config: {e}")))?; - - let server_config = ServerConfig { - port: ENV.port, - ..ServerConfig::default() - }; - - axum_init_advanced(router_fn, Some(server_config), postgres_config).await -} - -/// Test PostgreSQL connection with comprehensive checks -async fn test_postgres_connection(connection: &Arc) -> Result<(), PostgresError> { - // Test basic connectivity - let test_query = sea_orm::Statement::from_string( - connection.get_database_backend(), - "SELECT 1 as test_value".to_string() - ); - - let result = connection.query_one(test_query).await?; - - match result { - Some(query_result) => { - let test_value: Option = query_result.try_get("", "test_value").ok(); - if test_value == Some(1) { - log::debug!("PostgreSQL connection test successful"); - Ok(()) - } else { - Err(PostgresError::ConnectionError(DbErr::Custom( - "Connection test query returned unexpected result".to_string() - ))) - } - } - None => Err(PostgresError::ConnectionError(DbErr::Custom( - "Connection test query returned no results".to_string() - ))), - } -} - -/// Set up graceful shutdown handling -async fn setup_graceful_shutdown() { - use tokio::signal; - - match signal::ctrl_c().await { - Ok(()) => { - log::info!("Received Ctrl+C, initiating graceful shutdown"); - } - Err(err) => { - log::error!("Unable to listen for shutdown signal: {}", err); - // Wait forever if we can't listen for signal - std::future::pending::<()>().await; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_server_config_default() { - let config = ServerConfig::default(); - assert_eq!(config.port, 3000); - assert_eq!(config.host, "0.0.0.0"); - assert_eq!(config.max_request_size, 10 * 1024 * 1024); - assert_eq!(config.request_timeout, 30); - assert!(config.enable_logging); - assert!(config.enable_tracing); - } - - #[test] - fn test_postgres_clients_creation() { - // This is a basic test - in real scenarios you'd mock the connection - let mock_config = PostgresConfig::default(); - // Note: We can't test actual connection without a real database - // This test just verifies the struct creation logic - } - - #[tokio::test] - async fn test_server_init_error_types() { - let error = ServerInitError::ConfigurationError("Test error".to_string()); - assert_eq!(error.to_string(), "Configuration error: Test error"); - - let error = ServerInitError::NetworkBindingFailed("Bind failed".to_string()); - assert_eq!(error.to_string(), "Network binding failed: Bind failed"); - } -} +pub mod app_state; +pub mod validated_json; +pub mod zod_validate; + +pub use app_state::{AppState, PostgresClients}; +pub use validated_json::ValidatedJson; +pub use zod_validate::ZodValidate; + +use crate::environment::ENV; +use crate::postgres::{PostgresConfig, PostgresConnection, PostgresError}; +use axum::{Router, serve}; +use std::sync::Arc; +use std::{future::Future, net::SocketAddr}; +use tokio::net::TcpListener; + +pub struct ServerConfig { + pub port: u16, + pub host: String, + pub max_request_size: usize, + pub request_timeout: u64, + pub worker_threads: usize, + pub enable_logging: bool, + pub enable_tracing: bool, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: 3000, + host: "0.0.0.0".to_string(), + max_request_size: 10 * 1024 * 1024, + request_timeout: 30, + worker_threads: std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4), + enable_logging: true, + enable_tracing: true, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ServerInitError { + #[error("Database connection failed: {0}")] + DatabaseConnectionFailed(#[from] PostgresError), + + #[error("Network binding failed: {0}")] + NetworkBindingFailed(String), + + #[error("Configuration error: {0}")] + ConfigurationError(String), + + #[error("Server startup failed: {0}")] + ServerStartupFailed(String), +} + +pub async fn axum_init_advanced( + router_fn: F, + config: Option, + postgres_config: PostgresConfig, +) -> Result<(), ServerInitError> +where + F: FnOnce(PostgresClients) -> Fut, + Fut: Future, +{ + let server_config = config.unwrap_or_default(); + let _env = &ENV; + + log::info!("Starting server initialization with PostgreSQL support"); + + let main_connection = match PostgresConnection::new(postgres_config.clone()).await + { + Ok(conn) => { + log::info!("Main PostgreSQL connection established successfully"); + Arc::new(conn) + } + Err(e) => { + log::error!("Failed to establish main PostgreSQL connection: {}", e); + return Err(ServerInitError::DatabaseConnectionFailed(e)); + } + }; + + match test_postgres_connection(&main_connection).await { + Ok(()) => log::info!("PostgreSQL connection test passed"), + Err(e) => { + log::error!("PostgreSQL connection test failed: {}", e); + return Err(ServerInitError::DatabaseConnectionFailed(e)); + } + } + + let postgres_clients = PostgresClients::new(main_connection); + + log::info!("PostgreSQL clients initialized successfully"); + + let router = router_fn(postgres_clients).await; + + let port = server_config.port; + let host = server_config.host.clone(); + let addr = format!("{host}:{port}"); + let socket_addr: SocketAddr = addr.parse().map_err(|e| { + ServerInitError::ConfigurationError(format!("Invalid address '{addr}': {e}")) + })?; + + log::info!("Configuring server to listen on {}", socket_addr); + + let listener = TcpListener::bind(&socket_addr).await.map_err(|e| { + ServerInitError::NetworkBindingFailed(format!( + "Failed to bind to {socket_addr}: {e}" + )) + })?; + + log::info!("Server starting on {}", socket_addr); + + let shutdown_handle = setup_graceful_shutdown(); + + let server_handle = tokio::spawn(async move { + if let Err(err) = serve(listener, router).await { + log::error!("Server encountered an error: {}", err); + Err(ServerInitError::ServerStartupFailed(err.to_string())) + } else { + Ok(()) + } + }); + + tokio::select! { + result = server_handle => { + match result { + Ok(Ok(())) => { + log::info!("Server stopped gracefully"); + Ok(()) + } + Ok(Err(e)) => { + log::error!("Server error: {}", e); + Err(e) + } + Err(e) => { + log::error!("Server task panicked: {}", e); + Err(ServerInitError::ServerStartupFailed("Server task panicked".to_string())) + } + } + } + _ = shutdown_handle => { + log::info!("Received shutdown signal, stopping server gracefully"); + Ok(()) + } + } +} + +pub async fn axum_init(router_fn: F) -> Result<(), ServerInitError> +where + F: FnOnce(PostgresClients) -> Fut, + Fut: Future, +{ + let postgres_config = PostgresConfig::from_env().map_err(|e| { + ServerInitError::ConfigurationError(format!( + "Failed to load PostgreSQL config: {e}" + )) + })?; + + let server_config = ServerConfig { + port: ENV.port, + ..ServerConfig::default() + }; + + axum_init_advanced(router_fn, Some(server_config), postgres_config).await +} + +async fn test_postgres_connection( + connection: &Arc, +) -> Result<(), PostgresError> { + use sea_orm::DbErr; + let test_query = sea_orm::Statement::from_string( + connection.get_database_backend(), + "SELECT 1 as test_value".to_string(), + ); + + let result = connection.query_one(test_query).await?; + + match result { + Some(query_result) => { + let test_value: Option = query_result.try_get("", "test_value").ok(); + if test_value == Some(1) { + log::debug!("PostgreSQL connection test successful"); + Ok(()) + } else { + Err(PostgresError::ConnectionError(DbErr::Custom( + "Connection test query returned unexpected result".to_string(), + ))) + } + } + None => Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( + "Connection test query returned no results".to_string(), + ))), + } +} + +async fn setup_graceful_shutdown() { + use tokio::signal; + + match signal::ctrl_c().await { + Ok(()) => { + log::info!("Received Ctrl+C, initiating graceful shutdown"); + } + Err(err) => { + log::error!("Unable to listen for shutdown signal: {}", err); + std::future::pending::<()>().await; + } + } +} diff --git a/imphnen-libs/src/axum/validated_json.rs b/imphnen-libs/src/axum/validated_json.rs index 697df4c..8c140c2 100644 --- a/imphnen-libs/src/axum/validated_json.rs +++ b/imphnen-libs/src/axum/validated_json.rs @@ -1,9 +1,9 @@ use axum::{ - body::Bytes, - extract::{FromRequest, Request}, - http::StatusCode, - response::{IntoResponse, Response}, - Json, + Json, + body::Bytes, + extract::{FromRequest, Request}, + http::StatusCode, + response::{IntoResponse, Response}, }; use serde_json::json; @@ -13,46 +13,46 @@ pub struct ValidatedJson(pub T); impl FromRequest for ValidatedJson where - T: ZodValidate + 'static, - S: Send + Sync, + T: ZodValidate + 'static, + S: Send + Sync, { - type Rejection = Response; + type Rejection = Response; - async fn from_request(req: Request, state: &S) -> Result { - let bytes = Bytes::from_request(req, state).await.map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "message": format!("Failed to read body: {e}"), - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() - })?; + async fn from_request(req: Request, state: &S) -> Result { + let bytes = Bytes::from_request(req, state).await.map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "message": format!("Failed to read body: {e}"), + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() + })?; - let json_value: serde_json::Value = - serde_json::from_slice(&bytes).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "message": format!("Invalid JSON: {e}"), - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() - })?; + let json_value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "message": format!("Invalid JSON: {e}"), + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() + })?; - let value = T::zod_validate(&json_value).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "message": format!("Validation error: {e}"), - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() - })?; + let value = T::zod_validate(&json_value).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "message": format!("Validation error: {e}"), + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() + })?; - Ok(ValidatedJson(value)) - } + Ok(ValidatedJson(value)) + } } diff --git a/imphnen-libs/src/axum/zod_validate.rs b/imphnen-libs/src/axum/zod_validate.rs index 14c3f55..d5f7bcd 100644 --- a/imphnen-libs/src/axum/zod_validate.rs +++ b/imphnen-libs/src/axum/zod_validate.rs @@ -1,5 +1,5 @@ use serde_json::Value; pub trait ZodValidate: Sized { - fn zod_validate(value: &Value) -> Result; + fn zod_validate(value: &Value) -> Result; } diff --git a/imphnen-libs/src/environment/mod.rs b/imphnen-libs/src/environment/mod.rs index d76ca30..51d3060 100644 --- a/imphnen-libs/src/environment/mod.rs +++ b/imphnen-libs/src/environment/mod.rs @@ -1,226 +1,228 @@ -//! Environment configuration module using once_cell::sync::Lazy for one-time loading. -//! -//! This module provides centralized configuration management for the application. -//! All environment variables are loaded once at startup and cached for performance. -//! The application now uses PostgreSQL exclusively (migration from SurrealDB complete). - -use std::env; -use once_cell::sync::Lazy; -use log::{warn, info}; - -/// Struct holding all environment configuration. -/// -/// This struct contains all configuration values loaded from environment variables. -/// Sensitive values are masked in debug output for security. -/// PostgreSQL is the exclusive database backend (migration from SurrealDB complete). -#[derive(Clone)] -pub struct Env { - pub port: u16, - pub access_token_secret: String, - pub refresh_token_secret: String, - // PostgreSQL configuration - pub database_url: String, - pub pool_size: u32, - pub connect_timeout: u64, - pub idle_timeout: u64, - pub max_lifetime: Option, - pub statement_timeout: Option, - pub idle_in_transaction_session_timeout: Option, - pub sslmode: String, - pub retry_attempts: u32, - pub retry_delay: u64, - // SMTP configuration - pub smtp_email: String, - pub smtp_password: String, - pub smtp_name: String, - pub smtp_host: String, - pub redisdb_url: String, - pub fe_url: String, - pub rust_env: String, - pub minio_endpoint: String, - pub minio_bucket_name: String, - pub minio_access_key: String, - pub minio_secret_key: String, - pub minio_region: String, - pub minio_secure: bool, - - pub google_client_id: String, - - pub google_client_secret: String, - pub google_redirect_url: String, - } - - // Custom Debug implementation to mask secrets in logs - impl std::fmt::Debug for Env { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Env") - .field("port", &self.port) - .field("access_token_secret", &"***") - .field("refresh_token_secret", &"***") - .field("database_url", &"***") - .field("pool_size", &self.pool_size) - .field("connect_timeout", &self.connect_timeout) - .field("idle_timeout", &self.idle_timeout) - .field("max_lifetime", &self.max_lifetime) - .field("statement_timeout", &self.statement_timeout) - .field("idle_in_transaction_session_timeout", &self.idle_in_transaction_session_timeout) - .field("sslmode", &self.sslmode) - .field("retry_attempts", &self.retry_attempts) - .field("retry_delay", &self.retry_delay) - .field("smtp_email", &self.smtp_email) - .field("smtp_password", &"***") - .field("smtp_name", &self.smtp_name) - .field("smtp_host", &self.smtp_host) - .field("redisdb_url", &self.redisdb_url) - .field("fe_url", &self.fe_url) - .field("rust_env", &self.rust_env) - .field("minio_endpoint", &self.minio_endpoint) - .field("minio_bucket_name", &self.minio_bucket_name) - .field("minio_access_key", &"***") - .field("minio_secret_key", &"***") - .field("minio_region", &self.minio_region) - .field("minio_secure", &self.minio_secure) - .field("google_client_id", &self.google_client_id) - .field("google_client_secret", &"***") - .field("google_redirect_url", &self.google_redirect_url) - .finish() - } - } - - /// Get environment variable with warning if not set. - /// - /// This helper function attempts to read an environment variable and logs a warning - /// if it's not set, falling back to the provided default value. - /// - /// # Arguments - /// * `key` - The environment variable name - /// * `default` - The default value to use if the variable is not set - /// - /// # Returns - /// The environment variable value or the default - fn get_env_with_warning(key: &str, default: &str) -> String { - match env::var(key) { - Ok(val) => val, - Err(_) => { - warn!("Environment variable '{}' is not set. Using default: '{}'", key, default); - default.to_string() - } - } - } - - /// Parse environment variable as u16 with fallback. - /// - /// # Arguments - /// * `key` - The environment variable name - /// * `default` - The default numeric value - /// - /// # Returns - /// The parsed u16 value or the default if parsing fails - fn get_env_u16_with_warning(key: &str, default: u16) -> u16 { - match env::var(key) { - Ok(val) => val.parse().unwrap_or_else(|_| { - warn!("Environment variable '{}' has invalid value '{}'. Using default: {}", key, val, default); - default - }), - Err(_) => { - warn!("Environment variable '{}' is not set. Using default: {}", key, default); - default - } - } - } - - /// Parse environment variable as bool with fallback. - /// - /// # Arguments - /// * `key` - The environment variable name - /// * `default` - The default boolean value - /// - /// # Returns - /// The parsed boolean value or the default if parsing fails - fn get_env_bool_with_warning(key: &str, default: bool) -> bool { - match env::var(key) { - Ok(val) => val.parse().unwrap_or_else(|_| { - warn!("Environment variable '{}' has invalid value '{}'. Using default: {}", key, val, default); - default - }), - Err(_) => { - warn!("Environment variable '{}' is not set. Using default: {}", key, default); - default - } - } - } - - /// Global environment configuration loaded once at startup. - /// - /// This static variable loads all environment configuration exactly once - /// and caches it for the lifetime of the application. - pub static ENV: Lazy = Lazy::new(|| { - // Load .env file if present - load_dotenv_file(); - - let env = Env { - // Server configuration - port: get_env_u16_with_warning("PORT", 3000), - - // JWT secrets - access_token_secret: get_env_with_warning("ACCESS_TOKEN_SECRET", "default_access_secret"), - refresh_token_secret: get_env_with_warning("REFRESH_TOKEN_SECRET", "default_refresh_secret"), - - // PostgreSQL configuration (exclusive database backend) - database_url: get_env_with_warning("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/imphnen"), - pool_size: get_env_with_warning("POOL_SIZE", "10").parse().unwrap_or(10), - connect_timeout: get_env_with_warning("CONNECT_TIMEOUT", "30").parse().unwrap_or(30), - idle_timeout: get_env_with_warning("IDLE_TIMEOUT", "60").parse().unwrap_or(60), - max_lifetime: get_env_with_warning("MAX_LIFETIME", "1800").parse().ok(), - statement_timeout: get_env_with_warning("STATEMENT_TIMEOUT", "30000").parse().ok(), - idle_in_transaction_session_timeout: get_env_with_warning("IDLE_IN_TRANSACTION_SESSION_TIMEOUT", "60000").parse().ok(), - sslmode: get_env_with_warning("SSLMODE", "require"), - retry_attempts: get_env_with_warning("RETRY_ATTEMPTS", "3").parse().unwrap_or(3), - retry_delay: get_env_with_warning("RETRY_DELAY", "1").parse().unwrap_or(1), - - // SMTP configuration - smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"), - smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"), - smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"), - smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"), - - // Redis configuration - redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"), - - // Frontend URL - fe_url: get_env_with_warning("FE_URL", "http://localhost"), - - // Environment - rust_env: get_env_with_warning("RUST_ENV", "development"), - - // MinIO configuration - minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"), - minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"), - minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"), - minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"), - minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"), - minio_secure: get_env_bool_with_warning("MINIO_SECURE", false), - - // Google OAuth 2.1 - google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"), - google_client_secret: get_env_with_warning("GOOGLE_CLIENT_SECRET", "default_google_client_secret"), - google_redirect_url: get_env_with_warning("GOOGLE_REDIRECT_URL", "http://localhost:8000/api/v1/auth/google/callback"), - }; - - info!("Environment configuration loaded successfully"); - env - }); - - /// Load .env file if present, with appropriate logging. - fn load_dotenv_file() { - match dotenvy::dotenv() { - Ok(path) => info!("Loaded environment file: {:?}", path), - Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => { - warn!(".env file not found, falling back to system environment variables"); - } - Err(e) => { - warn!("Failed to load .env file: {}. Falling back to system environment variables", e); - } - } - } - +use log::{info, warn}; +use once_cell::sync::Lazy; +use std::env; + +#[derive(Clone)] +pub struct Env { + pub port: u16, + pub access_token_secret: String, + pub refresh_token_secret: String, + pub database_url: String, + pub pool_size: u32, + pub connect_timeout: u64, + pub idle_timeout: u64, + pub max_lifetime: Option, + pub statement_timeout: Option, + pub idle_in_transaction_session_timeout: Option, + pub sslmode: String, + pub retry_attempts: u32, + pub retry_delay: u64, + pub smtp_email: String, + pub smtp_password: String, + pub smtp_name: String, + pub smtp_host: String, + pub redisdb_url: String, + pub fe_url: String, + pub rust_env: String, + pub minio_endpoint: String, + pub minio_bucket_name: String, + pub minio_access_key: String, + pub minio_secret_key: String, + pub minio_region: String, + pub minio_secure: bool, + pub google_client_id: String, + pub google_client_secret: String, + pub google_redirect_url: String, + pub cdn_url: String, + pub cors_allowed_origins: Vec, +} + +impl std::fmt::Debug for Env { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Env") + .field("port", &self.port) + .field("access_token_secret", &"***") + .field("refresh_token_secret", &"***") + .field("database_url", &"***") + .field("pool_size", &self.pool_size) + .field("connect_timeout", &self.connect_timeout) + .field("idle_timeout", &self.idle_timeout) + .field("max_lifetime", &self.max_lifetime) + .field("statement_timeout", &self.statement_timeout) + .field( + "idle_in_transaction_session_timeout", + &self.idle_in_transaction_session_timeout, + ) + .field("sslmode", &self.sslmode) + .field("retry_attempts", &self.retry_attempts) + .field("retry_delay", &self.retry_delay) + .field("smtp_email", &self.smtp_email) + .field("smtp_password", &"***") + .field("smtp_name", &self.smtp_name) + .field("smtp_host", &self.smtp_host) + .field("redisdb_url", &self.redisdb_url) + .field("fe_url", &self.fe_url) + .field("rust_env", &self.rust_env) + .field("minio_endpoint", &self.minio_endpoint) + .field("minio_bucket_name", &self.minio_bucket_name) + .field("minio_access_key", &"***") + .field("minio_secret_key", &"***") + .field("minio_region", &self.minio_region) + .field("minio_secure", &self.minio_secure) + .field("google_client_id", &self.google_client_id) + .field("google_client_secret", &"***") + .field("google_redirect_url", &self.google_redirect_url) + .field("cdn_url", &self.cdn_url) + .field("cors_allowed_origins", &self.cors_allowed_origins) + .finish() + } +} + +fn get_env_with_warning(key: &str, default: &str) -> String { + match env::var(key) { + Ok(val) => val, + Err(_) => { + warn!( + "Environment variable '{}' is not set. Using default: '{}'", + key, default + ); + default.to_string() + } + } +} + +fn get_env_u16_with_warning(key: &str, default: u16) -> u16 { + match env::var(key) { + Ok(val) => val.parse().unwrap_or_else(|_| { + warn!( + "Environment variable '{}' has invalid value '{}'. Using default: {}", + key, val, default + ); + default + }), + Err(_) => { + warn!( + "Environment variable '{}' is not set. Using default: {}", + key, default + ); + default + } + } +} + +fn get_env_bool_with_warning(key: &str, default: bool) -> bool { + match env::var(key) { + Ok(val) => val.parse().unwrap_or_else(|_| { + warn!( + "Environment variable '{}' has invalid value '{}'. Using default: {}", + key, val, default + ); + default + }), + Err(_) => { + warn!( + "Environment variable '{}' is not set. Using default: {}", + key, default + ); + default + } + } +} + +pub static ENV: Lazy = Lazy::new(|| { + load_dotenv_file(); + + let env = Env { + port: get_env_u16_with_warning("PORT", 3000), + access_token_secret: get_env_with_warning( + "ACCESS_TOKEN_SECRET", + "default_access_secret", + ), + refresh_token_secret: get_env_with_warning( + "REFRESH_TOKEN_SECRET", + "default_refresh_secret", + ), + database_url: get_env_with_warning( + "DATABASE_URL", + "postgres://postgres:postgres@localhost:5432/imphnen", + ), + pool_size: get_env_with_warning("POOL_SIZE", "10") + .parse() + .unwrap_or(10), + connect_timeout: get_env_with_warning("CONNECT_TIMEOUT", "30") + .parse() + .unwrap_or(30), + idle_timeout: get_env_with_warning("IDLE_TIMEOUT", "60") + .parse() + .unwrap_or(60), + max_lifetime: get_env_with_warning("MAX_LIFETIME", "1800").parse().ok(), + statement_timeout: get_env_with_warning("STATEMENT_TIMEOUT", "30000") + .parse() + .ok(), + idle_in_transaction_session_timeout: get_env_with_warning( + "IDLE_IN_TRANSACTION_SESSION_TIMEOUT", + "60000", + ) + .parse() + .ok(), + sslmode: get_env_with_warning("SSLMODE", "require"), + retry_attempts: get_env_with_warning("RETRY_ATTEMPTS", "3") + .parse() + .unwrap_or(3), + retry_delay: get_env_with_warning("RETRY_DELAY", "1") + .parse() + .unwrap_or(1), + smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"), + smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"), + smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"), + smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"), + redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"), + fe_url: get_env_with_warning("FE_URL", "http://localhost"), + rust_env: get_env_with_warning("RUST_ENV", "development"), + minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"), + minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"), + minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"), + minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"), + minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"), + minio_secure: get_env_bool_with_warning("MINIO_SECURE", false), + google_client_id: get_env_with_warning( + "GOOGLE_CLIENT_ID", + "default_google_client_id", + ), + google_client_secret: get_env_with_warning( + "GOOGLE_CLIENT_SECRET", + "default_google_client_secret", + ), + google_redirect_url: get_env_with_warning( + "GOOGLE_REDIRECT_URL", + "http://localhost:8000/api/v1/auth/google/callback", + ), + cdn_url: get_env_with_warning("CDN_URL", "https://cdn.asepharyana.tech"), + cors_allowed_origins: get_env_with_warning( + "CORS_ALLOWED_ORIGINS", + "https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev", + ) + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }; + + info!("Environment configuration loaded successfully"); + env +}); + +fn load_dotenv_file() { + match dotenvy::dotenv() { + Ok(path) => info!("Loaded environment file: {:?}", path), + Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => { + warn!(".env file not found, falling back to system environment variables"); + } + Err(e) => { + warn!( + "Failed to load .env file: {}. Falling back to system environment variables", + e + ); + } + } +} diff --git a/imphnen-libs/src/jsonwebtoken/mod.rs b/imphnen-libs/src/jsonwebtoken/mod.rs index 1ef0d69..3610281 100644 --- a/imphnen-libs/src/jsonwebtoken/mod.rs +++ b/imphnen-libs/src/jsonwebtoken/mod.rs @@ -1,161 +1,112 @@ -//! JWT token encoding and decoding utilities. -//! -//! This module provides functions for creating and validating JWT tokens -//! for authentication purposes, including access tokens, refresh tokens, -//! and password reset tokens. - -use crate::environment::ENV; -use axum::http::StatusCode; -use chrono::{Duration, TimeDelta, Utc}; -use jsonwebtoken::{ - DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode, -}; -use serde::{Deserialize, Serialize}; - -/// JWT claims structure containing token payload information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Claims { - /// Expiration timestamp - pub exp: usize, - /// Issued at timestamp - pub iat: usize, - /// Subject (usually user identifier) - pub sub: String, - /// User ID - pub user_id: String, -} - -// Token configuration constants -const ACCESS_TOKEN_DURATION_MINUTES: i64 = 15; -const REFRESH_TOKEN_DURATION_DAYS: i64 = 1; -const RESET_TOKEN_DURATION_MINUTES: i64 = 5; - -// Lazy-initialized headers and keys for performance -static ACCESS_HEADER: once_cell::sync::Lazy
= once_cell::sync::Lazy::new(Header::default); -static ACCESS_KEY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - EncodingKey::from_secret(ENV.access_token_secret.as_ref()) -}); - -static REFRESH_HEADER: once_cell::sync::Lazy
= once_cell::sync::Lazy::new(Header::default); -static REFRESH_KEY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - EncodingKey::from_secret(ENV.refresh_token_secret.as_ref()) -}); - -/// Create JWT claims with specified expiration duration. -/// -/// # Arguments -/// * `sub` - Subject identifier -/// * `user_id` - User ID -/// * `duration` - Token validity duration -/// -/// # Returns -/// JWT claims structure -fn create_claims(sub: String, user_id: String, duration: TimeDelta) -> Claims { - let now = Utc::now(); - let exp: usize = (now + duration).timestamp() as usize; - let iat: usize = now.timestamp() as usize; - Claims { iat, exp, sub, user_id } -} - -/// Encode a JWT token with the specified header and key. -/// -/// # Arguments -/// * `claims` - JWT claims to encode -/// * `header` - JWT header -/// * `key` - Encoding key -/// -/// # Returns -/// Encoded JWT token or internal server error status -fn encode_token(claims: &Claims, header: &Header, key: &EncodingKey) -> Result { - encode(header, claims, key).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} - -/// Decode a JWT token with the specified secret. -/// -/// # Arguments -/// * `token` - JWT token string -/// * `secret` - Secret key for decoding -/// -/// # Returns -/// Decoded token data or internal server error status -fn decode_token(token: &str, secret: &str) -> Result, StatusCode> { - decode( - token, - &DecodingKey::from_secret(secret.as_ref()), - &Validation::default(), - ) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} - -/// Encode an access token with 15-minute expiration. -/// -/// # Arguments -/// * `sub` - Subject identifier -/// * `user_id` - User ID -/// -/// # Returns -/// Encoded JWT access token -pub fn encode_access_token(sub: String, user_id: String) -> Result { - let claims = create_claims(sub, user_id, Duration::minutes(ACCESS_TOKEN_DURATION_MINUTES)); - encode_token(&claims, &ACCESS_HEADER, &ACCESS_KEY) -} - -/// Encode a refresh token with 1-day expiration. -/// -/// # Arguments -/// * `sub` - Subject identifier -/// * `user_id` - User ID -/// -/// # Returns -/// Encoded JWT refresh token -pub fn encode_refresh_token(sub: String, user_id: String) -> Result { - let claims = create_claims(sub, user_id, Duration::days(REFRESH_TOKEN_DURATION_DAYS)); - encode_token(&claims, &REFRESH_HEADER, &REFRESH_KEY) -} - -/// Encode a password reset token with 5-minute expiration. -/// -/// # Arguments -/// * `sub` - Subject identifier -/// * `user_id` - User ID -/// -/// # Returns -/// Encoded JWT reset token -pub fn encode_reset_password_token(sub: String, user_id: String) -> Result { - let claims = create_claims(sub, user_id, Duration::minutes(RESET_TOKEN_DURATION_MINUTES)); - let key = EncodingKey::from_secret(ENV.access_token_secret.as_ref()); - encode_token(&claims, &Header::default(), &key) -} - -/// Decode an access token. -/// -/// # Arguments -/// * `jwt_token` - JWT token string -/// -/// # Returns -/// Decoded token data containing claims -pub fn decode_access_token(jwt_token: &str) -> Result, StatusCode> { - decode_token(jwt_token, &ENV.access_token_secret) -} - -/// Decode a refresh token. -/// -/// # Arguments -/// * `jwt_token` - JWT token string -/// -/// # Returns -/// Decoded token data containing claims -pub fn decode_refresh_token(jwt_token: &str) -> Result, StatusCode> { - decode_token(jwt_token, &ENV.refresh_token_secret) -} - -/// Generate a simple JWT access token using user_id as both sub and user_id. -/// -/// # Arguments -/// * `user_id` - User identifier -/// -/// # Returns -/// Encoded JWT access token -pub fn generate_jwt(user_id: &str) -> Result { - encode_access_token(user_id.to_string(), user_id.to_string()) -} +use crate::environment::ENV; +use axum::http::StatusCode; +use chrono::{Duration, TimeDelta, Utc}; +use jsonwebtoken::{ + DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub exp: usize, + pub iat: usize, + pub sub: String, + pub user_id: String, +} + +const ACCESS_TOKEN_DURATION_MINUTES: i64 = 15; +const REFRESH_TOKEN_DURATION_DAYS: i64 = 1; +const RESET_TOKEN_DURATION_MINUTES: i64 = 5; + +static ACCESS_HEADER: once_cell::sync::Lazy
= + once_cell::sync::Lazy::new(Header::default); +static ACCESS_KEY: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + EncodingKey::from_secret(ENV.access_token_secret.as_ref()) + }); + +static REFRESH_HEADER: once_cell::sync::Lazy
= + once_cell::sync::Lazy::new(Header::default); +static REFRESH_KEY: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + EncodingKey::from_secret(ENV.refresh_token_secret.as_ref()) + }); + +fn create_claims(sub: String, user_id: String, duration: TimeDelta) -> Claims { + let now = Utc::now(); + let exp: usize = (now + duration).timestamp() as usize; + let iat: usize = now.timestamp() as usize; + Claims { + iat, + exp, + sub, + user_id, + } +} + +fn encode_token( + claims: &Claims, + header: &Header, + key: &EncodingKey, +) -> Result { + encode(header, claims, key).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn decode_token(token: &str, secret: &str) -> Result, StatusCode> { + decode( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub fn encode_access_token( + sub: String, + user_id: String, +) -> Result { + let claims = create_claims( + sub, + user_id, + Duration::minutes(ACCESS_TOKEN_DURATION_MINUTES), + ); + encode_token(&claims, &ACCESS_HEADER, &ACCESS_KEY) +} + +pub fn encode_refresh_token( + sub: String, + user_id: String, +) -> Result { + let claims = + create_claims(sub, user_id, Duration::days(REFRESH_TOKEN_DURATION_DAYS)); + encode_token(&claims, &REFRESH_HEADER, &REFRESH_KEY) +} + +pub fn encode_reset_password_token( + sub: String, + user_id: String, +) -> Result { + let claims = create_claims( + sub, + user_id, + Duration::minutes(RESET_TOKEN_DURATION_MINUTES), + ); + let key = EncodingKey::from_secret(ENV.access_token_secret.as_ref()); + encode_token(&claims, &Header::default(), &key) +} + +pub fn decode_access_token( + jwt_token: &str, +) -> Result, StatusCode> { + decode_token(jwt_token, &ENV.access_token_secret) +} + +pub fn decode_refresh_token( + jwt_token: &str, +) -> Result, StatusCode> { + decode_token(jwt_token, &ENV.refresh_token_secret) +} + +pub fn generate_jwt(user_id: &str) -> Result { + encode_access_token(user_id.to_string(), user_id.to_string()) +} diff --git a/imphnen-libs/src/lettre/mod.rs b/imphnen-libs/src/lettre/mod.rs deleted file mode 100644 index bb54d92..0000000 --- a/imphnen-libs/src/lettre/mod.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Email sending utilities using Lettre SMTP client. -//! -//! This module provides functionality for sending emails through SMTP -//! with proper error handling and logging. - -use crate::environment::ENV; -use lettre::message::Mailbox; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{Message, SmtpTransport, Transport}; -use std::error::Error; -use std::fmt; - -/// Custom error type for email operations. -#[derive(Debug)] -pub enum EmailError { - /// SMTP configuration error - SmtpConfig(String), - /// Message building error - MessageBuild(String), - /// SMTP transport error - Transport(String), -} - -impl fmt::Display for EmailError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - EmailError::SmtpConfig(msg) => write!(f, "SMTP configuration error: {}", msg), - EmailError::MessageBuild(msg) => write!(f, "Message building error: {}", msg), - EmailError::Transport(msg) => write!(f, "SMTP transport error: {}", msg), - } - } -} - -impl Error for EmailError {} - -/// Send an email using the configured SMTP settings. -/// -/// This function constructs and sends an email using the SMTP configuration -/// from environment variables. It handles sender name normalization and -/// proper error reporting. -/// -/// # Arguments -/// * `to` - Recipient email address -/// * `subject` - Email subject line -/// * `body` - Email body content (plain text) -/// -/// # Returns -/// * `Ok(())` - Email sent successfully -/// * `Err(EmailError)` - Email sending failed -/// -/// # Example -/// ``` -/// use imphnen_libs::send_email; -/// -/// send_email("user@example.com", "Welcome!", "Hello, welcome to our service!")?; -/// # Ok::<(), Box>(()) -/// ``` -pub fn send_email(to: &str, subject: &str, body: &str) -> Result<(), Box> { - let env = &ENV; - - // Build the email message - let message = build_email_message(to, subject, body, env)?; - - // Create SMTP transport - let mailer = create_smtp_transport(env)?; - - // Send the email - mailer.send(&message).map_err(|e| { - log::error!("Failed to send email to {}: {}", to, e); - Box::new(EmailError::Transport(e.to_string())) as Box - })?; - - log::info!("Email sent successfully to: {}", to); - Ok(()) -} - -/// Build an email message with proper sender and recipient configuration. -/// -/// # Arguments -/// * `to` - Recipient email address -/// * `subject` - Email subject -/// * `body` - Email body -/// * `env` - Environment configuration -/// -/// # Returns -/// Email message or error -fn build_email_message( - to: &str, - subject: &str, - body: &str, - env: &crate::environment::Env, -) -> Result> { - let sender_name = env.smtp_name.replace("-", " "); // Normalize sender name - - Message::builder() - .from(Mailbox::new(Some(sender_name), env.smtp_email.parse()?)) - .to(to.parse()?) - .subject(subject) - .body(body.to_string()) - .map_err(|e| Box::new(EmailError::MessageBuild(e.to_string())) as Box) -} - -/// Create SMTP transport with authentication. -/// -/// # Arguments -/// * `env` - Environment configuration -/// -/// # Returns -/// Configured SMTP transport or error -fn create_smtp_transport(env: &crate::environment::Env) -> Result> { - let credentials = Credentials::new( - env.smtp_email.clone(), - env.smtp_password.replace("-", " "), // Normalize password - ); - - Ok(SmtpTransport::relay(&env.smtp_host)? - .credentials(credentials) - .build()) - -} diff --git a/imphnen-libs/src/lib.rs b/imphnen-libs/src/lib.rs index a779706..56ecd0b 100644 --- a/imphnen-libs/src/lib.rs +++ b/imphnen-libs/src/lib.rs @@ -1,68 +1,25 @@ -use std::sync::Arc; - -pub mod postgres; -pub mod argon; -pub mod axum; -pub mod environment; -pub mod jsonwebtoken; -pub mod lettre; -pub mod minio; -pub mod services; - -pub use argon::{hash_password, verify_password}; -pub use axum::{axum_init, ValidatedJson, ZodValidate}; -pub use environment::{ENV, Env}; -pub use imphnen_entities::{ - MessageResponseDto, - ResponseSuccessDto, - ResponseListSuccessDto, - UsersDetailQueryDto, - PermissionsEnum, - PermissionsItemDto, - PermissionsQueryDto, -}; -pub use jsonwebtoken::{ - Claims, encode_access_token, encode_refresh_token, decode_access_token, - decode_refresh_token, encode_reset_password_token, generate_jwt -}; -pub use lettre::send_email; -pub use minio::{ - MinioConfig, MinioService, UploadResult, FileType, UploadRequest, FileMetadata, - create_minio_service_from_config, decode_base64_file, extract_content_type_from_data_url -}; -pub use services::{UserLookupService, AuthRepositoryTrait}; -// Re-export concrete Postgres service implementations for convenience -pub use services::PostgresUserLookupService; -pub use services::PostgresAuthRepository; -pub use postgres::{ - PostgresConnection, PostgresConfig, PostgresError, AppStatePostgresExt, -}; -#[derive(Clone)] -pub struct AppState { - pub postgres_connection: Arc, - pub user_lookup_service: Arc, - pub auth_repository: Arc, -} - -impl AppState { - /// Create a new AppState with PostgreSQL connection - pub async fn new( - postgres_config: PostgresConfig, - user_lookup_service: Arc, - auth_repository: Arc, - ) -> Result { - let postgres_connection = PostgresConnection::new(postgres_config).await?; - - Ok(Self { - postgres_connection: Arc::new(postgres_connection), - user_lookup_service, - auth_repository, - }) - } -} - -impl AppStatePostgresExt for AppState { - fn postgres_connection(&self) -> &PostgresConnection { - &self.postgres_connection - } -} +pub mod argon; +pub mod axum; +pub mod environment; +pub mod jsonwebtoken; +pub mod postgres; +pub mod services; + +pub use argon::{hash_password, verify_password}; +pub use axum::app_state::PostgresClients; +pub use axum::{AppState, ValidatedJson, ZodValidate, axum_init}; +pub use environment::{ENV, Env}; +pub use imphnen_entities::{ + MessageResponseDto, PermissionsEnum, PermissionsItemDto, PermissionsQueryDto, + ResponseListSuccessDto, ResponseSuccessDto, UsersDetailQueryDto, +}; +pub use jsonwebtoken::{ + Claims, decode_access_token, decode_refresh_token, encode_access_token, + encode_refresh_token, encode_reset_password_token, generate_jwt, +}; +pub use postgres::{ + AppStatePostgresExt, PostgresConfig, PostgresConnection, PostgresError, +}; +pub use services::PostgresAuthRepository; +pub use services::PostgresUserLookupService; +pub use services::{AuthRepositoryTrait, UserLookupService}; diff --git a/imphnen-libs/src/minio.rs b/imphnen-libs/src/minio.rs deleted file mode 100644 index 712afa4..0000000 --- a/imphnen-libs/src/minio.rs +++ /dev/null @@ -1,697 +0,0 @@ -use anyhow::{anyhow, bail, Result}; -use base64::{engine::general_purpose, Engine as _}; -use chrono::Utc; -use hmac::{Hmac, Mac}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; -use crate::environment::ENV; - - - -// --- Struct Konfigurasi MinIO --- -#[derive(Debug, Clone)] -pub struct MinioConfig { - pub endpoint: String, - pub access_key: String, - pub secret_key: String, - pub bucket_name: String, - pub region: String, - pub secure: bool, -} - -impl MinioConfig { - /// Memuat konfigurasi MinIO dari variabel lingkungan. - pub fn from_env() -> Result { - Ok(Self { - endpoint: ENV.minio_endpoint.clone(), - access_key: ENV.minio_access_key.clone(), - secret_key: ENV.minio_secret_key.clone(), - bucket_name: ENV.minio_bucket_name.clone(), - region: ENV.minio_region.clone(), - secure: ENV.minio_secure, - }) - } - - /// Mendapatkan URL endpoint lengkap (http atau https). - pub fn endpoint_url(&self) -> String { - // If endpoint already has protocol, use it as-is - if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") { - self.endpoint.clone() - } else { - // Only add protocol if not present - let protocol = if self.secure { "https" } else { "http" }; - format!("{protocol}://{}", self.endpoint) - } - } -} - -// --- Layanan MinIO --- -pub struct MinioService { - endpoint: String, - access_key: String, - secret_key: String, - bucket_name: String, - region: String, - client: reqwest::Client, -} - -impl MinioService { - /// Membuat instance layanan MinIO baru. - pub async fn new( - endpoint: &str, - access_key: &str, - secret_key: &str, - bucket_name: &str, - region: &str, - ) -> Result { - let service = Self { - endpoint: endpoint.to_string(), - access_key: access_key.to_string(), - secret_key: secret_key.to_string(), - bucket_name: bucket_name.to_string(), - region: region.to_string(), - client: reqwest::Client::new(), - }; - Ok(service) - } - - /// Mengunggah file biner ke MinIO dengan deduplication berdasarkan hash. - pub async fn upload_file_with_deduplication( - &self, - file_data: &[u8], - content_type: &str, - folder: &str, - original_filename: &str, - ) -> Result { - Self::validate_file_type(content_type, file_data)?; - - // Calculate file hash - let mut hasher = Sha256::new(); - hasher.update(file_data); - let file_hash = format!("{:x}", hasher.finalize()); - let short_hash = &file_hash[..16]; // Use first 16 characters for filename - - // Check if file with same hash already exists - if let Some(existing_file) = self.check_file_exists_by_hash(folder, short_hash).await? { - log::info!("File with same content already exists: {}", existing_file); - return Ok(existing_file); - } - - let file_extension = Self::get_file_extension(original_filename); - let unique_filename = format!("{folder}/{short_hash}-{}.{file_extension}", Uuid::new_v4()); - let object_name = &unique_filename; - - // Extract host from endpoint (remove protocol) - let host = self.endpoint - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let url = format!("https://{host}/{}/{object_name}", self.bucket_name); - - - let now = Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - - // Use UNSIGNED-PAYLOAD for simpler signature - let payload_hash = "UNSIGNED-PAYLOAD".to_string(); - - let canonical_headers = format!( - "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", - host, payload_hash, amz_date - ); - let signed_headers = "host;x-amz-content-sha256;x-amz-date"; - - // For path-style, canonical URI should be /bucket/object - let canonical_uri = format!("/{}/{object_name}", self.bucket_name); - let canonical_request = format!( - "PUT\n{}\n\n{}\n{}\n{}", - canonical_uri, canonical_headers, signed_headers, payload_hash - ); - - - let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - - log::debug!("Scope: {}", scope); - log::debug!("String to sign:\n{}", string_to_sign); - - let signing_key = self.get_signature_key(&date_stamp)?; - let mut mac = Hmac::::new_from_slice(&signing_key)?; - mac.update(string_to_sign.as_bytes()); - let signature = hex::encode(mac.finalize().into_bytes()); - - - let auth_header = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", - self.access_key, scope, signed_headers, signature - ); - - // 5) Send request: Content-Type included but NOT signed - let response = self - .client - .put(&url) - .header("x-amz-date", &amz_date) - .header("x-amz-content-sha256", &payload_hash) - .header("Authorization", &auth_header) - .header("Content-Type", content_type) - // Don't set Host header manually - let reqwest handle it - // Add headers for reverse proxy support (not signed) - .header("X-Forwarded-Proto", "https") - .header("X-Forwarded-Host", host) - .body(file_data.to_vec()) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_body = response.text().await?; - bail!( - "Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}", - status, - error_body - ); - } - - log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename); - Ok(unique_filename) - } - - /// Mengunggah file biner ke MinIO. - pub async fn upload_file( - &self, - file_data: &[u8], - content_type: &str, - folder: &str, - original_filename: &str, - ) -> Result { - Self::validate_file_type(content_type, file_data)?; - - let file_extension = Self::get_file_extension(original_filename); - let unique_filename = format!("{folder}/{}.{file_extension}", Uuid::new_v4()); - let object_name = &unique_filename; - - // Extract host from endpoint (remove protocol) - let host = self.endpoint - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let url = format!("https://{host}/{}/{object_name}", self.bucket_name); - - - let now = Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - - // 1) Use UNSIGNED-PAYLOAD for HTTPS uploads (safer for proxies) - let payload_hash = "UNSIGNED-PAYLOAD".to_string(); - - // 2) Path-style canonical URI: /{bucket}/{object} - let canonical_uri = format!("/{}/{object_name}", self.bucket_name); - - // 3) ONLY sign essential headers (no content-type to avoid proxy issues) - let canonical_headers = format!( - "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", - host, payload_hash, amz_date - ); - let signed_headers = "host;x-amz-content-sha256;x-amz-date"; - - // 4) Canonical request - let canonical_request = format!( - "PUT\n{}\n\n{}\n{}\n{}", - canonical_uri, canonical_headers, signed_headers, payload_hash - ); - - - let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - - - let signing_key = self.get_signature_key(&date_stamp)?; - let mut mac = Hmac::::new_from_slice(&signing_key)?; - mac.update(string_to_sign.as_bytes()); - let signature = hex::encode(mac.finalize().into_bytes()); - - - let auth_header = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", - self.access_key, scope, signed_headers, signature - ); - - // 5) Send request: Content-Type included but NOT signed - let response = self - .client - .put(&url) - .header("x-amz-date", &amz_date) - .header("x-amz-content-sha256", &payload_hash) - .header("Authorization", &auth_header) - .header("Content-Type", content_type) - // Don't set Host header manually - let reqwest handle it - // Add headers for reverse proxy support (not signed) - .header("X-Forwarded-Proto", "https") - .header("X-Forwarded-Host", host) - .body(file_data.to_vec()) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_body = response.text().await?; - bail!( - "Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}", - status, - error_body - ); - } - - log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename); - Ok(unique_filename) - } - - /// Mengunggah file yang dikodekan base64 ke MinIO. - pub async fn upload_base64_file( - &self, - base64_data: &str, - content_type: &str, - folder: &str, - original_filename: &str, - ) -> Result { - let file_data = decode_base64_file(base64_data)?; - self.upload_file(&file_data, content_type, folder, original_filename) - .await - } - - /// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek. - pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result { - // Extract host from endpoint (remove protocol) - let host = self.endpoint - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let now = Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region); - let credential = format!("{}/{}", self.access_key, scope); - - let expires_str = expiry_seconds.to_string(); - let mut query_params = std::collections::BTreeMap::new(); - query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); - query_params.insert("X-Amz-Credential", &credential); - query_params.insert("X-Amz-Date", &amz_date); - query_params.insert("X-Amz-Expires", &expires_str); - query_params.insert("X-Amz-SignedHeaders", "host"); - - let canonical_query_string = query_params - .iter() - .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) - .collect::>() - .join("&"); - - let canonical_request = format!( - "GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD", - self.bucket_name, object_name, canonical_query_string, host - ); - - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - - let signing_key = self.get_signature_key(&date_stamp)?; - let mut mac = Hmac::::new_from_slice(&signing_key)?; - mac.update(string_to_sign.as_bytes()); - let signature = hex::encode(mac.finalize().into_bytes()); - - let url = format!( - "https://{}/{}/{}?{}&X-Amz-Signature={}", - host, self.bucket_name, object_name, canonical_query_string, signature - ); - - Ok(url) - } - - /// Mengecek apakah file dengan hash tertentu sudah ada di bucket - pub async fn check_file_exists_by_hash(&self, folder: &str, file_hash: &str) -> Result> { - // Extract host from endpoint (remove protocol) - let host = self.endpoint - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let url = format!("https://{host}/{bucket}?list-type=2&prefix={folder}", bucket = self.bucket_name); - - let now = Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - let payload_hash = hex::encode(Sha256::digest(b"")); - - let canonical_query_string = format!("list-type=2&prefix={}", urlencoding::encode(folder)); - let canonical_headers = format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"); - let signed_headers = "host;x-amz-content-sha256;x-amz-date"; - let canonical_request = format!( - "GET\n/{}\n{}\n{}\n{}\n{}", - self.bucket_name, canonical_query_string, canonical_headers, signed_headers, payload_hash - ); - - let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - - let signing_key = self.get_signature_key(&date_stamp)?; - let mut mac = Hmac::::new_from_slice(&signing_key)?; - mac.update(string_to_sign.as_bytes()); - let signature = hex::encode(mac.finalize().into_bytes()); - - let auth_header = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", - self.access_key, scope, signed_headers, signature - ); - - let response = self - .client - .get(&url) - .header("x-amz-date", &amz_date) - .header("x-amz-content-sha256", &payload_hash) - .header("Authorization", &auth_header) - .send() - .await?; - - if !response.status().is_success() { - return Ok(None); - } - - let body = response.text().await?; - - // Simple XML parsing to find files with matching hash - // Look for any file that contains the hash in its name - if body.contains(file_hash) { - // Extract the full file path from XML response - // This is a simplified approach - in production you might want proper XML parsing - for line in body.lines() { - if line.contains("") && line.contains(file_hash) - && let Some(start) = line.find("") - && let Some(end) = line.find("") { - let file_path = &line[start + 5..end]; - return Ok(Some(file_path.to_string())); - } - } - } - - Ok(None) - } - - /// Menghapus file dari MinIO. - pub async fn delete_file(&self, object_name: &str) -> Result<()> { - // Extract host from endpoint (remove protocol) - let host = self.endpoint - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let url = format!("https://{host}/{}/{object_name}", self.bucket_name); - - let now = Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - let payload_hash = hex::encode(Sha256::digest(b"")); - - let canonical_headers = format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"); - let signed_headers = "host;x-amz-content-sha256;x-amz-date"; - let canonical_request = format!( - "DELETE\n/{}/{}\n\n{}\n{}\n{}", - self.bucket_name, object_name, canonical_headers, signed_headers, payload_hash - ); - - let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - - // Debug logging for signature calculation - log::debug!("Region: {}", self.region); - - let signing_key = self.get_signature_key(&date_stamp)?; - let mut mac = Hmac::::new_from_slice(&signing_key)?; - mac.update(string_to_sign.as_bytes()); - let signature = hex::encode(mac.finalize().into_bytes()); - - - let auth_header = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", - self.access_key, scope, signed_headers, signature - ); - - let response = self - .client - .delete(&url) - .header("Host", host) - .header("x-amz-date", &amz_date) - .header("x-amz-content-sha256", &payload_hash) - .header("Authorization", &auth_header) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_body = response.text().await?; - bail!( - "Gagal menghapus file dari MinIO. Status: {}. Pesan: {}", - status, - error_body - ); - } - - log::info!("File berhasil dihapus: {}", object_name); - Ok(()) - } - - /// Fungsi pembantu untuk menghasilkan kunci tanda tangan AWS v4. - fn get_signature_key(&self, date_stamp: &str) -> Result> { - let secret = format!("AWS4{}", self.secret_key); - let mut mac1 = Hmac::::new_from_slice(secret.as_bytes())?; - mac1.update(date_stamp.as_bytes()); - let date_key = mac1.finalize().into_bytes(); - - let mut mac2 = Hmac::::new_from_slice(&date_key)?; - mac2.update(self.region.as_bytes()); - let date_region_key = mac2.finalize().into_bytes(); - - let mut mac3 = Hmac::::new_from_slice(&date_region_key)?; - mac3.update(b"s3"); - let date_region_service_key = mac3.finalize().into_bytes(); - - let mut mac4 = Hmac::::new_from_slice(&date_region_service_key)?; - mac4.update(b"aws4_request"); - Ok(mac4.finalize().into_bytes().to_vec()) - } - - /// Memvalidasi jenis file dan ukuran. - fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> { - const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB - if file_data.len() > MAX_SIZE { - bail!("Ukuran file melebihi batas 10MB"); - } - - match content_type { - "image/jpeg" | "image/jpg" => { - if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) { - bail!("File JPEG tidak valid"); - } - } - "image/png" => { - if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { - bail!("File PNG tidak valid"); - } - } - "application/pdf" => { - if !file_data.starts_with(b"%PDF") { - bail!("File PDF tidak valid"); - } - } - "image/webp" => { - if !file_data.starts_with(b"RIFF") - || file_data.get(8..12).is_none_or(|s| s != b"WEBP") - { - bail!("File WEBP tidak valid"); - } - } - "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { - if file_data.len() < 512 { - bail!("File dokumen tidak valid"); - } - } - _ => { - bail!("Jenis file tidak didukung: {}", content_type); - } - } - - Ok(()) - } - - /// Mendapatkan ekstensi file dari nama file. - fn get_file_extension(filename: &str) -> String { - std::path::Path::new(filename) - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("bin") - .to_lowercase() - } -} - -// --- Struct dan Enum Pembantu --- - -#[derive(Debug, Clone)] -pub struct UploadResult { - pub object_name: String, - pub url: String, - pub size: usize, - pub content_type: String, -} - -#[derive(Debug, Clone)] -pub enum FileType { - Jpeg, - Png, - Webp, - Gif, - Pdf, - Doc, - Docx, - Unknown, -} - -impl FileType { - pub fn as_folder(&self) -> &str { - match self { - FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles", - FileType::Pdf | FileType::Doc | FileType::Docx => "documents", - FileType::Unknown => "misc", - } - } - - pub fn max_size(&self) -> usize { - match self { - FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => 5 * 1024 * 1024, // 5MB for images - FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, // 10MB for documents - FileType::Unknown => 5 * 1024 * 1024, // 5MB default - } - } - - pub fn allowed_types(&self) -> Vec<&str> { - match self { - FileType::Jpeg => vec!["image/jpeg", "image/jpg"], - FileType::Png => vec!["image/png"], - FileType::Webp => vec!["image/webp"], - FileType::Gif => vec!["image/gif"], - FileType::Pdf => vec!["application/pdf"], - FileType::Doc => vec!["application/msword"], - FileType::Docx => vec!["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], - FileType::Unknown => vec![], // No allowed types for unknown - } - } - - pub fn from_content_type(content_type: &str) -> Self { - match content_type { - "image/jpeg" | "image/jpg" => FileType::Jpeg, - "image/png" => FileType::Png, - "image/webp" => FileType::Webp, - "image/gif" => FileType::Gif, - "application/pdf" => FileType::Pdf, - "application/msword" => FileType::Doc, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => FileType::Docx, - _ => FileType::Unknown, - } - } - - pub fn from_filename(filename: &str) -> Self { - let filename_lower = filename.to_lowercase(); - if filename_lower.ends_with(".jpg") || filename_lower.ends_with(".jpeg") { - FileType::Jpeg - } else if filename_lower.ends_with(".png") { - FileType::Png - } else if filename_lower.ends_with(".webp") { - FileType::Webp - } else if filename_lower.ends_with(".gif") { - FileType::Gif - } else if filename_lower.ends_with(".pdf") { - FileType::Pdf - } else if filename_lower.ends_with(".doc") { - FileType::Doc - } else if filename_lower.ends_with(".docx") { - FileType::Docx - } else { - FileType::Unknown - } - } -} - -#[derive(Debug, Clone)] -pub struct UploadRequest { - pub user_id: String, - pub file_type: FileType, - pub filename: String, - pub content_type: String, - pub data: Vec, -} - -#[derive(Debug, Clone)] -pub struct FileMetadata { - pub filename: String, - pub content_type: String, - pub size: usize, - pub path: String, - pub url: String, -} - -// --- Fungsi Pembantu --- - -/// Membuat instance MinioService dari struct MinioConfig. -pub async fn create_minio_service_from_config(config: MinioConfig) -> Result { - MinioService::new( - &config.endpoint, // Use raw endpoint, not endpoint_url() - &config.access_key, - &config.secret_key, - &config.bucket_name, - &config.region, - ) - .await -} - -/// Mendekode data file base64. -pub fn decode_base64_file(base64_data: &str) -> Result> { - let clean_data = if base64_data.contains(',') { - base64_data.split(',').nth(1).unwrap_or(base64_data) - } else { - base64_data - }; - - general_purpose::STANDARD - .decode(clean_data) - .map_err(|e| anyhow!("Gagal mendekode data base64: {}", e)) -} - -/// Mengekstrak tipe konten dari URL data. -pub fn extract_content_type_from_data_url(data_url: &str) -> Option { - if data_url.starts_with("data:") && let Some(type_part) = data_url.split(';').next() { - return Some(type_part.replace("data:", "")); - } - None -} diff --git a/imphnen-libs/src/postgres.rs b/imphnen-libs/src/postgres.rs deleted file mode 100644 index e873978..0000000 --- a/imphnen-libs/src/postgres.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::env; -use dotenvy::dotenv; -use sea_orm::{ - ConnectOptions, Database, DatabaseConnection, DbErr, Statement, - ConnectionTrait, QueryResult, ExecResult, DatabaseTransaction, - TransactionTrait, -}; -use tokio::time::{Duration, Instant}; -use thiserror::Error; - -/// Configuration for PostgreSQL connection -#[derive(Debug, Clone)] -pub struct PostgresConfig { - /// Database URL (e.g., postgres://user:pass@host:port/dbname) - pub database_url: String, - /// Maximum number of connections in the pool - pub pool_size: u32, - /// Connection timeout in seconds - pub connect_timeout: u64, - /// Idle timeout in seconds - pub idle_timeout: u64, - /// Max lifetime of connections in seconds - pub max_lifetime: Option, - /// Retry attempts for connection - pub retry_attempts: u32, - /// Retry delay between attempts in seconds - pub retry_delay: u64, -} - -impl Default for PostgresConfig { - fn default() -> Self { - Self { - database_url: "postgres://postgres:postgres@localhost:5432/imphnen".into(), - pool_size: 10, - connect_timeout: 30, - idle_timeout: 60, - max_lifetime: Some(1800), - retry_attempts: 3, - retry_delay: 1, - } - } -} - -impl PostgresConfig { - /// Load configuration from environment variables - pub fn from_env() -> Result { - dotenv().ok(); - - let database_url = env::var("DATABASE_URL") - .map_err(|_| PostgresError::EnvVarMissing("DATABASE_URL".into()))?; - - Ok(Self { - database_url, - pool_size: env::var("POOL_SIZE") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(10), - connect_timeout: env::var("CONNECT_TIMEOUT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(30), - idle_timeout: env::var("IDLE_TIMEOUT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(60), - max_lifetime: env::var("MAX_LIFETIME") - .ok() - .and_then(|s| s.parse().ok()) - .map(Some) - .unwrap_or(Some(1800)), - retry_attempts: env::var("RETRY_ATTEMPTS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(3), - retry_delay: env::var("RETRY_DELAY") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(1), - }) - } -} - -/// Errors that can occur during PostgreSQL connection -#[derive(Debug, Error)] -pub enum PostgresError { - /// Environment variable is missing - #[error("Environment variable {0} is missing")] - EnvVarMissing(String), - - /// Database connection error - #[error("Database connection error: {0}")] - ConnectionError(#[from] DbErr), - - /// Configuration error - #[error("Configuration error: {0}")] - ConfigError(String), - - /// Retry limit exceeded - #[error("Retry limit exceeded for database connection")] - RetryLimitExceeded, - - /// Timeout error - #[error("Connection timeout: {0}")] - TimeoutError(String), - - #[error("Operation failed: {0}")] - OperationFailed(String), -} - -/// PostgreSQL connection manager with pooling -#[derive(Clone)] -pub struct PostgresConnection { - /// Database connection pool - pub conn: DatabaseConnection, - /// Configuration - pub config: PostgresConfig, -} - -impl PostgresConnection { - /// Create a new PostgreSQL connection with connection pooling - pub async fn new(config: PostgresConfig) -> Result { - let connect_options = Self::build_connect_options(&config)?; - - // Implement retry logic for connection - let mut last_error = None; - for attempt in 1..=config.retry_attempts { - match Self::connect_with_timeout(connect_options.clone(), config.connect_timeout).await { - Ok(conn) => return Ok(Self { conn, config }), - Err(err) => { - last_error = Some(err); - if attempt < config.retry_attempts { - tokio::time::sleep(Duration::from_secs(config.retry_delay)).await; - } - } - } - } - - Err(last_error.unwrap_or_else(|| { - PostgresError::ConfigError("Failed to connect to database".into()) - })) - } - - /// Build connection options with pooling and timeouts - fn build_connect_options(config: &PostgresConfig) -> Result { - let mut options = ConnectOptions::new(config.database_url.clone()); - - options.max_connections(config.pool_size) - .min_connections(5) - .connect_timeout(Duration::from_secs(config.connect_timeout)) - .idle_timeout(Duration::from_secs(config.idle_timeout)); - - if let Some(max_lifetime) = config.max_lifetime { - options.max_lifetime(Duration::from_secs(max_lifetime)); - } - - Ok(options) - } - - /// Connect with timeout - async fn connect_with_timeout( - options: ConnectOptions, - timeout: u64, - ) -> Result { - let deadline = Instant::now() + Duration::from_secs(timeout); - - tokio::select! { - result = Database::connect(options) => result.map_err(PostgresError::ConnectionError), - _ = tokio::time::sleep_until(deadline) => { - Err(PostgresError::TimeoutError(format!( - "Connection timed out after {} seconds", - timeout - ))) - } - } - } - - /// Execute a raw SQL statement - pub async fn execute(&self, statement: Statement) -> Result { - self.conn.execute(statement).await.map_err(PostgresError::ConnectionError) - } - - /// Query one result - pub async fn query_one(&self, statement: Statement) -> Result, PostgresError> { - self.conn.query_one(statement).await.map_err(PostgresError::ConnectionError) - } - - /// Query all results - pub async fn query_all(&self, statement: Statement) -> Result, PostgresError> { - self.conn.query_all(statement).await.map_err(PostgresError::ConnectionError) - } - - /// Execute a raw SQL query and return results - pub async fn execute_raw(&self, sql: &str) -> Result, PostgresError> { - let statement = Statement::from_string( - self.conn.get_database_backend(), - sql.to_string() - ); - self.query_all(statement).await - } - - /// Get database backend type - pub fn get_database_backend(&self) -> sea_orm::DatabaseBackend { - self.conn.get_database_backend() - } - - /// Begin a transaction - pub async fn begin_transaction(&self) -> Result { - self.conn.begin().await.map_err(PostgresError::ConnectionError) - } - - /// Execute a transaction with automatic commit/rollback - pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result - where - F: FnOnce(&DatabaseTransaction) -> std::pin::Pin> + Send>> + Send + 'a + 'static, - R: Send + 'a + 'static, - { - self.conn.transaction(|txn| { - Box::pin(async move { - f(txn).await - }) - }).await.map_err(|e| { - PostgresError::ConnectionError(DbErr::Custom(e.to_string())) - }) - } - - /// Execute a simple database query - pub async fn query_simple(&self, sql: &str) -> Result, PostgresError> { - let statement = Statement::from_string( - self.conn.get_database_backend(), - sql.to_string() - ); - self.conn.query_all(statement).await.map_err(PostgresError::ConnectionError) - } -} - -/// Extension trait for AppState to add PostgreSQL functionality -pub trait AppStatePostgresExt { - /// Get the PostgreSQL connection - fn postgres_connection(&self) -> &PostgresConnection; - - /// Get the raw database connection (implements ConnectionTrait) - fn postgres_db(&self) -> &DatabaseConnection { - &self.postgres_connection().conn - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sea_orm::Statement; - - #[tokio::test] - async fn test_postgres_config_default() { - let config = PostgresConfig::default(); - assert_eq!(config.pool_size, 10); - assert_eq!(config.connect_timeout, 30); - assert_eq!(config.idle_timeout, 60); - assert_eq!(config.retry_attempts, 3); - assert_eq!(config.retry_delay, 1); - } - - #[tokio::test] - async fn test_postgres_connection_from_env() { - // Skip actual connection in test - let config = PostgresConfig::from_env(); - assert!(config.is_ok()); - } - - #[tokio::test] - async fn test_postgres_statement_execution() { - // This is a mock test since we don't want to connect to a real database in tests - let config = PostgresConfig::default(); - let connection_result = PostgresConnection::new(config).await; - - match connection_result { - Ok(_) => { - // If we somehow got a connection, test statement execution - let statement = Statement::from_string( - sea_orm::DatabaseBackend::Postgres, - "SELECT 1".to_string(), - ); - - // We expect this to fail in a test environment without a real database - assert!(connection_result.unwrap().execute(statement).await.is_err()); - } - Err(_) => { - // Expected behavior in test environment - assert!(true); - } - } - } -} \ No newline at end of file diff --git a/imphnen-libs/src/postgres/connection.rs b/imphnen-libs/src/postgres/connection.rs new file mode 100644 index 0000000..b6c9350 --- /dev/null +++ b/imphnen-libs/src/postgres/connection.rs @@ -0,0 +1,164 @@ +use dotenvy::dotenv; +use sea_orm::{ + ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbErr, +}; +use std::env; +use thiserror::Error; +use tokio::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct PostgresConfig { + pub database_url: String, + pub pool_size: u32, + pub connect_timeout: u64, + pub idle_timeout: u64, + pub max_lifetime: Option, + pub retry_attempts: u32, + pub retry_delay: u64, +} + +impl Default for PostgresConfig { + fn default() -> Self { + Self { + database_url: "postgres://postgres:postgres@localhost:5432/imphnen".into(), + pool_size: 10, + connect_timeout: 30, + idle_timeout: 60, + max_lifetime: Some(1800), + retry_attempts: 3, + retry_delay: 1, + } + } +} + +impl PostgresConfig { + pub fn from_env() -> Result { + dotenv().ok(); + + let database_url = env::var("DATABASE_URL") + .map_err(|_| PostgresError::EnvVarMissing("DATABASE_URL".into()))?; + + Ok(Self { + database_url, + pool_size: env::var("POOL_SIZE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(10), + connect_timeout: env::var("CONNECT_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(30), + idle_timeout: env::var("IDLE_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(60), + max_lifetime: env::var("MAX_LIFETIME") + .ok() + .and_then(|s| s.parse().ok()) + .map(Some) + .unwrap_or(Some(1800)), + retry_attempts: env::var("RETRY_ATTEMPTS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3), + retry_delay: env::var("RETRY_DELAY") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1), + }) + } +} + +#[derive(Debug, Error)] +pub enum PostgresError { + #[error("Environment variable {0} is missing")] + EnvVarMissing(String), + + #[error("Database connection error: {0}")] + ConnectionError(#[from] DbErr), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error("Retry limit exceeded for database connection")] + RetryLimitExceeded, + + #[error("Connection timeout: {0}")] + TimeoutError(String), + + #[error("Operation failed: {0}")] + OperationFailed(String), +} + +#[derive(Clone)] +pub struct PostgresConnection { + pub conn: DatabaseConnection, + pub config: PostgresConfig, +} + +impl PostgresConnection { + pub async fn new(config: PostgresConfig) -> Result { + let connect_options = Self::build_connect_options(&config)?; + + let mut last_error = None; + for attempt in 1..=config.retry_attempts { + match Self::connect_with_timeout( + connect_options.clone(), + config.connect_timeout, + ) + .await + { + Ok(conn) => return Ok(Self { conn, config }), + Err(err) => { + last_error = Some(err); + if attempt < config.retry_attempts { + tokio::time::sleep(Duration::from_secs(config.retry_delay)).await; + } + } + } + } + + Err(last_error.unwrap_or_else(|| { + PostgresError::ConfigError("Failed to connect to database".into()) + })) + } + + fn build_connect_options( + config: &PostgresConfig, + ) -> Result { + let mut options = ConnectOptions::new(config.database_url.clone()); + + options + .max_connections(config.pool_size) + .min_connections(5) + .connect_timeout(Duration::from_secs(config.connect_timeout)) + .idle_timeout(Duration::from_secs(config.idle_timeout)); + + if let Some(max_lifetime) = config.max_lifetime { + options.max_lifetime(Duration::from_secs(max_lifetime)); + } + + Ok(options) + } + + async fn connect_with_timeout( + options: ConnectOptions, + timeout: u64, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(timeout); + + tokio::select! { + result = Database::connect(options) => result.map_err(PostgresError::ConnectionError), + _ = tokio::time::sleep_until(deadline) => { + Err(PostgresError::TimeoutError(format!( + "Connection timed out after {} seconds", + timeout + ))) + } + } + } + + pub fn get_database_backend(&self) -> sea_orm::DatabaseBackend { + self.conn.get_database_backend() + } +} diff --git a/imphnen-libs/src/postgres/examples.rs b/imphnen-libs/src/postgres/examples.rs deleted file mode 100644 index c656158..0000000 --- a/imphnen-libs/src/postgres/examples.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Examples and usage patterns for PostgreSQL integration with SeaORM - -use std::sync::Arc; -use uuid::Uuid; -use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, DatabaseConnection}; - -use crate::{ - postgres::{PostgresConnection, PostgresConfig, PostgresError}, - AppState, AppStatePostgresExt, - imphnen_entities::seaorm::auth::users::Entity as UserEntity, - imphnen_entities::seaorm::auth::users::Model as UserModel, - imphnen_entities::seaorm::auth::users::ActiveModel as UserActiveModel, - imphnen_entities::seaorm::common::enums::ResourceEnum, -}; - -/// Example: Basic PostgreSQL connection usage -pub async fn basic_postgres_usage_example() -> Result<(), PostgresError> { - // Load configuration from environment variables - let config = PostgresConfig::from_env()?; - - // Create PostgreSQL connection - let postgres_conn = PostgresConnection::new(config).await?; - - // Example: Execute a raw SQL query - let statement = sea_orm::Statement::from_string( - sea_orm::DatabaseBackend::Postgres, - "SELECT version()".into(), - ); - - let result = postgres_conn.execute(statement).await?; - println!("PostgreSQL version query result: {:?}", result); - - Ok(()) -} - -/// Example: PostgreSQL integration with AppState -pub async fn app_state_integration_example( - postgres_config: PostgresConfig, -) -> Result { - // Create AppState with PostgreSQL connection - let app_state = AppState::new( - postgres_config, - Arc::new(dummy_user_lookup_service()), - Arc::new(dummy_auth_repository()), - ).await?; - - // Access PostgreSQL connection from AppState - let postgres_conn = app_state.postgres_connection(); - println!("Successfully accessed PostgreSQL connection from AppState"); - - Ok(app_state) -} - -/// Example: Repository pattern with PostgreSQL (simplified) -pub struct UserRepository { - postgres_conn: Arc, -} - -impl UserRepository { - /// Create a new UserRepository - pub fn new(postgres_conn: Arc) -> Self { - Self { postgres_conn } - } - - /// Get user by email - pub async fn get_user_by_email(&self, email: &str) -> Result, PostgresError> { - let users = UserEntity::find() - .filter(UserEntity::email.eq(email)) - .all(&self.postgres_conn.conn) - .await - .map_err(|e| PostgresError::ConnectionError(e.into()))?; - - Ok(users.into_iter().next()) - } - - /// Create a new user - pub async fn create_user(&self, user: UserActiveModel) -> Result { - let result = user.save(&self.postgres_conn.conn) - .await - .map_err(|e| PostgresError::ConnectionError(e.into()))?; - - Ok(result) - } -} - - -/// Example: Service layer using PostgreSQL repository -pub struct UserService { - user_repository: UserRepository, -} - -impl UserService { - /// Create a new UserService - pub fn new(user_repository: UserRepository) -> Self { - Self { user_repository } - } - - /// Get user by email with additional business logic - pub async fn get_user_by_email_with_logging(&self, email: &str) -> Result, PostgresError> { - println!("Attempting to find user with email: {}", email); - - let user = self.user_repository.get_user_by_email(email).await?; - - if let Some(user) = &user { - println!("Found user: {}", user.username); - } else { - println!("User not found with email: {}", email); - } - - Ok(user) - } -} - -/// Dummy implementations for dependencies -fn dummy_user_lookup_service() -> impl crate::services::UserLookupService { - struct DummyUserLookupService; - impl crate::services::UserLookupService for DummyUserLookupService { - async fn lookup_user(&self, _: &str) -> Result, String> { - Ok(None) - } - } - DummyUserLookupService -} - -fn dummy_auth_repository() -> impl crate::services::AuthRepositoryTrait { - struct DummyAuthRepository; - impl crate::services::AuthRepositoryTrait for DummyAuthRepository { - async fn verify_credentials(&self, _: &str, _: &str) -> Result { - Ok(false) - } - } - DummyAuthRepository -} - -#[cfg(test)] -mod tests { - use super::*; - use sea_orm::MockDatabaseConnection; - - #[tokio::test] - async fn test_postgres_config_from_env() { - // This test doesn't actually check environment variables - // It just ensures the method doesn't panic - let result = PostgresConfig::from_env(); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_user_repository_create() { - let mock_conn = MockDatabaseConnection::new(); - let postgres_conn = Arc::new(PostgresConnection { - conn: mock_conn, - config: PostgresConfig::default(), - }); - - let user_repo = UserRepository::new(postgres_conn); - - // We can't actually test the create_user method without a real database - // but we can test that it compiles and doesn't panic - let user_active_model = UserActiveModel { - id: sea_orm::Set(Uuid::new_v4()), - email: sea_orm::Set("test@example.com".into()), - username: sea_orm::Set("testuser".into()), - // Add other required fields as needed - ..Default::default() - }; - - let result = user_repo.create_user(user_active_model).await; - assert!(result.is_err()); // Expected to fail with mock connection - } -} \ No newline at end of file diff --git a/imphnen-libs/src/postgres/helpers.rs b/imphnen-libs/src/postgres/helpers.rs new file mode 100644 index 0000000..8b1e6a0 --- /dev/null +++ b/imphnen-libs/src/postgres/helpers.rs @@ -0,0 +1,97 @@ +use super::connection::{PostgresConnection, PostgresError}; +use sea_orm::{ + ConnectionTrait, DatabaseTransaction, DbErr, ExecResult, QueryResult, Statement, + TransactionTrait, +}; + +impl PostgresConnection { + pub async fn execute( + &self, + statement: Statement, + ) -> Result { + self + .conn + .execute(statement) + .await + .map_err(PostgresError::ConnectionError) + } + + pub async fn query_one( + &self, + statement: Statement, + ) -> Result, PostgresError> { + self + .conn + .query_one(statement) + .await + .map_err(PostgresError::ConnectionError) + } + + pub async fn query_all( + &self, + statement: Statement, + ) -> Result, PostgresError> { + self + .conn + .query_all(statement) + .await + .map_err(PostgresError::ConnectionError) + } + + pub async fn execute_raw( + &self, + sql: &str, + ) -> Result, PostgresError> { + let statement = + Statement::from_string(self.conn.get_database_backend(), sql.to_string()); + self.query_all(statement).await + } + + pub async fn begin_transaction( + &self, + ) -> Result { + self + .conn + .begin() + .await + .map_err(PostgresError::ConnectionError) + } + + pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result + where + F: FnOnce( + &DatabaseTransaction, + ) -> std::pin::Pin< + Box> + Send>, + > + Send + + 'a + 'static, + R: Send + 'a + 'static, + { + self + .conn + .transaction(|txn| Box::pin(async move { f(txn).await })) + .await + .map_err(|e| PostgresError::ConnectionError(DbErr::Custom(e.to_string()))) + } + + pub async fn query_simple( + &self, + sql: &str, + ) -> Result, PostgresError> { + let statement = + Statement::from_string(self.conn.get_database_backend(), sql.to_string()); + self + .conn + .query_all(statement) + .await + .map_err(PostgresError::ConnectionError) + } +} + +pub trait AppStatePostgresExt { + fn postgres_connection(&self) -> &PostgresConnection; + + fn postgres_db(&self) -> &sea_orm::DatabaseConnection { + &self.postgres_connection().conn + } +} diff --git a/imphnen-libs/src/postgres/mod.rs b/imphnen-libs/src/postgres/mod.rs new file mode 100644 index 0000000..e426e0c --- /dev/null +++ b/imphnen-libs/src/postgres/mod.rs @@ -0,0 +1,5 @@ +pub mod connection; +pub mod helpers; + +pub use connection::{PostgresConfig, PostgresConnection, PostgresError}; +pub use helpers::AppStatePostgresExt; diff --git a/imphnen-libs/src/services.rs b/imphnen-libs/src/services.rs deleted file mode 100644 index bf7adf6..0000000 --- a/imphnen-libs/src/services.rs +++ /dev/null @@ -1,819 +0,0 @@ -//! Service abstractions for the application -#![allow(clippy::field_reassign_with_default)] - -use crate::{postgres::PostgresError, AppState}; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; -use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity; -use imphnen_entities::seaorm::auth::users::Model as UserModel; -use imphnen_entities::UsersDetailQueryDto; -use imphnen_entities::PermissionsQueryDto; -use sea_orm::prelude::Json; -use sea_orm::{ - ActiveModelTrait, - ActiveValue, - ColumnTrait, - EntityTrait, - PaginatorTrait, - QueryFilter, - QuerySelect, -}; -use std::result::Result; -use thiserror::Error; -use uuid::Uuid; - -/// Service-related errors -#[derive(Debug, Error)] -pub enum ServiceError { - #[error("User not found: {0}")] - UserNotFound(String), - - #[error("Database error: {0}")] - DatabaseError(#[from] sea_orm::DbErr), - - #[error("Connection error: {0}")] - ConnectionError(#[from] PostgresError), - - #[error("Authentication failed: {0}")] - AuthenticationFailed(String), - - #[error("Authorization failed: {0}")] - AuthorizationFailed(String), - - #[error("Validation error: {0}")] - ValidationError(String), - - #[error("Internal service error: {0}")] - InternalError(String), -} - -/// User reference types for different identification methods -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] -pub enum UserReference { - /// User ID (UUID) - Id(Uuid), - /// User email address - Email(String), - /// User username - Username(String), - /// PostgreSQL-specific user model - Model(UserModel), -} - -/// Extended user information with additional computed fields -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ExtendedUserInfo { - pub basic_info: UsersDetailQueryDto, - pub last_login_at: Option>, - pub login_count: u64, - pub account_age_days: i64, - pub is_recently_active: bool, -} - -/// User registration data structure -#[derive(Debug, Clone)] -pub struct UserRegistrationData { - pub id: Option, - pub email: String, - pub password_hash: String, - pub username: String, - pub first_name: Option, - pub last_name: Option, - pub avatar_url: Option, - pub metadata: Option, - pub role_id: Option, -} - -/// Convert UserModel to UsersDetailQueryDto -fn model_to_dto(model: &UserModel, role_model: Option<&imphnen_entities::seaorm::auth::roles::Model>) -> UsersDetailQueryDto { - let mut dto = UsersDetailQueryDto::default(); - dto.id = model.id.to_string(); - dto.fullname = format!("{} {}", model.first_name.as_deref().unwrap_or(""), model.last_name.as_deref().unwrap_or("")).trim().to_string(); - dto.legal_name = None; - dto.email = model.email.clone(); - dto.avatar = model.avatar_url.clone(); - dto.is_active = model.is_active; - dto.is_deleted = model.deleted_at.is_some(); - dto.profile_extension = model.metadata.clone().and_then(|m| serde_json::from_value(m).ok()); - dto.password = String::new(); - - if let Some(role) = role_model { - let mut role_dto = imphnen_entities::RolesDetailQueryDto::default(); - role_dto.id = role.id.to_string(); - role_dto.name = role.name.clone(); - role_dto.is_deleted = false; - - // Populate permissions - if let Some(perms_json) = &role.permissions { - println!("DEBUG: perms_json: {:?}", perms_json); - if let Ok(perms_list) = serde_json::from_value::>(perms_json.clone()) { - println!("DEBUG: perms_list: {:?}", perms_list); - let dtos = perms_list.into_iter().map(|p| { - // Create PermissionsQueryDto wrapped in Option - Some(PermissionsQueryDto { - id: Some(p.clone()), - name: Some(p), - created_at: None, - updated_at: None, - }) - }).collect(); - role_dto.permissions = Some(dtos); - } - } - - dto.role = role_dto; - } else { - dto.role = imphnen_entities::RolesDetailQueryDto::default(); - } - - dto.created_at = model.created_at.to_rfc3339(); - dto.updated_at = model.updated_at.to_rfc3339(); - dto.mentor_id = None; - dto.from_profile_extension() -} - -/// User lookup service trait with comprehensive user retrieval methods -#[async_trait] -pub trait UserLookupService: Send + Sync { - async fn get_user_by_id( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result; - - async fn get_user_by_email( - &self, - email: &str, - state: &AppState, - ) -> Result; - - async fn get_user_by_username( - &self, - username: &str, - state: &AppState, - ) -> Result; - - async fn get_user_by_reference( - &self, - reference: UserReference, - state: &AppState, - ) -> Result; - - async fn user_exists( - &self, - reference: UserReference, - state: &AppState, - ) -> Result; - - async fn get_users_by_ids( - &self, - user_ids: Vec, - state: &AppState, - ) -> Result, ServiceError>; - - async fn search_users( - &self, - query: &str, - offset: u64, - limit: u64, - state: &AppState, - ) -> Result, ServiceError>; - - async fn count_users(&self, state: &AppState) -> Result; -} - -/// Authentication repository trait with comprehensive auth operations -#[async_trait] -pub trait AuthRepositoryTrait: Send + Sync { - async fn get_user_for_auth( - &self, - email: &str, - state: &AppState, - ) -> Result; - - async fn validate_credentials( - &self, - email: &str, - password: &str, - state: &AppState, - ) -> Result; - - async fn update_last_login( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError>; - - async fn create_user( - &self, - user_data: UserRegistrationData, - state: &AppState, - ) -> Result; - - async fn update_password( - &self, - user_id: Uuid, - new_password_hash: &str, - state: &AppState, - ) -> Result<(), ServiceError>; - - async fn deactivate_user( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError>; - - async fn reactivate_user( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError>; - - async fn get_user_permissions( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result, ServiceError>; - - async fn has_permission( - &self, - user_id: Uuid, - permission: &str, - state: &AppState, - ) -> Result; -} - -/// Default implementation of UserLookupService using PostgreSQL -pub struct PostgresUserLookupService; - -impl Default for PostgresUserLookupService { - fn default() -> Self { - Self::new() - } -} - -impl PostgresUserLookupService { - pub fn new() -> Self { - Self - } - - /// Convert UserModel to ExtendedUserInfo - fn model_to_extended_info(&self, model: UserModel, role_model: Option) -> ExtendedUserInfo { - let basic_info = model_to_dto(&model, role_model.as_ref()); - - let account_age_days = (Utc::now() - model.created_at).num_days(); - let is_recently_active = - model.updated_at > Utc::now() - chrono::Duration::days(30); - - ExtendedUserInfo { - basic_info, - last_login_at: None, - login_count: 0, - account_age_days, - is_recently_active, - } - } -} - -#[async_trait] -impl UserLookupService for PostgresUserLookupService { - async fn get_user_by_id( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let (user, role) = UsersEntity::find_by_id(user_id) - .find_also_related(RolesEntity) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - Ok(self.model_to_extended_info(user, role)) - } - - async fn get_user_by_email( - &self, - email: &str, - state: &AppState, - ) -> Result { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let (user, role) = UsersEntity::find() - .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email)) - .find_also_related(RolesEntity) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with email {email} not found")) - })?; - - Ok(self.model_to_extended_info(user, role)) - } - - async fn get_user_by_username( - &self, - username: &str, - state: &AppState, - ) -> Result { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let (user, role) = UsersEntity::find() - .filter(imphnen_entities::seaorm::auth::users::Column::Username.eq(username)) - .find_also_related(RolesEntity) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!( - "User with username {} not found", - username - )) - })?; - - Ok(self.model_to_extended_info(user, role)) - } - - async fn get_user_by_reference( - &self, - reference: UserReference, - state: &AppState, - ) -> Result { - match reference { - UserReference::Id(id) => self.get_user_by_id(id, state).await, - UserReference::Email(email) => self.get_user_by_email(&email, state).await, - UserReference::Username(username) => { - self.get_user_by_username(&username, state).await - } - UserReference::Model(model) => { - let role = if let Some(role_id) = model.role_id { - RolesEntity::find_by_id(role_id).one(&state.postgres_connection.conn).await.unwrap_or(None) - } else { - None - }; - Ok(self.model_to_extended_info(model, role)) - }, - } - } - - async fn user_exists( - &self, - reference: UserReference, - state: &AppState, - ) -> Result { - let exists = match reference { - UserReference::Id(id) => { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - UsersEntity::find_by_id(id) - .count(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - > 0 - } - UserReference::Email(email) => { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - UsersEntity::find() - .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(&email)) - .count(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - > 0 - } - UserReference::Username(username) => { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - UsersEntity::find() - .filter( - imphnen_entities::seaorm::auth::users::Column::Username.eq(&username), - ) - .count(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - > 0 - } - UserReference::Model(_) => true, - }; - - Ok(exists) - } - - async fn get_users_by_ids( - &self, - user_ids: Vec, - state: &AppState, - ) -> Result, ServiceError> { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let users_with_roles = UsersEntity::find() - .filter(imphnen_entities::seaorm::auth::users::Column::Id.is_in(user_ids)) - .find_also_related(RolesEntity) - .all(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok( - users_with_roles - .into_iter() - .map(|(user, role)| self.model_to_extended_info(user, role)) - .collect(), - ) - } - - async fn search_users( - &self, - query: &str, - offset: u64, - limit: u64, - state: &AppState, - ) -> Result, ServiceError> { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let search_pattern = format!("%{query}%"); - - let users_with_roles = UsersEntity::find() - .filter( - imphnen_entities::seaorm::auth::users::Column::Email - .contains(&search_pattern) - .or( - imphnen_entities::seaorm::auth::users::Column::Username - .contains(&search_pattern), - ) - .or( - imphnen_entities::seaorm::auth::users::Column::FirstName - .contains(&search_pattern), - ) - .or( - imphnen_entities::seaorm::auth::users::Column::LastName - .contains(&search_pattern), - ), - ) - .offset(offset) - .limit(limit) - .find_also_related(RolesEntity) - .all(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok( - users_with_roles - .into_iter() - .map(|(user, role)| self.model_to_extended_info(user, role)) - .collect(), - ) - } - - async fn count_users(&self, state: &AppState) -> Result { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - let count = UsersEntity::find() - .count(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(count) - } -} - -/// Default implementation of AuthRepositoryTrait using PostgreSQL -pub struct PostgresAuthRepository; - -impl Default for PostgresAuthRepository { - fn default() -> Self { - Self::new() - } -} - -impl PostgresAuthRepository { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl AuthRepositoryTrait for PostgresAuthRepository { - async fn get_user_for_auth( - &self, - email: &str, - state: &AppState, - ) -> Result { - use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; - - UsersEntity::find() - .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email)) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with email {email} not found")) - }) - } - - async fn validate_credentials( - &self, - email: &str, - password: &str, - state: &AppState, - ) -> Result { - use crate::argon::verify_password; - - let user = self.get_user_for_auth(email, state).await?; - - if !user.is_active { - return Err(ServiceError::AuthenticationFailed( - "Account is deactivated".to_string(), - )); - } - - if !user.is_verified { - return Err(ServiceError::AuthenticationFailed( - "Account not verified".to_string(), - )); - } - - let is_valid = verify_password(password, &user.password_hash).map_err(|e| { - ServiceError::InternalError(format!("Password verification failed: {e}")) - })?; - - if !is_valid { - return Err(ServiceError::AuthenticationFailed( - "Invalid password".to_string(), - )); - } - - Ok(user) - } - - async fn update_last_login( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError> { - use imphnen_entities::seaorm::auth::users::{ - ActiveModel, Entity as UsersEntity, - }; - - let user = UsersEntity::find_by_id(user_id) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - let mut active_model: ActiveModel = user.into(); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - active_model - .update(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(()) - } - - async fn create_user( - &self, - user_registration_data: UserRegistrationData, - state: &AppState, - ) -> Result { - use imphnen_entities::seaorm::auth::users::ActiveModel; - let user_id = user_registration_data.id.unwrap_or_else(Uuid::new_v4); // Use provided ID or generate new - let active_model = ActiveModel { - id: ActiveValue::Set(user_id), - email: ActiveValue::Set(user_registration_data.email), - password_hash: ActiveValue::Set(user_registration_data.password_hash), - username: ActiveValue::Set(user_registration_data.username), - first_name: ActiveValue::Set(user_registration_data.first_name), - last_name: ActiveValue::Set(user_registration_data.last_name), - avatar_url: ActiveValue::Set(user_registration_data.avatar_url), - is_verified: ActiveValue::Set(false), - is_active: ActiveValue::Set(true), - // Role-based permissions will determine admin access. - metadata: ActiveValue::Set(user_registration_data.metadata), - created_at: ActiveValue::Set(Utc::now()), - updated_at: ActiveValue::Set(Utc::now()), - deleted_at: ActiveValue::Set(None), - role_id: ActiveValue::Set(user_registration_data.role_id), - }; - - let created_user: UserModel = active_model - .insert(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(created_user) - } - async fn update_password( - &self, - user_id: Uuid, - new_password_hash: &str, - state: &AppState, - ) -> Result<(), ServiceError> { - use imphnen_entities::seaorm::auth::users::{ - ActiveModel, Entity as UsersEntity, - }; - - let user = UsersEntity::find_by_id(user_id) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - let mut active_model: ActiveModel = user.into(); - active_model.password_hash = ActiveValue::Set(new_password_hash.to_string()); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - active_model - .update(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(()) - } - - async fn deactivate_user( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError> { - use imphnen_entities::seaorm::auth::users::{ - ActiveModel, Entity as UsersEntity, - }; - - let user = UsersEntity::find_by_id(user_id) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - let mut active_model: ActiveModel = user.into(); - active_model.is_active = ActiveValue::Set(false); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - active_model - .update(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(()) - } - - async fn reactivate_user( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result<(), ServiceError> { - use imphnen_entities::seaorm::auth::users::{ - ActiveModel, Entity as UsersEntity, - }; - - let user = UsersEntity::find_by_id(user_id) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - let mut active_model: ActiveModel = user.into(); - active_model.is_active = ActiveValue::Set(true); - active_model.updated_at = ActiveValue::Set(Utc::now()); - - active_model - .update(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)?; - - Ok(()) - } - - async fn get_user_permissions( - &self, - user_id: Uuid, - state: &AppState, - ) -> Result, ServiceError> { - let user = UsersEntity::find_by_id(user_id) - .one(&state.postgres_connection.conn) - .await - .map_err(ServiceError::DatabaseError)? - .ok_or_else(|| { - ServiceError::UserNotFound(format!("User with ID {user_id} not found")) - })?; - - // Determine permissions from role if available. Fall back to verification-based permissions. - let permissions = if let Some(role_id) = user.role_id { - // Try to fetch the role from DB and return its configured permissions - match RolesEntity::find_by_id(role_id).one(&state.postgres_connection.conn).await.map_err(ServiceError::DatabaseError)? { - Some(role) => { - let perms = if let Some(perms_json) = role.permissions.clone() { - serde_json::from_value::>(perms_json).unwrap_or_default() - } else { - vec![] - }; - - if role.is_system_role { - if perms.is_empty() { - vec!["admin.*".to_string(), "user.*".to_string(), "content.*".to_string()] - } else { - perms - } - } else if perms.is_empty() { - if user.is_verified { - vec!["user.read".to_string(), "user.update".to_string(), "content.read".to_string()] - } else { - vec!["user.read".to_string(), "content.read".to_string()] - } - } else { - perms - } - } - None => { - if user.is_verified { - vec!["user.read".to_string(), "user.update".to_string(), "content.read".to_string()] - } else { - vec!["user.read".to_string(), "content.read".to_string()] - } - } - } - } else if user.is_verified { - vec![ - "user.read".to_string(), - "user.update".to_string(), - "content.read".to_string(), - ] - } else { - vec!["user.read".to_string(), "content.read".to_string()] - }; - - Ok(permissions) - } - - async fn has_permission( - &self, - user_id: Uuid, - permission: &str, - state: &AppState, - ) -> Result { - let permissions = self.get_user_permissions(user_id, state).await?; - Ok( - permissions.contains(&permission.to_string()) - || permissions.iter().any(|p| p.ends_with(".*")), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_user_reference_creation() { - let id_ref = UserReference::Id(Uuid::new_v4()); - let email_ref = UserReference::Email("test@example.com".to_string()); - let username_ref = UserReference::Username("testuser".to_string()); - - assert!(matches!(id_ref, UserReference::Id(_))); - assert!(matches!(email_ref, UserReference::Email(_))); - assert!(matches!(username_ref, UserReference::Username(_))); - } - - #[test] - fn test_service_error_types() { - let error = ServiceError::UserNotFound("Test user".to_string()); - assert_eq!(error.to_string(), "User not found: Test user"); - - let error = ServiceError::AuthenticationFailed("Invalid password".to_string()); - assert_eq!(error.to_string(), "Authentication failed: Invalid password"); - } - - #[test] - fn test_user_registration_data() { - let registration_data = UserRegistrationData { - id: None, - email: "test@example.com".to_string(), - password_hash: "hashed_password".to_string(), - username: "testuser".to_string(), - first_name: Some("Test".to_string()), - last_name: Some("User".to_string()), - avatar_url: None, - metadata: None, - role_id: None, - }; - - assert_eq!(registration_data.email, "test@example.com"); - assert_eq!(registration_data.username, "testuser"); - } -} diff --git a/imphnen-libs/src/services/auth_repository.rs b/imphnen-libs/src/services/auth_repository.rs new file mode 100644 index 0000000..2661042 --- /dev/null +++ b/imphnen-libs/src/services/auth_repository.rs @@ -0,0 +1,336 @@ +use async_trait::async_trait; +use chrono::Utc; +use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity; +use imphnen_entities::seaorm::auth::users::{ + Entity as UsersEntity, Model as UserModel, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, +}; +use std::result::Result; +use uuid::Uuid; + +use super::dto::UserRegistrationData; +use super::error::ServiceError; +use crate::AppState; + +#[async_trait] +pub trait AuthRepositoryTrait: Send + Sync { + async fn get_user_for_auth( + &self, + email: &str, + state: &AppState, + ) -> Result; + async fn validate_credentials( + &self, + email: &str, + password: &str, + state: &AppState, + ) -> Result; + async fn update_last_login( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError>; + async fn create_user( + &self, + user_data: UserRegistrationData, + state: &AppState, + ) -> Result; + async fn update_password( + &self, + user_id: Uuid, + new_password_hash: &str, + state: &AppState, + ) -> Result<(), ServiceError>; + async fn deactivate_user( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError>; + async fn reactivate_user( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError>; + async fn get_user_permissions( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result, ServiceError>; + async fn has_permission( + &self, + user_id: Uuid, + permission: &str, + state: &AppState, + ) -> Result; +} + +pub struct PostgresAuthRepository; + +impl Default for PostgresAuthRepository { + fn default() -> Self { + Self::new() + } +} + +impl PostgresAuthRepository { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl AuthRepositoryTrait for PostgresAuthRepository { + async fn get_user_for_auth( + &self, + email: &str, + state: &AppState, + ) -> Result { + UsersEntity::find() + .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email)) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with email {email} not found")) + }) + } + + async fn validate_credentials( + &self, + email: &str, + password: &str, + state: &AppState, + ) -> Result { + use crate::argon::verify_password; + let user = self.get_user_for_auth(email, state).await?; + if !user.is_active { + return Err(ServiceError::AuthenticationFailed( + "Account is deactivated".to_string(), + )); + } + if !user.is_verified { + return Err(ServiceError::AuthenticationFailed( + "Account not verified".to_string(), + )); + } + let is_valid = verify_password(password, &user.password_hash).map_err(|e| { + ServiceError::InternalError(format!("Password verification failed: {e}")) + })?; + if !is_valid { + return Err(ServiceError::AuthenticationFailed( + "Invalid password".to_string(), + )); + } + Ok(user) + } + + async fn update_last_login( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError> { + use imphnen_entities::seaorm::auth::users::ActiveModel; + let user = UsersEntity::find_by_id(user_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + let mut active_model: ActiveModel = user.into(); + active_model.updated_at = ActiveValue::Set(Utc::now()); + active_model + .update(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok(()) + } + + async fn create_user( + &self, + data: UserRegistrationData, + state: &AppState, + ) -> Result { + use imphnen_entities::seaorm::auth::users::ActiveModel; + let user_id = data.id.unwrap_or_else(Uuid::new_v4); + let active_model = ActiveModel { + id: ActiveValue::Set(user_id), + email: ActiveValue::Set(data.email), + password_hash: ActiveValue::Set(data.password_hash), + username: ActiveValue::Set(data.username), + first_name: ActiveValue::Set(data.first_name), + last_name: ActiveValue::Set(data.last_name), + avatar_url: ActiveValue::Set(data.avatar_url), + is_verified: ActiveValue::Set(false), + is_active: ActiveValue::Set(true), + metadata: ActiveValue::Set(data.metadata), + created_at: ActiveValue::Set(Utc::now()), + updated_at: ActiveValue::Set(Utc::now()), + deleted_at: ActiveValue::Set(None), + role_id: ActiveValue::Set(data.role_id), + }; + active_model + .insert(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError) + } + + async fn update_password( + &self, + user_id: Uuid, + new_password_hash: &str, + state: &AppState, + ) -> Result<(), ServiceError> { + use imphnen_entities::seaorm::auth::users::ActiveModel; + let user = UsersEntity::find_by_id(user_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + let mut active_model: ActiveModel = user.into(); + active_model.password_hash = ActiveValue::Set(new_password_hash.to_string()); + active_model.updated_at = ActiveValue::Set(Utc::now()); + active_model + .update(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok(()) + } + + async fn deactivate_user( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError> { + use imphnen_entities::seaorm::auth::users::ActiveModel; + let user = UsersEntity::find_by_id(user_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + let mut active_model: ActiveModel = user.into(); + active_model.is_active = ActiveValue::Set(false); + active_model.updated_at = ActiveValue::Set(Utc::now()); + active_model + .update(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok(()) + } + + async fn reactivate_user( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result<(), ServiceError> { + use imphnen_entities::seaorm::auth::users::ActiveModel; + let user = UsersEntity::find_by_id(user_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + let mut active_model: ActiveModel = user.into(); + active_model.is_active = ActiveValue::Set(true); + active_model.updated_at = ActiveValue::Set(Utc::now()); + active_model + .update(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok(()) + } + + async fn get_user_permissions( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result, ServiceError> { + let user = UsersEntity::find_by_id(user_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + + let permissions = if let Some(role_id) = user.role_id { + match RolesEntity::find_by_id(role_id) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + { + Some(role) => { + let perms = role + .permissions + .clone() + .and_then(|j| serde_json::from_value::>(j).ok()) + .unwrap_or_default(); + if role.is_system_role { + if perms.is_empty() { + vec![ + "admin.*".to_string(), + "user.*".to_string(), + "content.*".to_string(), + ] + } else { + perms + } + } else if perms.is_empty() { + if user.is_verified { + vec![ + "user.read".to_string(), + "user.update".to_string(), + "content.read".to_string(), + ] + } else { + vec!["user.read".to_string(), "content.read".to_string()] + } + } else { + perms + } + } + None => { + if user.is_verified { + vec![ + "user.read".to_string(), + "user.update".to_string(), + "content.read".to_string(), + ] + } else { + vec!["user.read".to_string(), "content.read".to_string()] + } + } + } + } else if user.is_verified { + vec![ + "user.read".to_string(), + "user.update".to_string(), + "content.read".to_string(), + ] + } else { + vec!["user.read".to_string(), "content.read".to_string()] + }; + + Ok(permissions) + } + + async fn has_permission( + &self, + user_id: Uuid, + permission: &str, + state: &AppState, + ) -> Result { + let permissions = self.get_user_permissions(user_id, state).await?; + Ok( + permissions.contains(&permission.to_string()) + || permissions.iter().any(|p| p.ends_with(".*")), + ) + } +} diff --git a/imphnen-libs/src/services/dto.rs b/imphnen-libs/src/services/dto.rs new file mode 100644 index 0000000..cd939ac --- /dev/null +++ b/imphnen-libs/src/services/dto.rs @@ -0,0 +1,95 @@ +use chrono::{DateTime, Utc}; +use imphnen_entities::seaorm::auth::users::Model as UserModel; +use imphnen_entities::{PermissionsQueryDto, UsersDetailQueryDto}; +use sea_orm::prelude::Json; +use uuid::Uuid; + +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum UserReference { + Id(Uuid), + Email(String), + Username(String), + Model(UserModel), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ExtendedUserInfo { + pub basic_info: UsersDetailQueryDto, + pub last_login_at: Option>, + pub login_count: u64, + pub account_age_days: i64, + pub is_recently_active: bool, +} + +#[derive(Debug, Clone)] +pub struct UserRegistrationData { + pub id: Option, + pub email: String, + pub password_hash: String, + pub username: String, + pub first_name: Option, + pub last_name: Option, + pub avatar_url: Option, + pub metadata: Option, + pub role_id: Option, +} + +pub fn model_to_dto( + model: &UserModel, + role_model: Option<&imphnen_entities::seaorm::auth::roles::Model>, +) -> UsersDetailQueryDto { + let mut dto = UsersDetailQueryDto::default(); + dto.id = model.id.to_string(); + dto.fullname = format!( + "{} {}", + model.first_name.as_deref().unwrap_or(""), + model.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(); + dto.legal_name = None; + dto.email = model.email.clone(); + dto.avatar = model.avatar_url.clone(); + dto.is_active = model.is_active; + dto.is_deleted = model.deleted_at.is_some(); + dto.profile_extension = model + .metadata + .clone() + .and_then(|m| serde_json::from_value(m).ok()); + dto.password = String::new(); + + if let Some(role) = role_model { + let mut role_dto = imphnen_entities::RolesDetailQueryDto::default(); + role_dto.id = role.id.to_string(); + role_dto.name = role.name.clone(); + role_dto.is_deleted = false; + + if let Some(perms_json) = &role.permissions + && let Ok(perms_list) = + serde_json::from_value::>(perms_json.clone()) + { + let dtos = perms_list + .into_iter() + .map(|p| { + Some(PermissionsQueryDto { + id: Some(p.clone()), + name: Some(p), + created_at: None, + updated_at: None, + }) + }) + .collect(); + role_dto.permissions = Some(dtos); + } + + dto.role = role_dto; + } else { + dto.role = imphnen_entities::RolesDetailQueryDto::default(); + } + + dto.created_at = model.created_at.to_rfc3339(); + dto.updated_at = model.updated_at.to_rfc3339(); + dto.mentor_id = None; + dto.from_profile_extension() +} diff --git a/imphnen-libs/src/services/error.rs b/imphnen-libs/src/services/error.rs new file mode 100644 index 0000000..81d2da3 --- /dev/null +++ b/imphnen-libs/src/services/error.rs @@ -0,0 +1,26 @@ +use crate::postgres::PostgresError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ServiceError { + #[error("User not found: {0}")] + UserNotFound(String), + + #[error("Database error: {0}")] + DatabaseError(#[from] sea_orm::DbErr), + + #[error("Connection error: {0}")] + ConnectionError(#[from] PostgresError), + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Authorization failed: {0}")] + AuthorizationFailed(String), + + #[error("Validation error: {0}")] + ValidationError(String), + + #[error("Internal service error: {0}")] + InternalError(String), +} diff --git a/imphnen-libs/src/services/mod.rs b/imphnen-libs/src/services/mod.rs new file mode 100644 index 0000000..52b2b59 --- /dev/null +++ b/imphnen-libs/src/services/mod.rs @@ -0,0 +1,11 @@ +#![allow(clippy::field_reassign_with_default)] + +pub mod auth_repository; +pub mod dto; +pub mod error; +pub mod user_lookup; + +pub use auth_repository::{AuthRepositoryTrait, PostgresAuthRepository}; +pub use dto::{ExtendedUserInfo, UserReference, UserRegistrationData}; +pub use error::ServiceError; +pub use user_lookup::{PostgresUserLookupService, UserLookupService}; diff --git a/imphnen-libs/src/services/user_lookup.rs b/imphnen-libs/src/services/user_lookup.rs new file mode 100644 index 0000000..b5d58f1 --- /dev/null +++ b/imphnen-libs/src/services/user_lookup.rs @@ -0,0 +1,257 @@ +use async_trait::async_trait; +use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity; +use imphnen_entities::seaorm::auth::users::Entity as UsersEntity; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect}; +use std::result::Result; +use uuid::Uuid; + +use super::dto::{ExtendedUserInfo, UserReference, model_to_dto}; +use super::error::ServiceError; +use crate::AppState; + +#[async_trait] +pub trait UserLookupService: Send + Sync { + async fn get_user_by_id( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result; + async fn get_user_by_email( + &self, + email: &str, + state: &AppState, + ) -> Result; + async fn get_user_by_username( + &self, + username: &str, + state: &AppState, + ) -> Result; + async fn get_user_by_reference( + &self, + reference: UserReference, + state: &AppState, + ) -> Result; + async fn user_exists( + &self, + reference: UserReference, + state: &AppState, + ) -> Result; + async fn get_users_by_ids( + &self, + user_ids: Vec, + state: &AppState, + ) -> Result, ServiceError>; + async fn search_users( + &self, + query: &str, + offset: u64, + limit: u64, + state: &AppState, + ) -> Result, ServiceError>; + async fn count_users(&self, state: &AppState) -> Result; +} + +pub struct PostgresUserLookupService; + +impl Default for PostgresUserLookupService { + fn default() -> Self { + Self::new() + } +} + +impl PostgresUserLookupService { + pub fn new() -> Self { + Self + } + + fn model_to_extended_info( + &self, + model: imphnen_entities::seaorm::auth::users::Model, + role_model: Option, + ) -> ExtendedUserInfo { + let basic_info = model_to_dto(&model, role_model.as_ref()); + let account_age_days = (chrono::Utc::now() - model.created_at).num_days(); + let is_recently_active = + model.updated_at > chrono::Utc::now() - chrono::Duration::days(30); + ExtendedUserInfo { + basic_info, + last_login_at: None, + login_count: 0, + account_age_days, + is_recently_active, + } + } +} + +#[async_trait] +impl UserLookupService for PostgresUserLookupService { + async fn get_user_by_id( + &self, + user_id: Uuid, + state: &AppState, + ) -> Result { + let (user, role) = UsersEntity::find_by_id(user_id) + .find_also_related(RolesEntity) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with ID {user_id} not found")) + })?; + Ok(self.model_to_extended_info(user, role)) + } + + async fn get_user_by_email( + &self, + email: &str, + state: &AppState, + ) -> Result { + let (user, role) = UsersEntity::find() + .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email)) + .find_also_related(RolesEntity) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!("User with email {email} not found")) + })?; + Ok(self.model_to_extended_info(user, role)) + } + + async fn get_user_by_username( + &self, + username: &str, + state: &AppState, + ) -> Result { + let (user, role) = UsersEntity::find() + .filter(imphnen_entities::seaorm::auth::users::Column::Username.eq(username)) + .find_also_related(RolesEntity) + .one(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + .ok_or_else(|| { + ServiceError::UserNotFound(format!( + "User with username {username} not found" + )) + })?; + Ok(self.model_to_extended_info(user, role)) + } + + async fn get_user_by_reference( + &self, + reference: UserReference, + state: &AppState, + ) -> Result { + match reference { + UserReference::Id(id) => self.get_user_by_id(id, state).await, + UserReference::Email(email) => self.get_user_by_email(&email, state).await, + UserReference::Username(username) => { + self.get_user_by_username(&username, state).await + } + UserReference::Model(model) => { + let role = if let Some(role_id) = model.role_id { + RolesEntity::find_by_id(role_id) + .one(&state.postgres_connection.conn) + .await + .unwrap_or(None) + } else { + None + }; + Ok(self.model_to_extended_info(model, role)) + } + } + } + + async fn user_exists( + &self, + reference: UserReference, + state: &AppState, + ) -> Result { + let exists = match reference { + UserReference::Id(id) => { + UsersEntity::find_by_id(id) + .count(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + > 0 + } + UserReference::Email(email) => { + UsersEntity::find() + .filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(&email)) + .count(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + > 0 + } + UserReference::Username(username) => { + UsersEntity::find() + .filter( + imphnen_entities::seaorm::auth::users::Column::Username.eq(&username), + ) + .count(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)? + > 0 + } + UserReference::Model(_) => true, + }; + Ok(exists) + } + + async fn get_users_by_ids( + &self, + user_ids: Vec, + state: &AppState, + ) -> Result, ServiceError> { + let users_with_roles = UsersEntity::find() + .filter(imphnen_entities::seaorm::auth::users::Column::Id.is_in(user_ids)) + .find_also_related(RolesEntity) + .all(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok( + users_with_roles + .into_iter() + .map(|(u, r)| self.model_to_extended_info(u, r)) + .collect(), + ) + } + + async fn search_users( + &self, + query: &str, + offset: u64, + limit: u64, + state: &AppState, + ) -> Result, ServiceError> { + use imphnen_entities::seaorm::auth::users::Column; + let pattern = format!("%{query}%"); + let users_with_roles = UsersEntity::find() + .filter( + Column::Email + .contains(&pattern) + .or(Column::Username.contains(&pattern)) + .or(Column::FirstName.contains(&pattern)) + .or(Column::LastName.contains(&pattern)), + ) + .offset(offset) + .limit(limit) + .find_also_related(RolesEntity) + .all(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError)?; + Ok( + users_with_roles + .into_iter() + .map(|(u, r)| self.model_to_extended_info(u, r)) + .collect(), + ) + } + + async fn count_users(&self, state: &AppState) -> Result { + UsersEntity::find() + .count(&state.postgres_connection.conn) + .await + .map_err(ServiceError::DatabaseError) + } +} diff --git a/imphnen-macros/Cargo.toml b/imphnen-macros/Cargo.toml index 4362447..e20fc41 100644 --- a/imphnen-macros/Cargo.toml +++ b/imphnen-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-macros" -version = "0.2.0" +version = "0.3.0" edition = "2021" [lib] diff --git a/imphnen-macros/src/lib.rs b/imphnen-macros/src/lib.rs index b6c3ee3..9346889 100644 --- a/imphnen-macros/src/lib.rs +++ b/imphnen-macros/src/lib.rs @@ -1,96 +1,98 @@ -use proc_macro::TokenStream; -use quote::{quote, format_ident}; -use syn::{parse_macro_input, Data, DeriveInput, Fields, Type, PathArguments, GenericArgument}; - -#[proc_macro_derive(Builder)] -pub fn derive_builder(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - let name = &input.ident; - let builder_name = format_ident!("{}Builder", name); - - let fields = match &input.data { - Data::Struct(data) => match &data.fields { - Fields::Named(fields) => &fields.named, - _ => panic!("Builder derive only supports structs with named fields"), - }, - _ => panic!("Builder derive only supports structs"), - }; - - let builder_fields = fields.iter().map(|f| { - let name = &f.ident; - let ty = &f.ty; - if is_option(ty) { - let inner_ty = extract_option_inner(ty); - quote! { - #name: Option<#inner_ty> - } - } else { - quote! { - #name: Option<#ty> - } - } - }); - - let builder_methods = fields.iter().map(|f| { - let name = &f.ident; - let ty = &f.ty; - if is_option(ty) { - let inner_ty = extract_option_inner(ty); - quote! { - #[must_use] - pub fn #name(mut self, #name: #inner_ty) -> Self { - self.#name = Some(#name); - self - } - } - } else { - quote! { - #[must_use] - pub fn #name(mut self, #name: #ty) -> Self { - self.#name = Some(#name); - self - } - } - } - }); - - let expanded = quote! { - #[derive(Default, serde::Serialize, serde::Deserialize)] - pub struct #builder_name { - #(#builder_fields,)* - } - - impl #builder_name { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #(#builder_methods)* - } - }; - - TokenStream::from(expanded) -} - -fn is_option(ty: &Type) -> bool { - if let Type::Path(type_path) = ty { - if let Some(segment) = type_path.path.segments.last() { - return segment.ident == "Option"; - } - } - false -} - -fn extract_option_inner(ty: &Type) -> &Type { - if let Type::Path(type_path) = ty { - if let Some(segment) = type_path.path.segments.last() { - if let PathArguments::AngleBracketed(args) = &segment.arguments { - if let Some(GenericArgument::Type(inner_ty)) = args.args.first() { - return inner_ty; - } - } - } - } - panic!("Expected Option"); -} \ No newline at end of file +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse_macro_input, Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, +}; + +#[proc_macro_derive(Builder)] +pub fn derive_builder(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let builder_name = format_ident!("{}Builder", name); + + let fields = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => &fields.named, + _ => panic!("Builder derive only supports structs with named fields"), + }, + _ => panic!("Builder derive only supports structs"), + }; + + let builder_fields = fields.iter().map(|f| { + let name = &f.ident; + let ty = &f.ty; + if is_option(ty) { + let inner_ty = extract_option_inner(ty); + quote! { + #name: Option<#inner_ty> + } + } else { + quote! { + #name: Option<#ty> + } + } + }); + + let builder_methods = fields.iter().map(|f| { + let name = &f.ident; + let ty = &f.ty; + if is_option(ty) { + let inner_ty = extract_option_inner(ty); + quote! { + #[must_use] + pub fn #name(mut self, #name: #inner_ty) -> Self { + self.#name = Some(#name); + self + } + } + } else { + quote! { + #[must_use] + pub fn #name(mut self, #name: #ty) -> Self { + self.#name = Some(#name); + self + } + } + } + }); + + let expanded = quote! { + #[derive(Default, serde::Serialize, serde::Deserialize)] + pub struct #builder_name { + #(#builder_fields,)* + } + + impl #builder_name { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #(#builder_methods)* + } + }; + + TokenStream::from(expanded) +} + +fn is_option(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "Option"; + } + } + false +} + +fn extract_option_inner(ty: &Type) -> &Type { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(GenericArgument::Type(inner_ty)) = args.args.first() { + return inner_ty; + } + } + } + } + panic!("Expected Option"); +} diff --git a/imphnen-middleware/Cargo.toml b/imphnen-middleware/Cargo.toml index 0b8eecb..223b76f 100644 --- a/imphnen-middleware/Cargo.toml +++ b/imphnen-middleware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-middleware" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/imphnen-middleware/src/audit_logging_middleware/mod.rs b/imphnen-middleware/src/audit_logging_middleware/mod.rs index f05143a..c433503 100644 --- a/imphnen-middleware/src/audit_logging_middleware/mod.rs +++ b/imphnen-middleware/src/audit_logging_middleware/mod.rs @@ -1,193 +1,181 @@ -use axum::{ - body::Body, - http::{Request, Response}, - middleware::Next, - Extension, -}; -use chrono::{DateTime, FixedOffset, Utc}; -use imphnen_entities::seaorm::common::audit_log::Model as AuditLogSchema; -use imphnen_libs::AppState; -use sea_orm::{ActiveModelTrait, Set}; -use sea_orm::prelude::Uuid; -use imphnen_utils::{extract_email, extract_email_async, extract_real_ip}; -use std::convert::Infallible; - -/// Middleware untuk mencatat semua aksi admin ke dalam audit log -pub async fn audit_logging_middleware( - Extension(state): Extension, - req: Request, - next: Next, -) -> Result, Infallible> { - let uri = req.uri().path().to_string(); - - // Hanya catat aksi admin (endpoint yang memerlukan permissions) - if is_admin_action(&uri) { - // Extract informasi pengguna dari headers - let headers = req.headers(); - let user_email = extract_user_email(headers).await; - let user_id = extract_user_id(&state, &user_email).await; - let ip_address = extract_real_ip(headers).unwrap_or_else(|| "unknown".to_string()); - let user_id_uuid = Uuid::parse_str(&user_id.clone().unwrap_or_else(|| "unknown".to_string())).unwrap_or(Uuid::nil()); - let user_agent = extract_user_agent(headers); - - // Ekstrak informasi aksi dari request - let action = extract_action(&uri, req.method().as_str()); - let resource = extract_resource(&uri); - let resource_id = extract_resource_id(&uri); - - // Simpan audit log sebelum memproses request - let audit_log = AuditLogSchema { - id: Uuid::new_v4(), - user_id: user_id_uuid, - user_email: user_email.clone().unwrap_or_else(|| "unknown".to_string()), - action, - resource, - resource_id, - old_data: None, // Untuk UPDATE/DELETE, perlu diisi setelah request - new_data: None, // Untuk CREATE/UPDATE, perlu diisi setelah request - ip_address, - user_agent, - timestamp: DateTime::::from(Utc::now()), - }; - - // Simpan audit log ke database - let action = audit_log.action.clone(); - match save_audit_log(&state.postgres_connection.conn, audit_log.clone()).await { - Ok(_) => log::debug!("Audit log saved for action: {}", action), - Err(e) => log::error!("Failed to save audit log: {}", e), - } - } - - // Lanjutkan dengan request - let response = next.run(req).await; - Ok(response) -} - -/// Periksa apakah endpoint termasuk aksi admin -fn is_admin_action(uri: &str) -> bool { - // Daftar endpoint admin yang perlu diaudit - let admin_endpoints = [ - "/v1/admin/", - "/v1/users/admin/", - "/v1/permissions/", - "/v1/roles/", - "/v1/gacha/admin/", - "/v1/cms/admin/", - ]; - - admin_endpoints.iter().any(|endpoint| uri.starts_with(endpoint)) -} - -/// Extract email pengguna dari headers -async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option { - // Coba extract email secara synchronous terlebih dahulu - match extract_email(headers) { - Some(email) => Some(email), - None => { - // Jika tidak ada, coba secara asynchronous - extract_email_async(headers).await - } - } -} - -/// Extract user ID dari email menggunakan auth repository -async fn extract_user_id(state: &AppState, email: &Option) -> Option { - if let Some(email) = email { - match state.auth_repository.get_user_for_auth(&email.clone(), state).await { - Ok(user) => Some(user.id.to_string()), - Err(_) => None, - } - } else { - None - } -} - -/// Extract user agent dari headers -fn extract_user_agent(headers: &axum::http::HeaderMap) -> Option { - headers.get("user-agent") - .and_then(|value| value.to_str().ok()) - .map(|s| s.to_string()) -} - -/// Extract tipe aksi dari URI dan method -fn extract_action(uri: &str, method: &str) -> String { - match method { - "POST" => "CREATE", - "PUT" | "PATCH" => "UPDATE", - "DELETE" => "DELETE", - "GET" => { - if uri.contains("/admin/") { - "VIEW" - } else { - "ACCESS" - } - }, - _ => "UNKNOWN", - }.to_string() -} - -/// Extract resource dari URI -fn extract_resource(uri: &str) -> String { - // Ambil bagian setelah /v1/ sebagai resource - if let Some(resource_part) = uri.split("/v1/").nth(1) - && let Some(resource) = resource_part.split('/').next() { - return resource.to_string(); - } - "unknown".to_string() -} - -/// Extract resource ID dari URI -fn extract_resource_id(uri: &str) -> Option { - // Cari bagian yang seperti UUID atau ID numerik - let segments = uri.split('/').collect::>(); - - for segment in segments.iter().rev() { - if segment.len() == 36 && segment.contains('-') { - // Kemungkinan UUID - return Some(segment.to_string()); - } else if segment.chars().all(|c| c.is_ascii_digit()) { - // Kemungkinan ID numerik - return Some(segment.to_string()); - } - } - - None -} - -/// Simpan audit log ke database menggunakan SeaORM -async fn save_audit_log( - db: &sea_orm::DatabaseConnection, - audit_log: AuditLogSchema, -) -> Result<(), Box> { - use imphnen_entities::seaorm::common::audit_log::ActiveModel as AuditLogActiveModel; - - let audit_log_model = AuditLogActiveModel { - id: Set(audit_log.id), - user_id: Set(audit_log.user_id), - user_email: Set(audit_log.user_email), - action: Set(audit_log.action.clone()), - resource: Set(audit_log.resource), - resource_id: Set(audit_log.resource_id), - old_data: Set(audit_log.old_data), - new_data: Set(audit_log.new_data), - ip_address: Set(audit_log.ip_address), - user_agent: Set(audit_log.user_agent), - timestamp: Set(audit_log.timestamp), - }; - - audit_log_model.insert(db).await?; - - log::debug!("Audit log saved for action: {}", audit_log.action); - Ok(()) -} - -/// Middleware khusus untuk aksi UPDATE/DELETE yang menangkap data sebelum dan sesudah -pub async fn detailed_audit_logging_middleware( - Extension(state): Extension, - req: Request, - next: Next, -) -> Result, Infallible> { - // Implementasi ini akan lebih kompleks dan membutuhkan intercept response - // Untuk sekarang, gunakan basic audit logging - audit_logging_middleware(Extension(state), req, next).await -} \ No newline at end of file +use axum::{ + Extension, + body::Body, + http::{Request, Response}, + middleware::Next, +}; +use chrono::{DateTime, FixedOffset, Utc}; +use imphnen_entities::seaorm::common::audit_log::Model as AuditLogSchema; +use imphnen_libs::AppState; +use imphnen_utils::{extract_email, extract_email_async, extract_real_ip}; +use sea_orm::prelude::Uuid; +use sea_orm::{ActiveModelTrait, Set}; +use std::convert::Infallible; + +pub async fn audit_logging_middleware( + Extension(state): Extension, + req: Request, + next: Next, +) -> Result, Infallible> { + let uri = req.uri().path().to_string(); + + if is_admin_action(&uri) { + let headers = req.headers(); + let user_email = extract_user_email(headers).await; + let user_id = extract_user_id(&state, &user_email).await; + let ip_address = + extract_real_ip(headers).unwrap_or_else(|| "unknown".to_string()); + let user_id_uuid = + Uuid::parse_str(&user_id.clone().unwrap_or_else(|| "unknown".to_string())) + .unwrap_or(Uuid::nil()); + let user_agent = extract_user_agent(headers); + + let action = extract_action(&uri, req.method().as_str()); + let resource = extract_resource(&uri); + let resource_id = extract_resource_id(&uri); + + let audit_log = AuditLogSchema { + id: Uuid::new_v4(), + user_id: user_id_uuid, + user_email: user_email.clone().unwrap_or_else(|| "unknown".to_string()), + action, + resource, + resource_id, + old_data: None, + new_data: None, + ip_address, + user_agent, + timestamp: DateTime::::from(Utc::now()), + }; + + let action = audit_log.action.clone(); + match save_audit_log(&state.postgres_connection.conn, audit_log.clone()).await { + Ok(_) => log::debug!("Audit log saved for action: {}", action), + Err(e) => log::error!("Failed to save audit log: {}", e), + } + } + + let response = next.run(req).await; + Ok(response) +} + +fn is_admin_action(uri: &str) -> bool { + let admin_endpoints = [ + "/v1/admin/", + "/v1/users/admin/", + "/v1/permissions/", + "/v1/roles/", + "/v1/gacha/admin/", + "/v1/cms/admin/", + ]; + + admin_endpoints + .iter() + .any(|endpoint| uri.starts_with(endpoint)) +} + +async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option { + match extract_email(headers) { + Some(email) => Some(email), + None => extract_email_async(headers).await, + } +} + +async fn extract_user_id( + state: &AppState, + email: &Option, +) -> Option { + if let Some(email) = email { + match state + .auth_repository + .get_user_for_auth(&email.clone(), state) + .await + { + Ok(user) => Some(user.id.to_string()), + Err(_) => None, + } + } else { + None + } +} + +fn extract_user_agent(headers: &axum::http::HeaderMap) -> Option { + headers + .get("user-agent") + .and_then(|value| value.to_str().ok()) + .map(|s| s.to_string()) +} + +fn extract_action(uri: &str, method: &str) -> String { + match method { + "POST" => "CREATE", + "PUT" | "PATCH" => "UPDATE", + "DELETE" => "DELETE", + "GET" => { + if uri.contains("/admin/") { + "VIEW" + } else { + "ACCESS" + } + } + _ => "UNKNOWN", + } + .to_string() +} + +fn extract_resource(uri: &str) -> String { + if let Some(resource_part) = uri.split("/v1/").nth(1) + && let Some(resource) = resource_part.split('/').next() + { + return resource.to_string(); + } + "unknown".to_string() +} + +fn extract_resource_id(uri: &str) -> Option { + let segments = uri.split('/').collect::>(); + + for segment in segments.iter().rev() { + if (segment.len() == 36 && segment.contains('-')) + || segment.chars().all(|c| c.is_ascii_digit()) + { + return Some(segment.to_string()); + } + } + + None +} + +async fn save_audit_log( + db: &sea_orm::DatabaseConnection, + audit_log: AuditLogSchema, +) -> Result<(), Box> { + use imphnen_entities::seaorm::common::audit_log::ActiveModel as AuditLogActiveModel; + + let audit_log_model = AuditLogActiveModel { + id: Set(audit_log.id), + user_id: Set(audit_log.user_id), + user_email: Set(audit_log.user_email), + action: Set(audit_log.action.clone()), + resource: Set(audit_log.resource), + resource_id: Set(audit_log.resource_id), + old_data: Set(audit_log.old_data), + new_data: Set(audit_log.new_data), + ip_address: Set(audit_log.ip_address), + user_agent: Set(audit_log.user_agent), + timestamp: Set(audit_log.timestamp), + }; + + audit_log_model.insert(db).await?; + + log::debug!("Audit log saved for action: {}", audit_log.action); + Ok(()) +} + +pub async fn detailed_audit_logging_middleware( + Extension(state): Extension, + req: Request, + next: Next, +) -> Result, Infallible> { + audit_logging_middleware(Extension(state), req, next).await +} diff --git a/imphnen-middleware/src/auth_middleware/mod.rs b/imphnen-middleware/src/auth_middleware/mod.rs index 3d92ad1..3a55597 100644 --- a/imphnen-middleware/src/auth_middleware/mod.rs +++ b/imphnen-middleware/src/auth_middleware/mod.rs @@ -1,66 +1,73 @@ -use axum::{ - Extension, extract::Request, http::StatusCode, middleware::Next, - response::{IntoResponse, Response}, -}; -use imphnen_libs::{AppState, jsonwebtoken::decode_access_token}; -use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt}; -use std::convert::Infallible; -use uuid::Uuid; -use imphnen_utils::response_format::ApiMessage; - -pub async fn auth_middleware( - Extension(state): Extension, - mut req: Request, - next: Next, -) -> Result { - let auth_header = match req - .headers() - .typed_get::>() { - Some(header) => header, - None => return Ok(ApiMessage::new( - StatusCode::UNAUTHORIZED, - "Invalid or missing authorization token", - ).into_response()), - }; - - let token = auth_header.token(); - - let claims = match decode_access_token(token) { - Ok(token_data) => token_data.claims, - Err(_) => return Ok(ApiMessage::new( - StatusCode::UNAUTHORIZED, - "Invalid or expired token", - ).into_response()), - }; - - let user_id = claims.user_id.clone(); - - // Validate UUID format - let user_uuid = match Uuid::parse_str(&user_id) { - Ok(uuid) => uuid, - Err(_) => return Ok(ApiMessage::new( - StatusCode::UNAUTHORIZED, - "Invalid user identifier format", - ).into_response()), - }; - - // Use UserLookupService to fetch full user details including roles/permissions - // This ensures consistency and populates the DTO expected by controllers - let user_info = match state.user_lookup_service.get_user_by_id(user_uuid, &state).await { - Ok(info) => info, - Err(_) => return Ok(ApiMessage::new(StatusCode::UNAUTHORIZED, "User not found or inactive").into_response()), - }; - - // Insert the Model (reconstructed or fetched? Wait, UserLookupService returns ExtendedUserInfo) - // We need to insert what the controllers expect. - // Some controllers might expect Model, others DTO. - // Let's fetch Model separately if needed, or better, insert DTO. - // The error said "Extension of type `imphnen_entities::users::UsersDetailQueryDto` was not found". - - req.extensions_mut().insert(user_info.basic_info); - // If controllers also need Model, we might need to insert it too. - // But usually they switch to DTO. - // Let's try inserting DTO first. - - Ok(next.run(req).await) -} +use axum::{ + Extension, + extract::Request, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer}; +use imphnen_libs::{AppState, jsonwebtoken::decode_access_token}; +use imphnen_utils::response_format::ApiMessage; +use std::convert::Infallible; +use uuid::Uuid; + +pub async fn auth_middleware( + Extension(state): Extension, + mut req: Request, + next: Next, +) -> Result { + let auth_header = match req.headers().typed_get::>() { + Some(header) => header, + None => { + return Ok( + ApiMessage::new( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + ) + .into_response(), + ); + } + }; + + let token = auth_header.token(); + + let claims = match decode_access_token(token) { + Ok(token_data) => token_data.claims, + Err(_) => { + return Ok( + ApiMessage::new(StatusCode::UNAUTHORIZED, "Invalid or expired token") + .into_response(), + ); + } + }; + + let user_id = claims.user_id.clone(); + + let user_uuid = match Uuid::parse_str(&user_id) { + Ok(uuid) => uuid, + Err(_) => { + return Ok( + ApiMessage::new(StatusCode::UNAUTHORIZED, "Invalid user identifier format") + .into_response(), + ); + } + }; + + let user_info = match state + .user_lookup_service + .get_user_by_id(user_uuid, &state) + .await + { + Ok(info) => info, + Err(_) => { + return Ok( + ApiMessage::new(StatusCode::UNAUTHORIZED, "User not found or inactive") + .into_response(), + ); + } + }; + + req.extensions_mut().insert(user_info.basic_info); + + Ok(next.run(req).await) +} diff --git a/imphnen-middleware/src/cors_middleware/mod.rs b/imphnen-middleware/src/cors_middleware/mod.rs index c3bcc74..32ab618 100644 --- a/imphnen-middleware/src/cors_middleware/mod.rs +++ b/imphnen-middleware/src/cors_middleware/mod.rs @@ -1,37 +1,23 @@ -use axum::http::{HeaderValue, Method, header}; -use imphnen_libs::environment::ENV; -use tower_http::cors::CorsLayer; - -pub fn cors_middleware() -> CorsLayer { - let env = &ENV; - let cors_origins = match env.rust_env.as_str() { - "development" => { - let mut origins = vec!["http://localhost:3000".to_string()]; - origins.push(format!("http://localhost:{}", env.port)); - origins - }, - "production" => { - vec![ - "https://gacha.imphnen.dev".to_string(), - "https://imphnen.dev".to_string(), - "https://dimentorin.imphnen.dev".to_string(), - ] - } - _ => vec![ - "http://localhost:3000".to_string(), - "https://gacha.imphnen.dev".to_string(), - "https://imphnen.dev".to_string(), - "https://dimentorin.imphnen.dev".to_string(), - ], - }; - let allowed_origins: Vec = cors_origins - .into_iter() - .filter_map(|origin| origin.parse::().ok()) - .collect(); - - CorsLayer::new() - .allow_origin(allowed_origins) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) - .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) - .allow_credentials(true) -} \ No newline at end of file +use axum::http::{HeaderValue, Method, header}; +use imphnen_libs::environment::ENV; +use tower_http::cors::CorsLayer; + +pub fn cors_middleware() -> CorsLayer { + let allowed_origins: Vec = ENV + .cors_allowed_origins + .iter() + .filter_map(|origin| origin.parse::().ok()) + .collect(); + + CorsLayer::new() + .allow_origin(allowed_origins) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .allow_credentials(true) +} diff --git a/imphnen-middleware/src/lib.rs b/imphnen-middleware/src/lib.rs index f7a7e57..56ed86d 100644 --- a/imphnen-middleware/src/lib.rs +++ b/imphnen-middleware/src/lib.rs @@ -1,16 +1,15 @@ -pub mod audit_logging_middleware; -pub mod auth_middleware; -pub mod cors_middleware; -pub mod payment_middleware; -pub mod permissions_middleware; -pub mod rate_limiting_middleware; -pub mod security_headers_middleware; - -// Re-export all middleware for easy access -pub use audit_logging_middleware::audit_logging_middleware; -pub use auth_middleware::auth_middleware; -pub use cors_middleware::cors_middleware; -pub use payment_middleware::PaymentLayer; -pub use permissions_middleware::{PermissionsMiddlewareLayer, check_permissions}; -pub use rate_limiting_middleware::rate_limiting_middleware; -pub use security_headers_middleware::security_headers_middleware; +pub mod audit_logging_middleware; +pub mod auth_middleware; +pub mod cors_middleware; +pub mod payment_middleware; +pub mod permissions_middleware; +pub mod rate_limiting_middleware; +pub mod security_headers_middleware; + +pub use audit_logging_middleware::audit_logging_middleware; +pub use auth_middleware::auth_middleware; +pub use cors_middleware::cors_middleware; +pub use payment_middleware::PaymentLayer; +pub use permissions_middleware::{PermissionsMiddlewareLayer, check_permissions}; +pub use rate_limiting_middleware::rate_limiting_middleware; +pub use security_headers_middleware::security_headers_middleware; diff --git a/imphnen-middleware/src/payment_middleware/mod.rs b/imphnen-middleware/src/payment_middleware/mod.rs index 3987bef..35d8c07 100644 --- a/imphnen-middleware/src/payment_middleware/mod.rs +++ b/imphnen-middleware/src/payment_middleware/mod.rs @@ -1,101 +1,95 @@ -use axum::{ - body::Body, - http::{Request, Response, StatusCode}, -}; -use futures::future::BoxFuture; -use imphnen_libs::AppState; -use std::task::{Context, Poll}; -use tower::{Layer, Service}; - -/// Placeholder middleware layer for payment processing. -/// Currently a pass-through implementation. -#[derive(Clone)] -pub struct PaymentLayer { - app_state: AppState, -} - -impl PaymentLayer { - /// Create a new payment middleware layer - pub fn new(app_state: AppState) -> Self { - Self { app_state } - } -} - -impl Layer for PaymentLayer { - type Service = PaymentMiddleware; - fn layer(&self, inner: S) -> Self::Service { - PaymentMiddleware { - inner, - app_state: self.app_state.clone(), - } - } -} - -#[derive(Clone)] -pub struct PaymentMiddleware { - inner: S, - app_state: AppState, -} - -impl Service> for PaymentMiddleware -where - S: Service, Response = Response, Error = Response> + Clone + Send + 'static, - S::Future: Send + 'static, -{ - type Response = S::Response; - type Error = S::Error; - type Future = BoxFuture<'static, Result>; - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - fn call(&mut self, req: Request) -> Self::Future { - let mut inner = self.inner.clone(); - let _app_state = self.app_state.clone(); - Box::pin(async move { - // Payment validation logic - // Check for payment-related headers or query parameters - let headers = req.headers(); - - // Validate payment token if present - if let Some(payment_token) = headers.get("X-Payment-Token") - && let Ok(token_str) = payment_token.to_str() { - // Basic validation: check token format - if !is_valid_payment_token(token_str) { - let error_response = Response::builder() - .status(StatusCode::PAYMENT_REQUIRED) - .body(Body::from("Invalid payment token")) - .unwrap(); - return Err(error_response); - } - } - - // Check if endpoint requires payment verification - let uri_path = req.uri().path(); - if requires_payment_verification(uri_path) - && !headers.contains_key("X-Payment-Token") { - let error_response = Response::builder() - .status(StatusCode::PAYMENT_REQUIRED) - .body(Body::from("Payment required for this endpoint")) - .unwrap(); - return Err(error_response); - } - - // Pass through if payment validation succeeds or not required - inner.call(req).await - }) - } -} - -/// Validate payment token format -fn is_valid_payment_token(token: &str) -> bool { - // Basic validation: token should be alphanumeric and at least 16 chars - token.len() >= 16 && token.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') -} - -/// Check if URI path requires payment verification -fn requires_payment_verification(path: &str) -> bool { - // Premium endpoints that require payment - path.contains("/premium/") || - path.contains("/paid/") || - path.contains("/subscription/") -} \ No newline at end of file +use axum::{ + body::Body, + http::{Request, Response, StatusCode}, +}; +use futures::future::BoxFuture; +use imphnen_libs::AppState; +use std::task::{Context, Poll}; +use tower::{Layer, Service}; + +#[derive(Clone)] +pub struct PaymentLayer { + app_state: AppState, +} + +impl PaymentLayer { + pub fn new(app_state: AppState) -> Self { + Self { app_state } + } +} + +impl Layer for PaymentLayer { + type Service = PaymentMiddleware; + fn layer(&self, inner: S) -> Self::Service { + PaymentMiddleware { + inner, + app_state: self.app_state.clone(), + } + } +} + +#[derive(Clone)] +pub struct PaymentMiddleware { + inner: S, + app_state: AppState, +} + +impl Service> for PaymentMiddleware +where + S: Service, Response = Response, Error = Response> + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + fn call(&mut self, req: Request) -> Self::Future { + let mut inner = self.inner.clone(); + let _app_state = self.app_state.clone(); + Box::pin(async move { + let headers = req.headers(); + + if let Some(payment_token) = headers.get("X-Payment-Token") + && let Ok(token_str) = payment_token.to_str() + && !is_valid_payment_token(token_str) + { + let error_response = Response::builder() + .status(StatusCode::PAYMENT_REQUIRED) + .body(Body::from("Invalid payment token")) + .expect("valid payment error response"); + return Err(error_response); + } + + let uri_path = req.uri().path(); + if requires_payment_verification(uri_path) + && !headers.contains_key("X-Payment-Token") + { + let error_response = Response::builder() + .status(StatusCode::PAYMENT_REQUIRED) + .body(Body::from("Payment required for this endpoint")) + .expect("valid payment required response"); + return Err(error_response); + } + + inner.call(req).await + }) + } +} + +fn is_valid_payment_token(token: &str) -> bool { + token.len() >= 16 + && token + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') +} + +fn requires_payment_verification(path: &str) -> bool { + path.contains("/premium/") + || path.contains("/paid/") + || path.contains("/subscription/") +} diff --git a/imphnen-middleware/src/permissions_middleware/mod.rs b/imphnen-middleware/src/permissions_middleware/mod.rs index 83ed25c..2e47c89 100644 --- a/imphnen-middleware/src/permissions_middleware/mod.rs +++ b/imphnen-middleware/src/permissions_middleware/mod.rs @@ -1,202 +1,199 @@ -use axum::{ - body::Body, - http::{Request, Response, StatusCode}, -}; -use futures::future::BoxFuture; -use imphnen_entities::PermissionsEnum; -use imphnen_libs::{AppState, services::ExtendedUserInfo}; -use imphnen_utils::response_format::ApiMessage; -use axum::response::IntoResponse; -use imphnen_utils::{extract_email, extract_email_async}; -use std::task::{Context, Poll}; -use tower::{Layer, Service}; - -/// Unified middleware layer for enforcing user permissions on requests. -/// This replaces the legacy permissions_guard function calls with a consistent middleware approach. -#[derive(Clone)] -pub struct PermissionsMiddlewareLayer { - app_state: AppState, - permissions: Vec, -} - -impl PermissionsMiddlewareLayer { - /// Create a new permissions middleware layer with the required permissions - pub fn new(app_state: AppState, permissions: Vec) -> Self { - Self { - app_state, - permissions, - } - } - - /// Create a middleware layer that requires administrator permissions - pub fn admin_only(app_state: AppState) -> Self { - Self::new(app_state, vec![PermissionsEnum::Administrator]) - } - - /// Create a middleware layer that requires specific permission - pub fn with_permission(app_state: AppState, permission: PermissionsEnum) -> Self { - Self::new(app_state, vec![permission]) - } -} - -impl Layer for PermissionsMiddlewareLayer { - type Service = PermissionsMiddleware; - fn layer(&self, inner: S) -> Self::Service { - PermissionsMiddleware { - inner, - app_state: self.app_state.clone(), - permissions: self.permissions.clone(), - } - } -} - -#[derive(Clone)] -pub struct PermissionsMiddleware { - inner: S, - app_state: AppState, - permissions: Vec, -} - -impl Service> for PermissionsMiddleware -where - S: Service, Response = Response, Error = Response> + Clone + Send + 'static, - S::Future: Send + 'static, -{ - type Response = S::Response; - type Error = S::Error; - type Future = BoxFuture<'static, Result>; - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - fn call(&mut self, req: Request) -> Self::Future { - let mut inner = self.inner.clone(); - let app_state = self.app_state.clone(); - let permissions = self.permissions.clone(); - Box::pin(async move { - let headers = req.headers(); - - // Extract user email from authorization headers - let email = extract_user_email(headers).await - .ok_or_else(|| { - ApiMessage::new( - StatusCode::UNAUTHORIZED, - "Invalid or missing authorization token", - ).into_response() - })?; - - // Get user data with permissions from user lookup service - let user = app_state.user_lookup_service.get_user_by_email(&email, &app_state).await - .map_err(|_| { - ApiMessage::new( - StatusCode::UNAUTHORIZED, - "User session expired or not found", - ).into_response() - })?; - - // Extract user permissions from role - let user_permissions = extract_user_permissions(&user); - - println!("DEBUG: User Permissions: {:?}", user_permissions); - println!("DEBUG: Required Permissions: {:?}", permissions); - - // Check if user has required permissions - if !has_required_permissions(&user_permissions, &permissions) { - return Err(ApiMessage::new( - StatusCode::FORBIDDEN, - "You don't have the required permissions", - ).into_response()); - } - - inner.call(req).await - }) - } -} - -/// Extract user email from headers (sync and async fallback) -async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option { - // Try synchronous extraction first - match extract_email(headers) { - Some(email) => Some(email), - None => { - // Fallback to async extraction for Google tokens - extract_email_async(headers).await - } - } -} - -/// Extract user permissions from user data -fn extract_user_permissions(user: &ExtendedUserInfo) -> Vec { - user.basic_info.role - .permissions - .as_ref() - .unwrap_or(&vec![]) - .iter() - .filter_map(|p| p.as_ref()) - .flat_map(|pp| { - let mut permissions = Vec::new(); - // Add permission name if available - if let Some(name) = pp.name.clone() { - permissions.push(name); - } - // Add permission ID if available - if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) { - permissions.push(id); - } - permissions - }) - .collect() -} - -/// Check if user has required permissions -fn has_required_permissions(user_permissions: &[String], required_permissions: &[PermissionsEnum]) -> bool { - // Administrator has access to everything - let admin_name = PermissionsEnum::Administrator.to_string(); - let admin_id = PermissionsEnum::Administrator.id(); - - if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) { - return true; - } - - // Check if user has all required permissions - required_permissions.iter().all(|required| { - let required_name = required.to_string(); - let required_id = required.id(); - - user_permissions.contains(&required_name) || user_permissions.contains(&required_id) - }) -} - -/// Simple permission check function for use in controllers (legacy compatibility) -/// This provides a bridge between old permissions_guard calls and new middleware approach -pub async fn check_permissions( - headers: &axum::http::HeaderMap, - app_state: &AppState, - required_permissions: Vec, -) -> Result<(), Response> { - let email = extract_user_email(headers).await - .ok_or_else(|| { - ApiMessage::new( - StatusCode::UNAUTHORIZED, - "Invalid or missing authorization token", - ).into_response() - })?; - - let user = app_state.user_lookup_service.get_user_by_email(&email, app_state).await - .map_err(|_| { - ApiMessage::new( - StatusCode::UNAUTHORIZED, - "User session expired or not found", - ).into_response() - })?; - - let user_permissions = extract_user_permissions(&user); - - if !has_required_permissions(&user_permissions, &required_permissions) { - return Err(ApiMessage::new( - StatusCode::FORBIDDEN, - "You don't have the required permissions", - ).into_response()); - } - - Ok(()) -} +use axum::response::IntoResponse; +use axum::{ + body::Body, + http::{Request, Response, StatusCode}, +}; +use futures::future::BoxFuture; +use imphnen_entities::PermissionsEnum; +use imphnen_libs::{AppState, services::ExtendedUserInfo}; +use imphnen_utils::response_format::ApiMessage; +use imphnen_utils::{extract_email, extract_email_async}; +use std::task::{Context, Poll}; +use tower::{Layer, Service}; + +#[derive(Clone)] +pub struct PermissionsMiddlewareLayer { + app_state: AppState, + permissions: Vec, +} + +impl PermissionsMiddlewareLayer { + pub fn new(app_state: AppState, permissions: Vec) -> Self { + Self { + app_state, + permissions, + } + } + + pub fn admin_only(app_state: AppState) -> Self { + Self::new(app_state, vec![PermissionsEnum::Administrator]) + } + + pub fn with_permission(app_state: AppState, permission: PermissionsEnum) -> Self { + Self::new(app_state, vec![permission]) + } +} + +impl Layer for PermissionsMiddlewareLayer { + type Service = PermissionsMiddleware; + fn layer(&self, inner: S) -> Self::Service { + PermissionsMiddleware { + inner, + app_state: self.app_state.clone(), + permissions: self.permissions.clone(), + } + } +} + +#[derive(Clone)] +pub struct PermissionsMiddleware { + inner: S, + app_state: AppState, + permissions: Vec, +} + +impl Service> for PermissionsMiddleware +where + S: Service, Response = Response, Error = Response> + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + fn call(&mut self, req: Request) -> Self::Future { + let mut inner = self.inner.clone(); + let app_state = self.app_state.clone(); + let permissions = self.permissions.clone(); + Box::pin(async move { + let headers = req.headers(); + + let email = extract_user_email(headers).await.ok_or_else(|| { + ApiMessage::new( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + ) + .into_response() + })?; + + let user = app_state + .user_lookup_service + .get_user_by_email(&email, &app_state) + .await + .map_err(|_| { + ApiMessage::new( + StatusCode::UNAUTHORIZED, + "User session expired or not found", + ) + .into_response() + })?; + + let user_permissions = extract_user_permissions(&user); + + if !has_required_permissions(&user_permissions, &permissions) { + return Err( + ApiMessage::new( + StatusCode::FORBIDDEN, + "You don't have the required permissions", + ) + .into_response(), + ); + } + + inner.call(req).await + }) + } +} + +async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option { + match extract_email(headers) { + Some(email) => Some(email), + None => extract_email_async(headers).await, + } +} + +fn extract_user_permissions(user: &ExtendedUserInfo) -> Vec { + user + .basic_info + .role + .permissions + .as_ref() + .unwrap_or(&vec![]) + .iter() + .filter_map(|p| p.as_ref()) + .flat_map(|pp| { + let mut permissions = Vec::new(); + if let Some(name) = pp.name.clone() { + permissions.push(name); + } + if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) { + permissions.push(id); + } + permissions + }) + .collect() +} + +fn has_required_permissions( + user_permissions: &[String], + required_permissions: &[PermissionsEnum], +) -> bool { + let admin_name = PermissionsEnum::Administrator.to_string(); + let admin_id = PermissionsEnum::Administrator.id(); + + if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) { + return true; + } + + required_permissions.iter().all(|required| { + let required_name = required.to_string(); + let required_id = required.id(); + user_permissions.contains(&required_name) + || user_permissions.contains(&required_id) + }) +} + +pub async fn check_permissions( + headers: &axum::http::HeaderMap, + app_state: &AppState, + required_permissions: Vec, +) -> Result<(), Response> { + let email = extract_user_email(headers).await.ok_or_else(|| { + ApiMessage::new( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + ) + .into_response() + })?; + + let user = app_state + .user_lookup_service + .get_user_by_email(&email, app_state) + .await + .map_err(|_| { + ApiMessage::new( + StatusCode::UNAUTHORIZED, + "User session expired or not found", + ) + .into_response() + })?; + + let user_permissions = extract_user_permissions(&user); + + if !has_required_permissions(&user_permissions, &required_permissions) { + return Err( + ApiMessage::new( + StatusCode::FORBIDDEN, + "You don't have the required permissions", + ) + .into_response(), + ); + } + + Ok(()) +} diff --git a/imphnen-middleware/src/rate_limiting_middleware/mod.rs b/imphnen-middleware/src/rate_limiting_middleware/mod.rs index 0a89cdf..290261f 100644 --- a/imphnen-middleware/src/rate_limiting_middleware/mod.rs +++ b/imphnen-middleware/src/rate_limiting_middleware/mod.rs @@ -1,182 +1,178 @@ -use axum::{ - body::Body, - http::{Request, Response, StatusCode}, - middleware::Next, - Extension, -}; -use chrono::{DateTime, FixedOffset, Utc, Duration}; -use imphnen_libs::{AppState}; -use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, Set, ActiveModelTrait}; -use uuid::Uuid; -use imphnen_utils::extract_real_ip; -use imphnen_entities::seaorm::common::rate_limit::Entity as RateLimitEntity; -use imphnen_entities::seaorm::common::rate_limit::ActiveModel as RateLimitActiveModel; -use imphnen_entities::seaorm::common::rate_limit::Column as RateLimitColumn; - -/// Rate limiting middleware yang menggunakan PostgreSQL (SeaORM) untuk semua public endpoints -/// -/// Migration dari SurrealDB ke PostgreSQL selesai - kini menggunakan sistem rate limiting -/// yang lebih scalable dan terintegrasi dengan backend utama -pub async fn rate_limiting_middleware( - Extension(state): Extension, - req: Request, - next: Next, -) -> Result, StatusCode> { - let uri = req.uri().path().to_string(); - - // Terapkan rate limiting pada semua public endpoints - if is_public_endpoint(&uri) { - // Extract real client IP dari headers - let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| { - log::warn!("Could not extract real IP, using fallback"); - "unknown".to_string() - }); - - // Konfigurasi rate limiting - let max_requests = 100; // 100 requests per minute - let window_duration_secs = 60; // 1 minute window - - // Periksa rate limit menggunakan PostgreSQL (SeaORM) - match check_rate_limit(&state.postgres_connection.conn, &client_ip, max_requests, window_duration_secs).await { - Ok(is_limited) => { - if is_limited { - return Ok(Response::builder() - .status(StatusCode::TOO_MANY_REQUESTS) - .header("Retry-After", "60") - .body("Too Many Requests: Rate limit exceeded".into()) - .unwrap()); - } - } - Err(e) => { - log::error!("Rate limit check failed: {}", e); - // Jika terjadi error, izinkan request untuk menjaga availability - } - } - } - - Ok(next.run(req).await) -} - -/// Middleware rate limiting khusus untuk endpoint autentikasi -/// -/// Menggunakan PostgreSQL (SeaORM) sebagai backend - kompatibilitas legacy dengan SurrealDB -/// telah dihapus selain fungsionalitas yang sama -pub async fn auth_rate_limiting_middleware( - Extension(state): Extension, - req: Request, - next: Next, -) -> Result, StatusCode> { - let uri = req.uri().path().to_string(); - - // Hanya terapkan pada endpoint auth - if uri == "/v1/auth/login" || uri == "/v1/auth/register" { - // Extract real client IP dari headers - let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| { - log::warn!("Could not extract real IP, using fallback"); - "unknown".to_string() - }); - - // Konfigurasi rate limiting yang lebih ketat untuk auth - let max_requests = 10; // 10 requests per minute - let window_duration_secs = 60; // 1 minute window - - // Periksa rate limit menggunakan PostgreSQL (SeaORM) - match check_rate_limit(&state.postgres_connection.conn, &client_ip, max_requests, window_duration_secs).await { - Ok(is_limited) => { - if is_limited { - return Ok(Response::builder() - .status(StatusCode::TOO_MANY_REQUESTS) - .header("Retry-After", "60") - .body("Too Many Requests: Rate limit exceeded for authentication endpoint".into()) - .unwrap()); - } - } - Err(e) => { - log::error!("Auth rate limit check failed: {}", e); - // Jika terjadi error, izinkan request untuk menjaga availability - } - } - } - - Ok(next.run(req).await) -} - -/// Periksa apakah endpoint termasuk public endpoint -fn is_public_endpoint(uri: &str) -> bool { - // Daftar endpoint yang memerlukan rate limiting - let public_endpoints = [ - "/v1/auth/login", - "/v1/auth/register", - "/v1/auth/refresh", - "/v1/auth/logout", - "/v1/gacha/roll", - "/v1/gacha/credits", - "/v1/cms/landing", - ]; - - public_endpoints.iter().any(|endpoint| uri.starts_with(endpoint)) -} - -/// Periksa rate limit untuk IP tertentu menggunakan PostgreSQL (SeaORM) -/// -/// Implementasi rate limiting yang didesain untuk skala besar dengan PostgreSQL, -/// menggantikan implementasi SurrealDB yang sebelumnya -async fn check_rate_limit( - db: &sea_orm::DatabaseConnection, - ip_address: &str, - max_requests: u32, - window_duration_secs: u64, -) -> Result> { - let now = Utc::now(); - let window_start = now - Duration::seconds(window_duration_secs as i64); - - // Cari record rate limit untuk IP ini - let existing_record = RateLimitEntity::find() - .filter(RateLimitColumn::IpAddress.eq(ip_address)) - .one(db) - .await?; - - match existing_record { - Some(record) => { - // Konversi ke ActiveModel untuk modifikasi - let mut active_model: RateLimitActiveModel = record.into(); - - // Reset counter jika window sudah expired - let was_reset = if active_model.last_request_time.clone().unwrap() <= window_start { - active_model.request_count = Set(0); - active_model.last_request_time = Set(DateTime::::from(now)); - true - } else { - false - }; - - if !was_reset { - // Increment counter jika masih dalam window - let current_count = active_model.request_count.clone().unwrap(); - active_model.request_count = Set(current_count + 1); - } - - // Simpan perubahan ke database - let updated_model = active_model.update(db).await?; - - // Periksa apakah rate limit terlampaui - Ok(updated_model.request_count > max_requests) - } - None => { - // Buat record baru dengan nilai awal - let new_record = RateLimitActiveModel { - id: Set(Uuid::new_v4().to_string()), - ip_address: Set(ip_address.to_string()), - request_count: Set(1), - first_request_time: Set(DateTime::::from(now)), - last_request_time: Set(DateTime::::from(now)), - window_duration_secs: Set(window_duration_secs as i64), - }; - - // Simpan record baru ke database - new_record.insert(db).await?; - - Ok(false) // Request pertama selalu diizinkan - } - } -} \ No newline at end of file +use axum::{ + Extension, + body::Body, + http::{Request, Response, StatusCode}, + middleware::Next, +}; +use chrono::{DateTime, Duration, FixedOffset, Utc}; +use imphnen_entities::seaorm::common::rate_limit::ActiveModel as RateLimitActiveModel; +use imphnen_entities::seaorm::common::rate_limit::Column as RateLimitColumn; +use imphnen_entities::seaorm::common::rate_limit::Entity as RateLimitEntity; +use imphnen_libs::AppState; +use imphnen_utils::extract_real_ip; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +pub async fn rate_limiting_middleware( + Extension(state): Extension, + req: Request, + next: Next, +) -> Result, StatusCode> { + let uri = req.uri().path().to_string(); + + if is_public_endpoint(&uri) { + let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| { + log::warn!("Could not extract real IP, using fallback"); + "unknown".to_string() + }); + + let max_requests = 100; + let window_duration_secs = 60; + + match check_rate_limit( + &state.postgres_connection.conn, + &client_ip, + max_requests, + window_duration_secs, + ) + .await + { + Ok(is_limited) => { + if is_limited { + return Ok( + Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("Retry-After", "60") + .body("Too Many Requests: Rate limit exceeded".into()) + .expect("valid rate limit response"), + ); + } + } + Err(e) => { + log::error!("Rate limit check failed: {}", e); + } + } + } + + Ok(next.run(req).await) +} + +pub async fn auth_rate_limiting_middleware( + Extension(state): Extension, + req: Request, + next: Next, +) -> Result, StatusCode> { + let uri = req.uri().path().to_string(); + + if uri == "/v1/auth/login" || uri == "/v1/auth/register" { + let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| { + log::warn!("Could not extract real IP, using fallback"); + "unknown".to_string() + }); + + let max_requests = 10; + let window_duration_secs = 60; + + match check_rate_limit( + &state.postgres_connection.conn, + &client_ip, + max_requests, + window_duration_secs, + ) + .await + { + Ok(is_limited) => { + if is_limited { + return Ok( + Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("Retry-After", "60") + .body( + "Too Many Requests: Rate limit exceeded for authentication endpoint" + .into(), + ) + .expect("valid auth rate limit response"), + ); + } + } + Err(e) => { + log::error!("Auth rate limit check failed: {}", e); + } + } + } + + Ok(next.run(req).await) +} + +fn is_public_endpoint(uri: &str) -> bool { + let public_endpoints = [ + "/v1/auth/login", + "/v1/auth/register", + "/v1/auth/refresh", + "/v1/auth/logout", + "/v1/gacha/roll", + "/v1/gacha/credits", + "/v1/cms/landing", + ]; + + public_endpoints + .iter() + .any(|endpoint| uri.starts_with(endpoint)) +} + +async fn check_rate_limit( + db: &sea_orm::DatabaseConnection, + ip_address: &str, + max_requests: u32, + window_duration_secs: u64, +) -> Result> { + let now = Utc::now(); + let window_start = now - Duration::seconds(window_duration_secs as i64); + + let existing_record = RateLimitEntity::find() + .filter(RateLimitColumn::IpAddress.eq(ip_address)) + .one(db) + .await?; + + match existing_record { + Some(record) => { + let mut active_model: RateLimitActiveModel = record.into(); + + let last_request = active_model + .last_request_time + .clone() + .take() + .unwrap_or(DateTime::::from(window_start)); + let was_reset = if last_request <= window_start { + active_model.request_count = Set(0); + active_model.last_request_time = Set(DateTime::::from(now)); + true + } else { + false + }; + + if !was_reset { + let current_count = active_model.request_count.clone().take().unwrap_or(0); + active_model.request_count = Set(current_count + 1); + } + + let updated_model = active_model.update(db).await?; + + Ok(updated_model.request_count > max_requests) + } + None => { + let new_record = RateLimitActiveModel { + id: Set(Uuid::new_v4().to_string()), + ip_address: Set(ip_address.to_string()), + request_count: Set(1), + first_request_time: Set(DateTime::::from(now)), + last_request_time: Set(DateTime::::from(now)), + window_duration_secs: Set(window_duration_secs as i64), + }; + + new_record.insert(db).await?; + + Ok(false) + } + } +} diff --git a/imphnen-middleware/src/security_headers_middleware/mod.rs b/imphnen-middleware/src/security_headers_middleware/mod.rs index b4ec5dc..5a289eb 100644 --- a/imphnen-middleware/src/security_headers_middleware/mod.rs +++ b/imphnen-middleware/src/security_headers_middleware/mod.rs @@ -1,127 +1,101 @@ -use axum::{ - http::{HeaderValue, Request, Response}, - middleware::Next, - Extension, -}; -use imphnen_libs::{AppState, ENV}; -use rand::RngCore; -use std::convert::Infallible; - -/// Security headers middleware that adds various security-related HTTP headers to all responses. -/// -/// This middleware implements security best practices by adding headers that help protect -/// against common web attacks like clickjacking, XSS, and information leakage. -pub async fn security_headers_middleware( - Extension(_state): Extension, - req: Request, - next: Next, -) -> Result, Infallible> { - // Generate nonce for CSP if in development mode - let nonce = if ENV.rust_env != "production" { - generate_nonce() - } else { - String::new() - }; - - let res = next.run(req).await; - - let res = add_security_headers(res, &nonce); - - Ok(res) -} - -/// Adds security headers to a response based on the current environment. -/// -/// # Arguments -/// * `res` - The response to add headers to -/// * `nonce` - Nonce value for CSP (empty in production) -/// -/// # Returns -/// The response with security headers added -fn add_security_headers(mut res: Response, nonce: &str) -> Response { - let headers = res.headers_mut(); - - // Strict-Transport-Security (HSTS) - // Prevents downgrade attacks and cookie hijacking - // Only enable in production to avoid HSTS pinning issues during development - if ENV.rust_env == "production" { - headers.insert( - "Strict-Transport-Security", - HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"), - ); - } else { - headers.insert( - "Strict-Transport-Security", - HeaderValue::from_static("max-age=0"), - ); - } - - // Content-Security-Policy (CSP) - // Mitigates XSS and data injection attacks - let csp = if ENV.rust_env == "production" { - // Production CSP - strict policy for production - "default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-violation-report-endpoint".to_string() - } else { - // Development CSP - secure nonce-based approach - if nonce.is_empty() { - // Fallback if nonce generation fails - "default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000; style-src 'self' http://localhost:3000; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'".to_string() - } else { - // Nonce-based CSP for development - format!("default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000 'nonce-{}'; style-src 'self' http://localhost:3000 'nonce-{}'; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'", nonce, nonce) - } - }; - - headers.insert("Content-Security-Policy", HeaderValue::from_str(&csp).unwrap()); - - // Add nonce to response headers for frontend use (development only) - if ENV.rust_env != "production" && !nonce.is_empty() { - headers.insert("X-CSP-Nonce", HeaderValue::from_str(nonce).unwrap()); - } - - // X-Frame-Options - // Prevents clickjacking attacks - headers.insert( - "X-Frame-Options", - HeaderValue::from_static("DENY"), - ); - - // X-Content-Type-Options - // Prevents MIME sniffing attacks - headers.insert( - "X-Content-Type-Options", - HeaderValue::from_static("nosniff"), - ); - - // Referrer-Policy - // Controls how much referrer information should be included with requests - headers.insert( - "Referrer-Policy", - HeaderValue::from_static("strict-origin-when-cross-origin"), - ); - - // Permissions-Policy (Feature Policy) - // Controls which features and APIs can be used - headers.insert( - "Permissions-Policy", - HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), - ); - - // X-XSS-Protection - // Provides basic XSS protection (note: this is a legacy header and CSP is preferred) - headers.insert( - "X-XSS-Protection", - HeaderValue::from_static("1; mode=block"), - ); - - res -} - -/// Generate a random nonce for CSP -fn generate_nonce() -> String { - use base64::{Engine as _, engine::general_purpose::STANDARD}; - let mut rng = rand::rng(); - let mut random_bytes = [0u8; 16]; - rng.fill_bytes(&mut random_bytes); - STANDARD.encode(random_bytes) -} \ No newline at end of file +use axum::{ + Extension, + http::{HeaderValue, Request, Response}, + middleware::Next, +}; +use imphnen_libs::{AppState, ENV}; +use rand::RngCore; +use std::convert::Infallible; + +pub async fn security_headers_middleware( + Extension(_state): Extension, + req: Request, + next: Next, +) -> Result, Infallible> { + let nonce = if ENV.rust_env != "production" { + generate_nonce() + } else { + String::new() + }; + + let res = next.run(req).await; + + let res = add_security_headers(res, &nonce); + + Ok(res) +} + +fn add_security_headers( + mut res: Response, + nonce: &str, +) -> Response { + let headers = res.headers_mut(); + + if ENV.rust_env == "production" { + headers.insert( + "Strict-Transport-Security", + HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"), + ); + } else { + headers.insert( + "Strict-Transport-Security", + HeaderValue::from_static("max-age=0"), + ); + } + + let csp = if ENV.rust_env == "production" { + "default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-violation-report-endpoint".to_string() + } else { + if nonce.is_empty() { + "default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000; style-src 'self' http://localhost:3000; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'".to_string() + } else { + format!( + "default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000 'nonce-{}'; style-src 'self' http://localhost:3000 'nonce-{}'; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'", + nonce, nonce + ) + } + }; + + if let Ok(csp_value) = HeaderValue::from_str(&csp) { + headers.insert("Content-Security-Policy", csp_value); + } + + if ENV.rust_env != "production" + && !nonce.is_empty() + && let Ok(nonce_value) = HeaderValue::from_str(nonce) + { + headers.insert("X-CSP-Nonce", nonce_value); + } + + headers.insert("X-Frame-Options", HeaderValue::from_static("DENY")); + + headers.insert( + "X-Content-Type-Options", + HeaderValue::from_static("nosniff"), + ); + + headers.insert( + "Referrer-Policy", + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + + headers.insert( + "Permissions-Policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + + headers.insert( + "X-XSS-Protection", + HeaderValue::from_static("1; mode=block"), + ); + + res +} + +fn generate_nonce() -> String { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let mut rng = rand::rng(); + let mut random_bytes = [0u8; 16]; + rng.fill_bytes(&mut random_bytes); + STANDARD.encode(random_bytes) +} diff --git a/imphnen-qr/Cargo.toml b/imphnen-qr/Cargo.toml deleted file mode 100644 index eb66774..0000000 --- a/imphnen-qr/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "imphnen-qr" -version = "0.2.0" -edition = "2024" - -[dependencies] -imphnen-utils.workspace = true -imphnen-libs.workspace = true -axum.workspace = true -async-trait.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -chrono.workspace = true -uuid.workspace = true -sqlx.workspace = true -tracing.workspace = true -utoipa.workspace = true -image.workspace = true -qrcode.workspace = true diff --git a/imphnen-qr/src/campaigns/application/campaign_service.rs b/imphnen-qr/src/campaigns/application/campaign_service.rs deleted file mode 100644 index 942fd3b..0000000 --- a/imphnen-qr/src/campaigns/application/campaign_service.rs +++ /dev/null @@ -1,88 +0,0 @@ -use async_trait::async_trait; -use image::{DynamicImage, GenericImageView, ImageFormat, imageops}; -use imphnen_utils::errors::AppError; -use qrcode::QrCode; -use std::io::Cursor; -use std::sync::Arc; -use uuid::Uuid; - -use crate::campaigns::domain::{ - entity::{CampaignEntity, CreateCampaignInput}, - repository::CampaignRepository, - service::QrCampaignService, -}; - -pub struct QrCampaignServiceImpl { - repo: Arc, -} - -impl QrCampaignServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } -} - -#[async_trait] -impl QrCampaignService for QrCampaignServiceImpl { - async fn create(&self, name: String, url: String, created_by: Uuid) -> Result { - let qr = QrCode::new(url.as_bytes()) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - let qr_img = qr.render::>().min_dimensions(256, 256).build(); - let mut qr_bytes = Vec::new(); - DynamicImage::ImageLuma8(qr_img) - .write_to(&mut Cursor::new(&mut qr_bytes), ImageFormat::Png) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let input = CreateCampaignInput { - name, - url, - created_by, - qr_code_data: qr_bytes, - }; - self.repo.create(input).await - } - - async fn list_all(&self) -> Result, AppError> { - self.repo.find_all().await - } - - async fn get_active_qr_data(&self) -> Result>, AppError> { - self.repo.find_active_qr_data().await - } - - async fn set_active(&self, id: Uuid) -> Result { - self.repo.set_active(id).await - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.delete(id).await - } - - async fn process_image(&self, image_bytes: Vec) -> Result, AppError> { - let qr_data = self.repo.find_active_qr_data().await? - .ok_or_else(|| AppError::NotFoundError("No active campaign".to_string()))?; - - let img = image::load_from_memory(&image_bytes) - .map_err(|_| AppError::BadRequestError("Invalid image format".to_string()))?; - - let qr_img = image::load_from_memory(&qr_data) - .map_err(|_| AppError::InternalServerError("Failed to load QR data".to_string()))?; - - let (w, h) = img.dimensions(); - let qr_size = (std::cmp::min(w, h) / 5).max(100); - - let qr_resized = qr_img.resize_exact(qr_size, qr_size, imageops::FilterType::Nearest); - - let mut output = img.to_rgba8(); - let x = (w - qr_size - 10) as i64; - let y = (h - qr_size - 10) as i64; - imageops::overlay(&mut output, &qr_resized.to_rgba8(), x, y); - - let mut out_bytes = Vec::new(); - DynamicImage::ImageRgba8(output) - .write_to(&mut Cursor::new(&mut out_bytes), ImageFormat::Png) - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(out_bytes) - } -} diff --git a/imphnen-qr/src/campaigns/domain/entity.rs b/imphnen-qr/src/campaigns/domain/entity.rs deleted file mode 100644 index 9e26947..0000000 --- a/imphnen-qr/src/campaigns/domain/entity.rs +++ /dev/null @@ -1,24 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use utoipa::ToSchema; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)] -pub struct CampaignEntity { - pub id: Uuid, - pub name: String, - pub url: String, - pub is_active: bool, - pub created_by: Uuid, - pub expires_at: DateTime, - pub created_at: Option>, - pub updated_at: Option>, -} - -pub struct CreateCampaignInput { - pub name: String, - pub url: String, - pub created_by: Uuid, - pub qr_code_data: Vec, -} diff --git a/imphnen-qr/src/campaigns/domain/repository.rs b/imphnen-qr/src/campaigns/domain/repository.rs deleted file mode 100644 index efde2be..0000000 --- a/imphnen-qr/src/campaigns/domain/repository.rs +++ /dev/null @@ -1,14 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use uuid::Uuid; - -use super::entity::{CampaignEntity, CreateCampaignInput}; - -#[async_trait] -pub trait CampaignRepository: Send + Sync { - async fn create(&self, input: CreateCampaignInput) -> Result; - async fn find_all(&self) -> Result, AppError>; - async fn find_active_qr_data(&self) -> Result>, AppError>; - async fn set_active(&self, id: Uuid) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; -} diff --git a/imphnen-qr/src/campaigns/domain/service.rs b/imphnen-qr/src/campaigns/domain/service.rs deleted file mode 100644 index 97c14a0..0000000 --- a/imphnen-qr/src/campaigns/domain/service.rs +++ /dev/null @@ -1,15 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use uuid::Uuid; - -use super::entity::CampaignEntity; - -#[async_trait] -pub trait QrCampaignService: Send + Sync { - async fn create(&self, name: String, url: String, created_by: Uuid) -> Result; - async fn list_all(&self) -> Result, AppError>; - async fn get_active_qr_data(&self) -> Result>, AppError>; - async fn set_active(&self, id: Uuid) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; - async fn process_image(&self, image_bytes: Vec) -> Result, AppError>; -} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/dto.rs b/imphnen-qr/src/campaigns/infrastructure/http/dto.rs deleted file mode 100644 index f69d9fd..0000000 --- a/imphnen-qr/src/campaigns/infrastructure/http/dto.rs +++ /dev/null @@ -1,22 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateCampaignRequest { - pub name: String, - pub url: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct CampaignResponse { - pub id: Uuid, - pub name: String, - pub url: String, - pub is_active: bool, - pub created_by: Uuid, - pub expires_at: DateTime, - pub created_at: Option>, - pub updated_at: Option>, -} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs b/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs deleted file mode 100644 index d91b4b0..0000000 --- a/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs +++ /dev/null @@ -1,85 +0,0 @@ -use axum::{ - extract::{Multipart, Path}, - response::{IntoResponse, Response}, - Extension, Json, -}; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::{ - campaigns::{ - domain::service::QrCampaignService, - infrastructure::http::dto::CreateCampaignRequest, - }, - middleware::qr_auth::QrAuthUser, -}; - -pub async fn create_campaign_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Json(body): Json, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - let campaign = service.create(body.name, body.url, auth_user.user_id).await?; - Ok(imphnen_utils::response_format::ApiCreated(campaign).into_response()) -} - -pub async fn list_campaigns_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - let campaigns = service.list_all().await?; - Ok(ApiSuccess(campaigns).into_response()) -} - -pub async fn activate_campaign_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Path(id): Path, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - let campaign = service.set_active(id).await?; - Ok(ApiSuccess(campaign).into_response()) -} - -pub async fn delete_campaign_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Path(id): Path, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - service.delete(id).await?; - Ok(imphnen_utils::response_format::ApiMessage::ok("Campaign deleted successfully").into_response()) -} - -pub async fn process_image_handler( - Extension(service): Extension>, - Extension(_auth_user): Extension, - mut multipart: Multipart, -) -> Result { - let mut image_bytes = Vec::new(); - while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequestError(e.to_string()))? { - if field.name() == Some("file") { - image_bytes = field.bytes().await.map_err(|e| AppError::BadRequestError(e.to_string()))?.to_vec(); - break; - } - } - if image_bytes.is_empty() { - return Err(AppError::BadRequestError("No file provided".to_string())); - } - let png_bytes = service.process_image(image_bytes).await?; - Ok(( - [(axum::http::header::CONTENT_TYPE, "image/png")], - png_bytes, - ).into_response()) -} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/routes.rs b/imphnen-qr/src/campaigns/infrastructure/http/routes.rs deleted file mode 100644 index 3047a0e..0000000 --- a/imphnen-qr/src/campaigns/infrastructure/http/routes.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::{ - middleware::from_fn, - routing::{delete, post, put}, - Extension, Router, -}; -use sqlx::PgPool; -use std::sync::Arc; - -use crate::{ - campaigns::{ - application::campaign_service::QrCampaignServiceImpl, - domain::{repository::CampaignRepository, service::QrCampaignService}, - infrastructure::{ - http::handlers::{ - activate_campaign_handler, create_campaign_handler, delete_campaign_handler, - list_campaigns_handler, process_image_handler, - }, - persistence::postgres_campaign_repository::PostgresCampaignRepository, - }, - }, - middleware::qr_auth::qr_auth_middleware, -}; - -pub fn qr_campaigns_routes(pool: Arc) -> Router { - let repo: Arc = Arc::new(PostgresCampaignRepository::new(pool.clone())); - let service: Arc = Arc::new(QrCampaignServiceImpl::new(repo)); - - Router::new() - .route("/campaigns", post(create_campaign_handler).get(list_campaigns_handler)) - .route("/campaigns/:id/activate", put(activate_campaign_handler)) - .route("/campaigns/:id", delete(delete_campaign_handler)) - .route("/campaigns/process-image", post(process_image_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(qr_auth_middleware)) -} diff --git a/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs b/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs deleted file mode 100644 index 9f4e71d..0000000 --- a/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs +++ /dev/null @@ -1,107 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use sqlx::PgPool; -use std::sync::Arc; -use uuid::Uuid; - -use crate::campaigns::domain::{ - entity::{CampaignEntity, CreateCampaignInput}, - repository::CampaignRepository, -}; - -pub struct PostgresCampaignRepository { - pool: Arc, -} - -impl PostgresCampaignRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } -} - -#[async_trait] -impl CampaignRepository for PostgresCampaignRepository { - async fn create(&self, input: CreateCampaignInput) -> Result { - let mut tx = self.pool.begin().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") - .execute(&mut *tx) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let id = Uuid::new_v4(); - let campaign = sqlx::query_as::<_, CampaignEntity>( - "INSERT INTO qr_campaigns (id, name, url, qr_code_data, is_active, created_by, expires_at) \ - VALUES ($1, $2, $3, $4, true, $5, NOW() + INTERVAL '30 days') \ - RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", - ) - .bind(id) - .bind(&input.name) - .bind(&input.url) - .bind(&input.qr_code_data) - .bind(input.created_by) - .fetch_one(&mut *tx) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - tx.commit().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(campaign) - } - - async fn find_all(&self) -> Result, AppError> { - sqlx::query_as::<_, CampaignEntity>( - "SELECT id, name, url, is_active, created_by, expires_at, created_at, updated_at \ - FROM qr_campaigns ORDER BY created_at DESC", - ) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn find_active_qr_data(&self) -> Result>, AppError> { - let row = sqlx::query_as::<_, (Vec,)>( - "SELECT qr_code_data FROM qr_campaigns WHERE is_active = true LIMIT 1", - ) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(row.map(|r| r.0)) - } - - async fn set_active(&self, id: Uuid) -> Result { - let mut tx = self.pool.begin().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") - .execute(&mut *tx) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - let campaign = sqlx::query_as::<_, CampaignEntity>( - "UPDATE qr_campaigns SET is_active = true, updated_at = NOW() WHERE id = $1 \ - RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", - ) - .bind(id) - .fetch_one(&mut *tx) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - tx.commit().await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - Ok(campaign) - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - sqlx::query("DELETE FROM qr_campaigns WHERE id = $1") - .bind(id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } -} diff --git a/imphnen-qr/src/common/mod.rs b/imphnen-qr/src/common/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/imphnen-qr/src/common/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/imphnen-qr/src/middleware/qr_auth.rs b/imphnen-qr/src/middleware/qr_auth.rs deleted file mode 100644 index 6bd3df7..0000000 --- a/imphnen-qr/src/middleware/qr_auth.rs +++ /dev/null @@ -1,55 +0,0 @@ -use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}}; -use axum::http::StatusCode; -use sqlx::PgPool; -use std::sync::Arc; -use uuid::Uuid; -use serde::{Deserialize, Serialize}; -use imphnen_libs::decode_access_token; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QrAuthUser { - pub user_id: Uuid, - pub role: String, -} - -pub async fn qr_auth_middleware( - axum::Extension(pool): axum::Extension>, - mut request: Request, - next: Next, -) -> Result { - let auth_header = request - .headers() - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response())?; - - let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { - (StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response() - })?; - - let token_data = decode_access_token(token).map_err(|_| { - (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response() - })?; - - let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| { - (StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response() - })?; - - let _ = sqlx::query( - "INSERT INTO users (id, email, name, role, provider) VALUES ($1, $2, $2, 'user', 'external') ON CONFLICT (id) DO NOTHING" - ) - .bind(user_id) - .bind(&token_data.claims.sub) - .execute(pool.as_ref()) - .await; - - let role: String = sqlx::query_scalar("SELECT role FROM users WHERE id = $1") - .bind(user_id) - .fetch_optional(pool.as_ref()) - .await - .unwrap_or(None) - .unwrap_or_else(|| "user".to_string()); - - request.extensions_mut().insert(QrAuthUser { user_id, role }); - Ok(next.run(request).await) -} diff --git a/imphnen-qr/src/users/application/user_service.rs b/imphnen-qr/src/users/application/user_service.rs deleted file mode 100644 index 6be98fd..0000000 --- a/imphnen-qr/src/users/application/user_service.rs +++ /dev/null @@ -1,51 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use std::sync::Arc; -use uuid::Uuid; - -use crate::users::domain::{ - entity::{UpdateUserInput, UserEntity}, - repository::UserRepository, - service::QrUserService, -}; - -pub struct QrUserServiceImpl { - repo: Arc, -} - -impl QrUserServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } -} - -#[async_trait] -impl QrUserService for QrUserServiceImpl { - async fn get_profile(&self, user_id: Uuid) -> Result { - self.repo - .find_by_id(user_id) - .await? - .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) - } - - async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result { - if let Some(ref email) = input.email { - if email.trim().is_empty() { - return Err(AppError::ValidationError("Email cannot be empty".to_string())); - } - } - self.repo.update(user_id, input).await - } - - async fn list_all(&self) -> Result, AppError> { - self.repo.find_all().await - } - - async fn update_role(&self, id: Uuid, role: String) -> Result { - self.repo.update_role(id, role).await - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - self.repo.delete(id).await - } -} diff --git a/imphnen-qr/src/users/domain/entity.rs b/imphnen-qr/src/users/domain/entity.rs deleted file mode 100644 index 6e8eb4b..0000000 --- a/imphnen-qr/src/users/domain/entity.rs +++ /dev/null @@ -1,21 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use utoipa::ToSchema; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)] -pub struct UserEntity { - pub id: Uuid, - pub email: String, - pub name: String, - pub role: String, - pub provider: String, - pub created_at: Option>, - pub updated_at: Option>, -} - -pub struct UpdateUserInput { - pub name: Option, - pub email: Option, -} diff --git a/imphnen-qr/src/users/domain/repository.rs b/imphnen-qr/src/users/domain/repository.rs deleted file mode 100644 index 9b4eda2..0000000 --- a/imphnen-qr/src/users/domain/repository.rs +++ /dev/null @@ -1,14 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use uuid::Uuid; - -use super::entity::{UpdateUserInput, UserEntity}; - -#[async_trait] -pub trait UserRepository: Send + Sync { - async fn find_by_id(&self, id: Uuid) -> Result, AppError>; - async fn find_all(&self) -> Result, AppError>; - async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result; - async fn update_role(&self, id: Uuid, role: String) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; -} diff --git a/imphnen-qr/src/users/domain/service.rs b/imphnen-qr/src/users/domain/service.rs deleted file mode 100644 index 46a867b..0000000 --- a/imphnen-qr/src/users/domain/service.rs +++ /dev/null @@ -1,14 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use uuid::Uuid; - -use super::entity::{UpdateUserInput, UserEntity}; - -#[async_trait] -pub trait QrUserService: Send + Sync { - async fn get_profile(&self, user_id: Uuid) -> Result; - async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result; - async fn list_all(&self) -> Result, AppError>; - async fn update_role(&self, id: Uuid, role: String) -> Result; - async fn delete(&self, id: Uuid) -> Result<(), AppError>; -} diff --git a/imphnen-qr/src/users/infrastructure/http/handlers.rs b/imphnen-qr/src/users/infrastructure/http/handlers.rs deleted file mode 100644 index 6ed17ff..0000000 --- a/imphnen-qr/src/users/infrastructure/http/handlers.rs +++ /dev/null @@ -1,73 +0,0 @@ -use axum::{ - extract::Path, - response::{IntoResponse, Response}, - Extension, Json, -}; -use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::{ - middleware::qr_auth::QrAuthUser, - users::{ - domain::{entity::UpdateUserInput, service::QrUserService}, - infrastructure::http::dto::{UpdateProfileRequest, UpdateRoleRequest}, - }, -}; - -pub async fn get_me_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, -) -> Result { - let user = service.get_profile(auth_user.user_id).await?; - Ok(ApiSuccess(user).into_response()) -} - -pub async fn update_me_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Json(body): Json, -) -> Result { - let input = UpdateUserInput { - name: body.name, - email: body.email, - }; - let user = service.update_profile(auth_user.user_id, input).await?; - Ok(ApiSuccess(user).into_response()) -} - -pub async fn list_users_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - let users = service.list_all().await?; - Ok(ApiSuccess(users).into_response()) -} - -pub async fn update_role_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Path(id): Path, - Json(body): Json, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - let user = service.update_role(id, body.role).await?; - Ok(ApiSuccess(user).into_response()) -} - -pub async fn delete_user_handler( - Extension(service): Extension>, - Extension(auth_user): Extension, - Path(id): Path, -) -> Result { - if auth_user.role != "admin" { - return Err(AppError::ForbiddenError("Admin access required".to_string())); - } - service.delete(id).await?; - Ok(imphnen_utils::response_format::ApiMessage::ok("User deleted successfully").into_response()) -} diff --git a/imphnen-qr/src/users/infrastructure/http/routes.rs b/imphnen-qr/src/users/infrastructure/http/routes.rs deleted file mode 100644 index a27976b..0000000 --- a/imphnen-qr/src/users/infrastructure/http/routes.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::{ - middleware::from_fn, - routing::{delete, get, put}, - Extension, Router, -}; -use sqlx::PgPool; -use std::sync::Arc; - -use crate::{ - middleware::qr_auth::qr_auth_middleware, - users::{ - application::user_service::QrUserServiceImpl, - domain::{repository::UserRepository, service::QrUserService}, - infrastructure::{ - http::handlers::{ - delete_user_handler, get_me_handler, list_users_handler, update_me_handler, - update_role_handler, - }, - persistence::postgres_user_repository::PostgresUserRepository, - }, - }, -}; - -pub fn qr_users_routes(pool: Arc) -> Router { - let repo: Arc = Arc::new(PostgresUserRepository::new(pool.clone())); - let service: Arc = Arc::new(QrUserServiceImpl::new(repo)); - - Router::new() - .route("/users/me", get(get_me_handler).put(update_me_handler)) - .route("/users", get(list_users_handler)) - .route("/users/:id/role", put(update_role_handler)) - .route("/users/:id", delete(delete_user_handler)) - .layer(Extension(service)) - .layer(Extension(pool)) - .layer(from_fn(qr_auth_middleware)) -} diff --git a/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs b/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs deleted file mode 100644 index 45bb5e2..0000000 --- a/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs +++ /dev/null @@ -1,74 +0,0 @@ -use async_trait::async_trait; -use imphnen_utils::errors::AppError; -use sqlx::PgPool; -use std::sync::Arc; -use uuid::Uuid; - -use crate::users::domain::{ - entity::{UpdateUserInput, UserEntity}, - repository::UserRepository, -}; - -pub struct PostgresUserRepository { - pool: Arc, -} - -impl PostgresUserRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } -} - -#[async_trait] -impl UserRepository for PostgresUserRepository { - async fn find_by_id(&self, id: Uuid) -> Result, AppError> { - sqlx::query_as::<_, UserEntity>( - "SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1", - ) - .bind(id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn find_all(&self) -> Result, AppError> { - sqlx::query_as::<_, UserEntity>( - "SELECT id, email, name, role, provider, created_at, updated_at FROM users ORDER BY created_at DESC", - ) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result { - sqlx::query_as::<_, UserEntity>( - "UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email), updated_at = NOW() WHERE id = $3 RETURNING id, email, name, role, provider, created_at, updated_at", - ) - .bind(input.name) - .bind(input.email) - .bind(id) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn update_role(&self, id: Uuid, role: String) -> Result { - sqlx::query_as::<_, UserEntity>( - "UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role, provider, created_at, updated_at", - ) - .bind(role) - .bind(id) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string())) - } - - async fn delete(&self, id: Uuid) -> Result<(), AppError> { - sqlx::query("DELETE FROM users WHERE id = $1") - .bind(id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - Ok(()) - } -} diff --git a/imphnen-storage/Cargo.toml b/imphnen-storage/Cargo.toml new file mode 100644 index 0000000..a45f592 --- /dev/null +++ b/imphnen-storage/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "imphnen-storage" +version = "0.3.0" +edition = "2024" + +[dependencies] +imphnen-libs.workspace = true +anyhow.workspace = true +base64.workspace = true +chrono.workspace = true +hmac.workspace = true +sha2.workspace = true +uuid.workspace = true +reqwest.workspace = true +hex.workspace = true +urlencoding.workspace = true +tracing.workspace = true diff --git a/imphnen-storage/src/config.rs b/imphnen-storage/src/config.rs new file mode 100644 index 0000000..e5aef29 --- /dev/null +++ b/imphnen-storage/src/config.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use imphnen_libs::ENV; + +#[derive(Debug, Clone)] +pub struct MinioConfig { + pub endpoint: String, + pub access_key: String, + pub secret_key: String, + pub bucket_name: String, + pub region: String, + pub secure: bool, +} + +impl MinioConfig { + pub fn from_env() -> Result { + Ok(Self { + endpoint: ENV.minio_endpoint.clone(), + access_key: ENV.minio_access_key.clone(), + secret_key: ENV.minio_secret_key.clone(), + bucket_name: ENV.minio_bucket_name.clone(), + region: ENV.minio_region.clone(), + secure: ENV.minio_secure, + }) + } + + pub fn endpoint_url(&self) -> String { + if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") + { + self.endpoint.clone() + } else { + let protocol = if self.secure { "https" } else { "http" }; + format!("{protocol}://{}", self.endpoint) + } + } +} diff --git a/imphnen-storage/src/helpers.rs b/imphnen-storage/src/helpers.rs new file mode 100644 index 0000000..f5648c3 --- /dev/null +++ b/imphnen-storage/src/helpers.rs @@ -0,0 +1,38 @@ +use anyhow::{Result, anyhow}; +use base64::{Engine as _, engine::general_purpose}; + +use crate::config::MinioConfig; +use crate::service::MinioService; + +pub async fn create_minio_service_from_config( + config: MinioConfig, +) -> Result { + MinioService::new( + &config.endpoint, + &config.access_key, + &config.secret_key, + &config.bucket_name, + &config.region, + ) + .await +} + +pub fn decode_base64_file(base64_data: &str) -> Result> { + let clean_data = if base64_data.contains(',') { + base64_data.split(',').nth(1).unwrap_or(base64_data) + } else { + base64_data + }; + general_purpose::STANDARD + .decode(clean_data) + .map_err(|e| anyhow!("Failed to decode base64 data: {}", e)) +} + +pub fn extract_content_type_from_data_url(data_url: &str) -> Option { + if data_url.starts_with("data:") + && let Some(type_part) = data_url.split(';').next() + { + return Some(type_part.replace("data:", "")); + } + None +} diff --git a/imphnen-storage/src/lib.rs b/imphnen-storage/src/lib.rs new file mode 100644 index 0000000..d29ed81 --- /dev/null +++ b/imphnen-storage/src/lib.rs @@ -0,0 +1,13 @@ +pub mod config; +pub mod helpers; +pub mod service; +pub mod signing; +pub mod types; + +pub use config::MinioConfig; +pub use helpers::{ + create_minio_service_from_config, decode_base64_file, + extract_content_type_from_data_url, +}; +pub use service::MinioService; +pub use types::{FileMetadata, FileType, UploadRequest, UploadResult}; diff --git a/imphnen-storage/src/service.rs b/imphnen-storage/src/service.rs new file mode 100644 index 0000000..5ef0562 --- /dev/null +++ b/imphnen-storage/src/service.rs @@ -0,0 +1,253 @@ +use anyhow::{Result, bail}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::helpers::decode_base64_file; +use crate::signing::{compute_header_auth, compute_presigned_url}; +use crate::types::{get_file_extension, validate_file_type}; + +pub struct MinioService { + pub(crate) endpoint: String, + pub(crate) access_key: String, + pub(crate) secret_key: String, + pub(crate) bucket_name: String, + pub(crate) region: String, + pub(crate) client: reqwest::Client, +} + +impl MinioService { + pub async fn new( + endpoint: &str, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region: &str, + ) -> Result { + Ok(Self { + endpoint: endpoint.to_string(), + access_key: access_key.to_string(), + secret_key: secret_key.to_string(), + bucket_name: bucket_name.to_string(), + region: region.to_string(), + client: reqwest::Client::new(), + }) + } + + pub async fn upload_file_with_deduplication( + &self, + file_data: &[u8], + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + validate_file_type(content_type, file_data)?; + let mut hasher = Sha256::new(); + hasher.update(file_data); + let file_hash = format!("{:x}", hasher.finalize()); + let short_hash = &file_hash[..16]; + if let Some(existing) = + self.check_file_exists_by_hash(folder, short_hash).await? + { + return Ok(existing); + } + let ext = get_file_extension(original_filename); + let unique_filename = format!("{folder}/{short_hash}-{}.{ext}", Uuid::new_v4()); + self + .put_object(&unique_filename, file_data, content_type) + .await?; + tracing::info!("Uploaded {} bytes to {}", file_data.len(), unique_filename); + Ok(unique_filename) + } + + pub async fn upload_file( + &self, + file_data: &[u8], + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + validate_file_type(content_type, file_data)?; + let ext = get_file_extension(original_filename); + let unique_filename = format!("{folder}/{}.{ext}", Uuid::new_v4()); + self + .put_object(&unique_filename, file_data, content_type) + .await?; + tracing::info!("Uploaded {} bytes to {}", file_data.len(), unique_filename); + Ok(unique_filename) + } + + pub async fn upload_base64_file( + &self, + base64_data: &str, + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + let file_data = decode_base64_file(base64_data)?; + self + .upload_file(&file_data, content_type, folder, original_filename) + .await + } + + pub async fn get_presigned_url( + &self, + object_name: &str, + expiry_seconds: u32, + ) -> Result { + let host = self.strip_protocol(); + compute_presigned_url( + host, + &self.bucket_name, + object_name, + expiry_seconds, + &self.access_key, + &self.secret_key, + &self.region, + ) + } + + pub async fn check_file_exists_by_hash( + &self, + folder: &str, + file_hash: &str, + ) -> Result> { + let host = self.strip_protocol(); + let url = format!( + "https://{host}/{}?list-type=2&prefix={folder}", + self.bucket_name + ); + let payload_hash = hex::encode(Sha256::digest(b"")); + let canonical_query = + format!("list-type=2&prefix={}", urlencoding::encode(folder)); + let (auth_header, amz_date) = compute_header_auth( + "GET", + host, + &format!("/{}", self.bucket_name), + &canonical_query, + &payload_hash, + &self.access_key, + &self.secret_key, + &self.region, + )?; + + let response = self + .client + .get(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + return Ok(None); + } + + let body = response.text().await?; + if body.contains(file_hash) { + for line in body.lines() { + if line.contains("") + && line.contains(file_hash) + && let Some(start) = line.find("") + && let Some(end) = line.find("") + { + return Ok(Some(line[start + 5..end].to_string())); + } + } + } + Ok(None) + } + + pub async fn delete_file(&self, object_name: &str) -> Result<()> { + let host = self.strip_protocol(); + let url = format!("https://{host}/{}/{object_name}", self.bucket_name); + let payload_hash = hex::encode(Sha256::digest(b"")); + let canonical_uri = format!("/{}/{object_name}", self.bucket_name); + let (auth_header, amz_date) = compute_header_auth( + "DELETE", + host, + &canonical_uri, + "", + &payload_hash, + &self.access_key, + &self.secret_key, + &self.region, + )?; + + let response = self + .client + .delete(&url) + .header("Host", host) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Failed to delete from MinIO. Status: {}. Message: {}", + status, + error_body + ); + } + + tracing::info!("Deleted file: {}", object_name); + Ok(()) + } + + async fn put_object( + &self, + object_name: &str, + file_data: &[u8], + content_type: &str, + ) -> Result<()> { + let host = self.strip_protocol(); + let url = format!("https://{host}/{}/{object_name}", self.bucket_name); + let payload_hash = "UNSIGNED-PAYLOAD".to_string(); + let canonical_uri = format!("/{}/{object_name}", self.bucket_name); + let (auth_header, amz_date) = compute_header_auth( + "PUT", + host, + &canonical_uri, + "", + &payload_hash, + &self.access_key, + &self.secret_key, + &self.region, + )?; + + let response = self + .client + .put(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .header("Content-Type", content_type) + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", host) + .body(file_data.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Failed to upload to MinIO. Status: {}. Message: {}", + status, + error_body + ); + } + Ok(()) + } + + fn strip_protocol(&self) -> &str { + self + .endpoint + .trim_start_matches("https://") + .trim_start_matches("http://") + } +} diff --git a/imphnen-storage/src/signing.rs b/imphnen-storage/src/signing.rs new file mode 100644 index 0000000..dda3fa8 --- /dev/null +++ b/imphnen-storage/src/signing.rs @@ -0,0 +1,115 @@ +use anyhow::Result; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; + +#[allow(clippy::too_many_arguments)] +pub fn compute_header_auth( + method: &str, + host: &str, + canonical_uri: &str, + canonical_query: &str, + payload_hash: &str, + access_key: &str, + secret_key: &str, + region: &str, +) -> Result<(String, String)> { + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let scope = format!("{date_stamp}/{region}/s3/aws4_request"); + + let canonical_headers = format!( + "host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n" + ); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + ); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = derive_signing_key(secret_key, &date_stamp, region)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed_headers}, Signature={signature}" + ); + Ok((auth_header, amz_date)) +} + +pub fn compute_presigned_url( + host: &str, + bucket: &str, + object_name: &str, + expiry_seconds: u32, + access_key: &str, + secret_key: &str, + region: &str, +) -> Result { + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let scope = format!("{date_stamp}/{region}/s3/aws4_request"); + let credential = format!("{access_key}/{scope}"); + let expires_str = expiry_seconds.to_string(); + + let mut query_params = std::collections::BTreeMap::new(); + query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + query_params.insert("X-Amz-Credential", &credential); + query_params.insert("X-Amz-Date", &amz_date); + query_params.insert("X-Amz-Expires", &expires_str); + query_params.insert("X-Amz-SignedHeaders", "host"); + + let canonical_query_string = query_params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + let canonical_request = format!( + "GET\n/{bucket}/{object_name}\n{canonical_query_string}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD" + ); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = derive_signing_key(secret_key, &date_stamp, region)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + Ok(format!( + "https://{host}/{bucket}/{object_name}?{canonical_query_string}&X-Amz-Signature={signature}" + )) +} + +pub(crate) fn derive_signing_key( + secret_key: &str, + date_stamp: &str, + region: &str, +) -> Result> { + let secret = format!("AWS4{secret_key}"); + let mut mac1 = Hmac::::new_from_slice(secret.as_bytes())?; + mac1.update(date_stamp.as_bytes()); + let date_key = mac1.finalize().into_bytes(); + + let mut mac2 = Hmac::::new_from_slice(&date_key)?; + mac2.update(region.as_bytes()); + let date_region_key = mac2.finalize().into_bytes(); + + let mut mac3 = Hmac::::new_from_slice(&date_region_key)?; + mac3.update(b"s3"); + let date_region_service_key = mac3.finalize().into_bytes(); + + let mut mac4 = Hmac::::new_from_slice(&date_region_service_key)?; + mac4.update(b"aws4_request"); + Ok(mac4.finalize().into_bytes().to_vec()) +} diff --git a/imphnen-storage/src/types.rs b/imphnen-storage/src/types.rs new file mode 100644 index 0000000..6ba9c35 --- /dev/null +++ b/imphnen-storage/src/types.rs @@ -0,0 +1,157 @@ +use anyhow::{Result, bail}; + +#[derive(Debug, Clone)] +pub struct UploadResult { + pub object_name: String, + pub url: String, + pub size: usize, + pub content_type: String, +} + +#[derive(Debug, Clone)] +pub struct UploadRequest { + pub user_id: String, + pub file_type: FileType, + pub filename: String, + pub content_type: String, + pub data: Vec, +} + +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub filename: String, + pub content_type: String, + pub size: usize, + pub path: String, + pub url: String, +} + +#[derive(Debug, Clone)] +pub enum FileType { + Jpeg, + Png, + Webp, + Gif, + Pdf, + Doc, + Docx, + Unknown, +} + +impl FileType { + pub fn as_folder(&self) -> &str { + match self { + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles", + FileType::Pdf | FileType::Doc | FileType::Docx => "documents", + FileType::Unknown => "misc", + } + } + + pub fn max_size(&self) -> usize { + match self { + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => { + 5 * 1024 * 1024 + } + FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, + FileType::Unknown => 5 * 1024 * 1024, + } + } + + pub fn allowed_types(&self) -> Vec<&str> { + match self { + FileType::Jpeg => vec!["image/jpeg", "image/jpg"], + FileType::Png => vec!["image/png"], + FileType::Webp => vec!["image/webp"], + FileType::Gif => vec!["image/gif"], + FileType::Pdf => vec!["application/pdf"], + FileType::Doc => vec!["application/msword"], + FileType::Docx => vec![ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + FileType::Unknown => vec![], + } + } + + pub fn from_content_type(content_type: &str) -> Self { + match content_type { + "image/jpeg" | "image/jpg" => FileType::Jpeg, + "image/png" => FileType::Png, + "image/webp" => FileType::Webp, + "image/gif" => FileType::Gif, + "application/pdf" => FileType::Pdf, + "application/msword" => FileType::Doc, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + FileType::Docx + } + _ => FileType::Unknown, + } + } + + pub fn from_filename(filename: &str) -> Self { + let f = filename.to_lowercase(); + if f.ends_with(".jpg") || f.ends_with(".jpeg") { + FileType::Jpeg + } else if f.ends_with(".png") { + FileType::Png + } else if f.ends_with(".webp") { + FileType::Webp + } else if f.ends_with(".gif") { + FileType::Gif + } else if f.ends_with(".pdf") { + FileType::Pdf + } else if f.ends_with(".doc") { + FileType::Doc + } else if f.ends_with(".docx") { + FileType::Docx + } else { + FileType::Unknown + } + } +} + +pub fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> { + const MAX_SIZE: usize = 10 * 1024 * 1024; + if file_data.len() > MAX_SIZE { + bail!("File size exceeds 10MB limit"); + } + match content_type { + "image/jpeg" | "image/jpg" => { + if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) { + bail!("Invalid JPEG file"); + } + } + "image/png" => { + if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { + bail!("Invalid PNG file"); + } + } + "application/pdf" => { + if !file_data.starts_with(b"%PDF") { + bail!("Invalid PDF file"); + } + } + "image/webp" => { + if !file_data.starts_with(b"RIFF") + || file_data.get(8..12).is_none_or(|s| s != b"WEBP") + { + bail!("Invalid WebP file"); + } + } + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + if file_data.len() < 512 { + bail!("Invalid document file"); + } + } + _ => bail!("Unsupported file type: {}", content_type), + } + Ok(()) +} + +pub fn get_file_extension(filename: &str) -> String { + std::path::Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("bin") + .to_lowercase() +} diff --git a/imphnen-utils/Cargo.toml b/imphnen-utils/Cargo.toml index 66e6cd9..8b6b253 100644 --- a/imphnen-utils/Cargo.toml +++ b/imphnen-utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imphnen-utils" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/imphnen-utils/src/csrf_token.rs b/imphnen-utils/src/csrf_token.rs index b0a01a1..9fe4f9a 100644 --- a/imphnen-utils/src/csrf_token.rs +++ b/imphnen-utils/src/csrf_token.rs @@ -1,226 +1,184 @@ -//! CSRF token generation and validation utilities. -//! -//! This module provides stateless CSRF token management using signed tokens -//! with timestamp validation to prevent cross-site request forgery attacks. - -use std::time::{SystemTime, UNIX_EPOCH}; -use serde::{Deserialize, Serialize}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use sha2::{Sha256, Digest}; -use imphnen_entities::error_dto::error::Error; -use tracing::error; - -#[derive(Debug, Serialize, Deserialize)] -struct CsrfPayload { - pub timestamp: u64, - pub random: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct OAuthCsrfPayload { - pub timestamp: u64, - pub random: String, - pub pkce_verifier: String, -} - -/// Generate a signed CSRF token that can be validated without server-side storage -pub fn generate_csrf_token(secret: &str) -> Result { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? - .as_secs(); - - let random = uuid::Uuid::new_v4().to_string(); - - let payload = CsrfPayload { - timestamp, - random, - }; - - let payload_json = serde_json::to_string(&payload) - .map_err(|e| { - error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e); - Error::Auth("Failed to serialize CSRF payload".to_string()) - })?; - - let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); - - // Create signature - let mut hasher = Sha256::new(); - hasher.update(payload_b64.as_bytes()); - hasher.update(secret.as_bytes()); - let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - Ok(format!("{payload_b64}.{signature}")) -} - -/// Generate a signed OAuth CSRF token with PKCE verifier -pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? - .as_secs(); - - let random = uuid::Uuid::new_v4().to_string(); - - let payload = OAuthCsrfPayload { - timestamp, - random, - pkce_verifier: pkce_verifier.to_string(), - }; - - let payload_json = serde_json::to_string(&payload) - .map_err(|e| { - error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e); - Error::Auth("Failed to serialize OAuth CSRF payload".to_string()) - })?; - - let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); - - // Create signature - let mut hasher = Sha256::new(); - hasher.update(payload_b64.as_bytes()); - hasher.update(secret.as_bytes()); - let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - Ok(format!("{payload_b64}.{signature}")) -} - -/// Validate a CSRF token -pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 2 { - return Err(Error::Auth("Invalid CSRF token format".to_string())); - } - - let payload_b64 = parts[0]; - let provided_signature = parts[1]; - - // Verify signature - let mut hasher = Sha256::new(); - hasher.update(payload_b64.as_bytes()); - hasher.update(secret.as_bytes()); - let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - if provided_signature != expected_signature { - return Err(Error::Auth("Invalid CSRF token signature".to_string())); - } - - // Decode and validate payload - let payload_json = URL_SAFE_NO_PAD.decode(payload_b64) - .map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?; - - let payload_str = String::from_utf8(payload_json) - .map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?; - - let payload: CsrfPayload = serde_json::from_str(&payload_str) - .map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?; - - // Check timestamp - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? - .as_secs(); - - if now > payload.timestamp + max_age_seconds { - return Err(Error::Auth("CSRF token has expired".to_string())); - } - - if payload.timestamp > now + 60 { // Allow 1 minute clock skew - return Err(Error::Auth("CSRF token timestamp is in the future".to_string())); - } - - Ok(()) -} - -/// Validate OAuth CSRF token and extract PKCE verifier -pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 2 { - return Err(Error::Auth("Invalid OAuth CSRF token format".to_string())); - } - - let payload_b64 = parts[0]; - let provided_signature = parts[1]; - - // Verify signature - let mut hasher = Sha256::new(); - hasher.update(payload_b64.as_bytes()); - hasher.update(secret.as_bytes()); - let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - if provided_signature != expected_signature { - return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string())); - } - - // Decode and validate payload - let payload_json = URL_SAFE_NO_PAD.decode(payload_b64) - .map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?; - - let payload_str = String::from_utf8(payload_json) - .map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?; - - let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str) - .map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?; - - // Check timestamp - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? - .as_secs(); - - if now > payload.timestamp + max_age_seconds { - return Err(Error::Auth("OAuth CSRF token has expired".to_string())); - } - - if payload.timestamp > now + 60 { // Allow 1 minute clock skew - return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string())); - } - - Ok(payload.pkce_verifier) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_csrf_token_generation_and_validation() { - let secret = "test_secret"; - - // Generate token - let token = generate_csrf_token(secret).unwrap(); - - // Validate token (should pass) - assert!(validate_csrf_token(&token, secret, 300).is_ok()); - - // Validate with wrong secret (should fail) - assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err()); - } - - #[test] - fn test_csrf_token_expiration() { - let secret = "test_secret"; - let token = generate_csrf_token(secret).unwrap(); - - // Add a 2 second delay to ensure the token expires when max_age is 1 second - std::thread::sleep(std::time::Duration::from_secs(2)); - - // Should fail with 1 second max age (token is now 2 seconds old) - assert!(validate_csrf_token(&token, secret, 1).is_err()); - - // Should still work with a large max age - assert!(validate_csrf_token(&token, secret, 300).is_ok()); - } - - #[test] - fn test_invalid_csrf_token_format() { - let secret = "test_secret"; - - // Invalid format (no dot) - assert!(validate_csrf_token("invalid_token", secret, 300).is_err()); - - // Invalid format (too many dots) - assert!(validate_csrf_token("a.b.c", secret, 300).is_err()); - } -} \ No newline at end of file +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use imphnen_entities::error_dto::error::Error; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::error; + +#[derive(Debug, Serialize, Deserialize)] +struct CsrfPayload { + pub timestamp: u64, + pub random: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OAuthCsrfPayload { + pub timestamp: u64, + pub random: String, + pub pkce_verifier: String, +} + +pub fn generate_csrf_token(secret: &str) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? + .as_secs(); + + let random = uuid::Uuid::new_v4().to_string(); + + let payload = CsrfPayload { timestamp, random }; + + let payload_json = serde_json::to_string(&payload).map_err(|e| { + error!( + "CSRF Token Generation: Failed to serialize CSRF payload: {:?}", + e + ); + Error::Auth("Failed to serialize CSRF payload".to_string()) + })?; + + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + Ok(format!("{payload_b64}.{signature}")) +} + +pub fn generate_oauth_csrf_token( + secret: &str, + pkce_verifier: &str, +) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? + .as_secs(); + + let random = uuid::Uuid::new_v4().to_string(); + + let payload = OAuthCsrfPayload { + timestamp, + random, + pkce_verifier: pkce_verifier.to_string(), + }; + + let payload_json = serde_json::to_string(&payload).map_err(|e| { + error!( + "OAuth CSRF Token Generation: Failed to serialize payload: {:?}", + e + ); + Error::Auth("Failed to serialize OAuth CSRF payload".to_string()) + })?; + + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + Ok(format!("{payload_b64}.{signature}")) +} + +pub fn validate_csrf_token( + token: &str, + secret: &str, + max_age_seconds: u64, +) -> Result<(), Error> { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err(Error::Auth("Invalid CSRF token format".to_string())); + } + + let payload_b64 = parts[0]; + let provided_signature = parts[1]; + + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + if provided_signature != expected_signature { + return Err(Error::Auth("Invalid CSRF token signature".to_string())); + } + + let payload_json = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?; + + let payload_str = String::from_utf8(payload_json) + .map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?; + + let payload: CsrfPayload = serde_json::from_str(&payload_str) + .map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? + .as_secs(); + + if now > payload.timestamp + max_age_seconds { + return Err(Error::Auth("CSRF token has expired".to_string())); + } + + if payload.timestamp > now + 60 { + return Err(Error::Auth( + "CSRF token timestamp is in the future".to_string(), + )); + } + + Ok(()) +} + +pub fn validate_oauth_csrf_token( + token: &str, + secret: &str, + max_age_seconds: u64, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err(Error::Auth("Invalid OAuth CSRF token format".to_string())); + } + + let payload_b64 = parts[0]; + let provided_signature = parts[1]; + + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + if provided_signature != expected_signature { + return Err(Error::Auth( + "Invalid OAuth CSRF token signature".to_string(), + )); + } + + let payload_json = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?; + + let payload_str = String::from_utf8(payload_json) + .map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?; + + let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str) + .map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? + .as_secs(); + + if now > payload.timestamp + max_age_seconds { + return Err(Error::Auth("OAuth CSRF token has expired".to_string())); + } + + if payload.timestamp > now + 60 { + return Err(Error::Auth( + "OAuth CSRF token timestamp is in the future".to_string(), + )); + } + + Ok(payload.pkce_verifier) +} diff --git a/imphnen-utils/src/errors.rs b/imphnen-utils/src/errors.rs index a4625c4..21c25d8 100644 --- a/imphnen-utils/src/errors.rs +++ b/imphnen-utils/src/errors.rs @@ -1,114 +1,124 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Serialize; -use serde_json::json; - -#[derive(Debug, Serialize)] -pub enum AppError { - ValidationError(String), - AuthenticationError(String), - AuthorizationError(String), - NotFoundError(String), - ConflictError(String), - InternalServerError(String), - BadRequestError(String), - ForbiddenError(String), - PaymentRequiredError(String), - MethodNotAllowedError(String), - NotAcceptableError(String), - RequestTimeoutError(String), - TooManyRequestsError(String), - GatewayTimeoutError(String), - ServiceUnavailableError(String), -} - -impl std::fmt::Display for AppError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - AppError::AuthenticationError(msg) => write!(f, "Authentication failed: {}", msg), - AppError::AuthorizationError(msg) => write!(f, "Authorization failed: {}", msg), - AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg), - AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg), - AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg), - AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg), - AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg), - AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg), - AppError::MethodNotAllowedError(msg) => write!(f, "Method not allowed: {}", msg), - AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg), - AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg), - AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg), - AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg), - AppError::ServiceUnavailableError(msg) => write!(f, "Service unavailable: {}", msg), - } - } -} - -impl AppError { - pub fn status_code(&self) -> StatusCode { - match self { - AppError::ValidationError(_) => StatusCode::BAD_REQUEST, - AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED, - AppError::AuthorizationError(_) => StatusCode::FORBIDDEN, - AppError::NotFoundError(_) => StatusCode::NOT_FOUND, - AppError::ConflictError(_) => StatusCode::CONFLICT, - AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR, - AppError::BadRequestError(_) => StatusCode::BAD_REQUEST, - AppError::ForbiddenError(_) => StatusCode::FORBIDDEN, - AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED, - AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED, - AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE, - AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT, - AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS, - AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT, - AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE, - } - } - - pub fn message(&self) -> String { - self.to_string() - } -} - -impl From for AppError { - fn from(err: sea_orm::DbErr) -> Self { - AppError::InternalServerError(format!("Database error: {err}")) - } -} - -impl From for AppError { - fn from(err: anyhow::Error) -> Self { - AppError::InternalServerError(format!("Error: {err}")) - } -} - -impl From for AppError { - fn from(err: chrono::ParseError) -> Self { - AppError::BadRequestError(format!("Date parsing error: {err}")) - } -} - -impl From for AppError { - fn from(err: uuid::Error) -> Self { - AppError::BadRequestError(format!("UUID parsing error: {err}")) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status = self.status_code(); - ( - status, - Json(json!({ - "message": self.to_string(), - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() - } -} - -pub type Result = std::result::Result; \ No newline at end of file +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use serde_json::json; + +#[derive(Debug, Serialize)] +pub enum AppError { + ValidationError(String), + AuthenticationError(String), + AuthorizationError(String), + NotFoundError(String), + ConflictError(String), + InternalServerError(String), + BadRequestError(String), + ForbiddenError(String), + PaymentRequiredError(String), + MethodNotAllowedError(String), + NotAcceptableError(String), + RequestTimeoutError(String), + TooManyRequestsError(String), + GatewayTimeoutError(String), + ServiceUnavailableError(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + AppError::AuthenticationError(msg) => { + write!(f, "Authentication failed: {}", msg) + } + AppError::AuthorizationError(msg) => { + write!(f, "Authorization failed: {}", msg) + } + AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg), + AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg), + AppError::InternalServerError(msg) => { + write!(f, "Internal server error: {}", msg) + } + AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg), + AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg), + AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg), + AppError::MethodNotAllowedError(msg) => { + write!(f, "Method not allowed: {}", msg) + } + AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg), + AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg), + AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg), + AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg), + AppError::ServiceUnavailableError(msg) => { + write!(f, "Service unavailable: {}", msg) + } + } + } +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + AppError::ValidationError(_) => StatusCode::BAD_REQUEST, + AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED, + AppError::AuthorizationError(_) => StatusCode::FORBIDDEN, + AppError::NotFoundError(_) => StatusCode::NOT_FOUND, + AppError::ConflictError(_) => StatusCode::CONFLICT, + AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::BadRequestError(_) => StatusCode::BAD_REQUEST, + AppError::ForbiddenError(_) => StatusCode::FORBIDDEN, + AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED, + AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED, + AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE, + AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT, + AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS, + AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT, + AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } + + pub fn message(&self) -> String { + self.to_string() + } +} + +impl From for AppError { + fn from(err: sea_orm::DbErr) -> Self { + AppError::InternalServerError(format!("Database error: {err}")) + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::InternalServerError(format!("Error: {err}")) + } +} + +impl From for AppError { + fn from(err: chrono::ParseError) -> Self { + AppError::BadRequestError(format!("Date parsing error: {err}")) + } +} + +impl From for AppError { + fn from(err: uuid::Error) -> Self { + AppError::BadRequestError(format!("UUID parsing error: {err}")) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status_code(); + ( + status, + Json(json!({ + "message": self.to_string(), + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() + } +} + +pub type Result = std::result::Result; diff --git a/imphnen-utils/src/extract_email.rs b/imphnen-utils/src/extract_email.rs index cff77e3..a56e7f4 100644 --- a/imphnen-utils/src/extract_email.rs +++ b/imphnen-utils/src/extract_email.rs @@ -1,155 +1,136 @@ -//! Email extraction utilities from authentication tokens. -//! -//! This module provides functions to extract email addresses from JWT tokens -//! and Google OAuth access tokens, supporting both synchronous and asynchronous -//! validation methods. - -use tracing::{error, info}; -use imphnen_libs::jsonwebtoken::decode_access_token; -use axum::http::{HeaderMap, header::AUTHORIZATION}; - -/// Extracts the email from the Authorization header, if present and valid. -/// Supports both our internal JWT tokens and Google access tokens. -pub fn extract_email(headers: &HeaderMap) -> Option { - let auth_header = match headers.get(AUTHORIZATION) { - Some(h) => h, - None => { - error!("Authorization header missing in extract_email"); - return None; - } - }; - let auth_str = match auth_header.to_str() { - Ok(s) => s, - Err(e) => { - error!(error = ?e, "Failed to convert Authorization header to str in extract_email"); - return None; - } - }; - let token = match auth_str.strip_prefix("Bearer ") { - Some(t) => t, - None => { - error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email"); - return None; - } - }; - - // First try to decode as our internal JWT token - match decode_access_token(token) { - Ok(data) => { - Some(data.claims.sub) - } - Err(_) => { - // If it fails, it might be a Google access token - // For Google tokens, we need async validation, so we'll return None here - // and handle Google tokens separately in the calling code - error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly."); - None - } - } -} - -/// Async version that can handle Google access tokens -pub async fn extract_email_async(headers: &HeaderMap) -> Option { - let auth_header = match headers.get(AUTHORIZATION) { - Some(h) => h, - None => { - error!("Authorization header missing in extract_email_async"); - return None; - } - }; - let auth_str = match auth_header.to_str() { - Ok(s) => s, - Err(e) => { - error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async"); - return None; - } - }; - let token = match auth_str.strip_prefix("Bearer ") { - Some(t) => t, - None => { - error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async"); - return None; - } - }; - - // First try to decode as our internal JWT token - match decode_access_token(token) { - Ok(data) => { - Some(data.claims.sub) - } - Err(_) => { - // If it fails, try to validate as Google access token - extract_email_from_google_token(token).await - } - } -} - -/// Extracts email from Google access token by calling Google's tokeninfo endpoint -async fn extract_email_from_google_token(token: &str) -> Option { - use serde_json::Value; - - let client = reqwest::Client::new(); - let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={token}"); - - match client.get(&tokeninfo_url).send().await { - Ok(response) => { - if response.status().is_success() { - match response.json::().await { - Ok(token_info) => { - if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) { - info!(email = %email, "Successfully extracted email from Google token"); - Some(email.to_string()) - } else { - error!("Email not found in Google token info response"); - None - } - } - Err(e) => { - error!(error = ?e, "Failed to parse Google token info response"); - None - } - } - } else { - error!(status = %response.status(), "Google token validation failed"); - None - } - } - Err(e) => { - error!(error = ?e, "Failed to validate Google token"); - None - } - } -} - -/// Extracts the email from a JWT token string. -/// Supports both our internal JWT tokens and Google access tokens. -pub fn extract_email_token(token: String) -> Option { - match decode_access_token(&token) { - Ok(data) => { - Some(data.claims.sub) - } - Err(_) => { - // If it fails, it might be a Google access token - // For Google tokens, we need async validation, so we'll return None here - // and handle Google tokens separately in the calling code - error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly."); - None - } - } -} - -/// A simple helper to check if a token string looks like a JWT. -fn is_jwt(token: &str) -> bool { - let parts: Vec<_> = token.split('.').collect(); - parts.len() == 3 -} - -/// Async version of extract_email_token that can handle Google access tokens -pub async fn extract_email_token_async(token: String) -> Option { - if is_jwt(&token) && let Ok(data) = decode_access_token(&token) { - return Some(data.claims.sub); - } - - // If it's not a valid internal JWT, try to validate as Google access token - extract_email_from_google_token(&token).await -} \ No newline at end of file +use axum::http::{HeaderMap, header::AUTHORIZATION}; +use imphnen_libs::jsonwebtoken::decode_access_token; +use tracing::{error, info}; + +pub fn extract_email(headers: &HeaderMap) -> Option { + let auth_header = match headers.get(AUTHORIZATION) { + Some(h) => h, + None => { + error!("Authorization header missing in extract_email"); + return None; + } + }; + let auth_str = match auth_header.to_str() { + Ok(s) => s, + Err(e) => { + error!(error = ?e, "Failed to convert Authorization header to str in extract_email"); + return None; + } + }; + let token = match auth_str.strip_prefix("Bearer ") { + Some(t) => t, + None => { + error!( + auth_str, + "Authorization header does not start with 'Bearer ' in extract_email" + ); + return None; + } + }; + + match decode_access_token(token) { + Ok(data) => Some(data.claims.sub), + Err(_) => { + error!( + "Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly." + ); + None + } + } +} + +pub async fn extract_email_async(headers: &HeaderMap) -> Option { + let auth_header = match headers.get(AUTHORIZATION) { + Some(h) => h, + None => { + error!("Authorization header missing in extract_email_async"); + return None; + } + }; + let auth_str = match auth_header.to_str() { + Ok(s) => s, + Err(e) => { + error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async"); + return None; + } + }; + let token = match auth_str.strip_prefix("Bearer ") { + Some(t) => t, + None => { + error!( + auth_str, + "Authorization header does not start with 'Bearer ' in extract_email_async" + ); + return None; + } + }; + + match decode_access_token(token) { + Ok(data) => Some(data.claims.sub), + Err(_) => extract_email_from_google_token(token).await, + } +} + +async fn extract_email_from_google_token(token: &str) -> Option { + use serde_json::Value; + + let client = reqwest::Client::new(); + let tokeninfo_url = + format!("https://oauth2.googleapis.com/tokeninfo?access_token={token}"); + + match client.get(&tokeninfo_url).send().await { + Ok(response) => { + if response.status().is_success() { + match response.json::().await { + Ok(token_info) => { + if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) { + info!(email = %email, "Successfully extracted email from Google token"); + Some(email.to_string()) + } else { + error!("Email not found in Google token info response"); + None + } + } + Err(e) => { + error!(error = ?e, "Failed to parse Google token info response"); + None + } + } + } else { + error!(status = %response.status(), "Google token validation failed"); + None + } + } + Err(e) => { + error!(error = ?e, "Failed to validate Google token"); + None + } + } +} + +pub fn extract_email_token(token: String) -> Option { + match decode_access_token(&token) { + Ok(data) => Some(data.claims.sub), + Err(_) => { + error!( + "Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly." + ); + None + } + } +} + +fn is_jwt(token: &str) -> bool { + let parts: Vec<_> = token.split('.').collect(); + parts.len() == 3 +} + +pub async fn extract_email_token_async(token: String) -> Option { + if is_jwt(&token) + && let Ok(data) = decode_access_token(&token) + { + return Some(data.claims.sub); + } + + extract_email_from_google_token(&token).await +} diff --git a/imphnen-utils/src/extract_ip.rs b/imphnen-utils/src/extract_ip.rs index d68e4b8..fefe01a 100644 --- a/imphnen-utils/src/extract_ip.rs +++ b/imphnen-utils/src/extract_ip.rs @@ -1,144 +1,136 @@ -use axum::http::HeaderMap; - -/// Extract real client IP address from various headers commonly used in proxies -/// -/// Priority order: -/// 1. X-Forwarded-For (first IP in the list) -/// 2. X-Real-IP -/// 3. CF-Connecting-IP (Cloudflare) -/// 4. True-Client-IP (Akamai and others) -/// 5. X-Cluster-Client-IP -/// 6. Forwarded (standard header) -/// 7. Direct connection IP (if available) -pub fn extract_real_ip(headers: &HeaderMap) -> Option { - // Try different headers in priority order - if let Some(ip) = extract_from_x_forwarded_for(headers) { - return Some(ip); - } - - if let Some(ip) = extract_header_value(headers, "x-real-ip") { - return Some(ip); - } - - if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") { - return Some(ip); - } - - if let Some(ip) = extract_header_value(headers, "true-client-ip") { - return Some(ip); - } - - if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") { - return Some(ip); - } - - if let Some(ip) = extract_from_forwarded_header(headers) { - return Some(ip); - } - - None -} - -/// Extract the first IP from X-Forwarded-For header -fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option { - let header_value = headers.get("x-forwarded-for")?; - let header_str = header_value.to_str().ok()?; - - // X-Forwarded-For can contain multiple IPs separated by commas - // We take the first one (the original client IP) - header_str.split(',').next() - .map(|ip| ip.trim().to_string()) - .filter(|ip| is_valid_ip(ip)) -} - -/// Extract IP from Forwarded header (RFC 7239) -fn extract_from_forwarded_header(headers: &HeaderMap) -> Option { - let header_value = headers.get("forwarded")?; - let header_str = header_value.to_str().ok()?; - - // Parse Forwarded header: for=192.0.2.60;proto=http;by=203.0.113.43 - for part in header_str.split(';') { - if part.trim().starts_with("for=") { - let ip = part.trim().trim_start_matches("for="); - // Remove quotes and brackets if present - let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']'); - if is_valid_ip(ip) { - return Some(ip.to_string()); - } - } - } - - None -} - -/// Extract value from a specific header -fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option { - let header_value = headers.get(header_name)?; - let value_str = header_value.to_str().ok()?; - - if is_valid_ip(value_str) { - Some(value_str.to_string()) - } else { - None - } -} - -/// Basic IP validation -fn is_valid_ip(ip: &str) -> bool { - // Simple validation - check if it looks like an IP address - if ip.is_empty() || ip == "unknown" || ip == "undefined" { - return false; - } - - // Check for IPv4 pattern - if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.') { - return true; - } - - // Check for IPv6 pattern (simplified) - if ip.contains(':') { - return true; - } - - false -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - #[test] - fn test_extract_from_x_forwarded_for() { - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1, 10.0.0.1")); - - assert_eq!(extract_from_x_forwarded_for(&headers), Some("192.168.1.1".to_string())); - } - - #[test] - fn test_extract_from_forwarded_header() { - let mut headers = HeaderMap::new(); - headers.insert("forwarded", HeaderValue::from_static("for=192.168.1.1;proto=https")); - - assert_eq!(extract_from_forwarded_header(&headers), Some("192.168.1.1".to_string())); - } - - #[test] - fn test_extract_real_ip_priority() { - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1")); - headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1")); - - // Should prefer x-forwarded-for - assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string())); - } - - #[test] - fn test_invalid_ip_rejection() { - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-for", HeaderValue::from_static("unknown")); - - assert_eq!(extract_real_ip(&headers), None); - } -} \ No newline at end of file +use axum::http::HeaderMap; + +pub fn extract_real_ip(headers: &HeaderMap) -> Option { + if let Some(ip) = extract_from_x_forwarded_for(headers) { + return Some(ip); + } + + if let Some(ip) = extract_header_value(headers, "x-real-ip") { + return Some(ip); + } + + if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") { + return Some(ip); + } + + if let Some(ip) = extract_header_value(headers, "true-client-ip") { + return Some(ip); + } + + if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") { + return Some(ip); + } + + if let Some(ip) = extract_from_forwarded_header(headers) { + return Some(ip); + } + + None +} + +fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option { + let header_value = headers.get("x-forwarded-for")?; + let header_str = header_value.to_str().ok()?; + + header_str + .split(',') + .next() + .map(|ip| ip.trim().to_string()) + .filter(|ip| is_valid_ip(ip)) +} + +fn extract_from_forwarded_header(headers: &HeaderMap) -> Option { + let header_value = headers.get("forwarded")?; + let header_str = header_value.to_str().ok()?; + + for part in header_str.split(';') { + if part.trim().starts_with("for=") { + let ip = part.trim().trim_start_matches("for="); + let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']'); + if is_valid_ip(ip) { + return Some(ip.to_string()); + } + } + } + + None +} + +fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option { + let header_value = headers.get(header_name)?; + let value_str = header_value.to_str().ok()?; + + if is_valid_ip(value_str) { + Some(value_str.to_string()) + } else { + None + } +} + +fn is_valid_ip(ip: &str) -> bool { + if ip.is_empty() || ip == "unknown" || ip == "undefined" { + return false; + } + + if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.') + { + return true; + } + + if ip.contains(':') { + return true; + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + #[test] + fn test_extract_from_x_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("192.168.1.1, 10.0.0.1"), + ); + + assert_eq!( + extract_from_x_forwarded_for(&headers), + Some("192.168.1.1".to_string()) + ); + } + + #[test] + fn test_extract_from_forwarded_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "forwarded", + HeaderValue::from_static("for=192.168.1.1;proto=https"), + ); + + assert_eq!( + extract_from_forwarded_header(&headers), + Some("192.168.1.1".to_string()) + ); + } + + #[test] + fn test_extract_real_ip_priority() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1")); + headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1")); + + assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string())); + } + + #[test] + fn test_invalid_ip_rejection() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("unknown")); + + assert_eq!(extract_real_ip(&headers), None); + } +} diff --git a/imphnen-utils/src/generate_date.rs b/imphnen-utils/src/generate_date.rs index bc4497b..313a7d3 100644 --- a/imphnen-utils/src/generate_date.rs +++ b/imphnen-utils/src/generate_date.rs @@ -1,30 +1,27 @@ -use tracing::{info}; -use chrono::{DateTime, Utc}; - -/// Returns the current UTC date/time as an RFC3339 string. -pub fn get_iso_date() -> String { - info!("get_iso_date called"); - let now: DateTime = Utc::now(); - let date_str = now.to_rfc3339(); - info!(date_str = %date_str, "get_iso_date returning RFC3339 date string"); - date_str -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::DateTime; - - #[test] - fn test_get_iso_date() { - let date_str = get_iso_date(); - // Should be valid RFC3339 - let parsed = DateTime::parse_from_rfc3339(&date_str); - assert!(parsed.is_ok()); - // Should be recent (within last second) - let now = Utc::now(); - let parsed = parsed.unwrap().with_timezone(&Utc); - let diff = (now - parsed).num_milliseconds().abs(); - assert!(diff < 1000); // Within 1 second - } -} +use chrono::{DateTime, Utc}; +use tracing::info; + +pub fn get_iso_date() -> String { + info!("get_iso_date called"); + let now: DateTime = Utc::now(); + let date_str = now.to_rfc3339(); + info!(date_str = %date_str, "get_iso_date returning RFC3339 date string"); + date_str +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::DateTime; + + #[test] + fn test_get_iso_date() { + let date_str = get_iso_date(); + let parsed = DateTime::parse_from_rfc3339(&date_str); + assert!(parsed.is_ok()); + let now = Utc::now(); + let parsed = parsed.unwrap().with_timezone(&Utc); + let diff = (now - parsed).num_milliseconds().abs(); + assert!(diff < 1000); + } +} diff --git a/imphnen-utils/src/generate_otp.rs b/imphnen-utils/src/generate_otp.rs index 9896b25..7c5a095 100644 --- a/imphnen-utils/src/generate_otp.rs +++ b/imphnen-utils/src/generate_otp.rs @@ -1,87 +1,80 @@ -//! OTP generation utilities with time-based expiration and secure hashing. -//! -//! This module provides functionality to generate one-time passwords (OTPs) with -//! a 5-minute expiration time and SHA256 hashing for secure storage and validation, -//! preventing replay attacks. - -use rand::{Rng, rng}; -use sha2::{Sha256, Digest}; -use chrono::{DateTime, Utc, Duration}; - -/// Represents an OTP with its code, hashed value and expiration time -#[derive(Debug, Clone)] -pub struct OtpData { - pub code: u32, - pub hash: String, - pub expires_at: DateTime, -} - -pub struct OtpManager; - -impl OtpManager { - /// Generates a new OTP with a 5-minute expiration and SHA256 hash for secure storage - pub fn generate_otp() -> OtpData { - let code = rng().random_range(100_000..1_000_000); - let otp_str = code.to_string(); - let mut hasher = Sha256::new(); - hasher.update(otp_str.as_bytes()); - let hash = format!("{:x}", hasher.finalize()); - let expires_at = Utc::now() + Duration::minutes(5); - OtpData { code, hash, expires_at } - } - - /// Validates the user-provided OTP against the stored OTP data - /// Checks both hash match and expiration - pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool { - if Utc::now() > stored.expires_at { - return false; - } - let user_otp_str = user_otp.to_string(); - let mut hasher = Sha256::new(); - hasher.update(user_otp_str.as_bytes()); - let user_hash = format!("{:x}", hasher.finalize()); - user_hash == stored.hash - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_otp() { - let otp = OtpManager::generate_otp(); - assert!(otp.code >= 100_000 && otp.code < 1_000_000); - assert!(!otp.hash.is_empty()); - assert!(otp.expires_at > Utc::now()); - assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5)); - } - - #[test] - fn test_validate_otp_valid() { - let otp = OtpManager::generate_otp(); - assert!(OtpManager::validate_otp(&otp, otp.code)); - } - - #[test] - fn test_validate_otp_invalid_code() { - let otp = OtpManager::generate_otp(); - assert!(!OtpManager::validate_otp(&otp, 123456)); // Wrong code - } - - #[test] - fn test_validate_otp_expired() { - let mut otp = OtpManager::generate_otp(); - otp.expires_at = Utc::now() - chrono::Duration::seconds(1); // Expired - assert!(!OtpManager::validate_otp(&otp, otp.code)); - } - - #[test] - fn test_otp_uniqueness() { - let otp1 = OtpManager::generate_otp(); - let otp2 = OtpManager::generate_otp(); - // Codes should be different (high probability) - assert_ne!(otp1.code, otp2.code); - assert_ne!(otp1.hash, otp2.hash); - } -} +use chrono::{DateTime, Duration, Utc}; +use rand::{Rng, rng}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone)] +pub struct OtpData { + pub code: u32, + pub hash: String, + pub expires_at: DateTime, +} + +pub struct OtpManager; + +impl OtpManager { + pub fn generate_otp() -> OtpData { + let code = rng().random_range(100_000..1_000_000); + let otp_str = code.to_string(); + let mut hasher = Sha256::new(); + hasher.update(otp_str.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + let expires_at = Utc::now() + Duration::minutes(5); + OtpData { + code, + hash, + expires_at, + } + } + + pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool { + if Utc::now() > stored.expires_at { + return false; + } + let user_otp_str = user_otp.to_string(); + let mut hasher = Sha256::new(); + hasher.update(user_otp_str.as_bytes()); + let user_hash = format!("{:x}", hasher.finalize()); + user_hash == stored.hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_otp() { + let otp = OtpManager::generate_otp(); + assert!(otp.code >= 100_000 && otp.code < 1_000_000); + assert!(!otp.hash.is_empty()); + assert!(otp.expires_at > Utc::now()); + assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5)); + } + + #[test] + fn test_validate_otp_valid() { + let otp = OtpManager::generate_otp(); + assert!(OtpManager::validate_otp(&otp, otp.code)); + } + + #[test] + fn test_validate_otp_invalid_code() { + let otp = OtpManager::generate_otp(); + assert!(!OtpManager::validate_otp(&otp, 123456)); + } + + #[test] + fn test_validate_otp_expired() { + let mut otp = OtpManager::generate_otp(); + otp.expires_at = Utc::now() - chrono::Duration::seconds(1); + assert!(!OtpManager::validate_otp(&otp, otp.code)); + } + + #[test] + fn test_otp_uniqueness() { + let otp1 = OtpManager::generate_otp(); + let otp2 = OtpManager::generate_otp(); + assert_ne!(otp1.code, otp2.code); + assert_ne!(otp1.hash, otp2.hash); + } +} diff --git a/imphnen-utils/src/lib.rs b/imphnen-utils/src/lib.rs index b12fe28..721764f 100644 --- a/imphnen-utils/src/lib.rs +++ b/imphnen-utils/src/lib.rs @@ -1,18 +1,20 @@ -pub mod csrf_token; -pub mod pagination; -pub mod errors; -pub mod extract_email; -pub mod extract_ip; -pub mod generate_date; -pub mod generate_otp; -pub mod logger; -pub mod response_format; -pub mod sanitization; - -// Re-export commonly used functions -pub use extract_email::{extract_email, extract_email_async}; -pub use extract_ip::extract_real_ip; -pub use generate_date::get_iso_date; -pub use response_format::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage}; -pub use sanitization::{sanitize_html, sanitize_dangerous_patterns, sanitize_filename, sanitize_user_text, normalize_whitespace, sanitize_email, sanitize_url}; -pub use errors::{AppError, Result}; +pub mod csrf_token; +pub mod errors; +pub mod extract_email; +pub mod extract_ip; +pub mod generate_date; +pub mod generate_otp; +pub mod logger; +pub mod pagination; +pub mod response_format; +pub mod sanitization; + +pub use errors::{AppError, Result}; +pub use extract_email::{extract_email, extract_email_async}; +pub use extract_ip::extract_real_ip; +pub use generate_date::get_iso_date; +pub use response_format::{ApiCreated, ApiMessage, ApiPaginated, ApiSuccess}; +pub use sanitization::{ + normalize_whitespace, sanitize_dangerous_patterns, sanitize_email, + sanitize_filename, sanitize_html, sanitize_url, sanitize_user_text, +}; diff --git a/imphnen-utils/src/logger.rs b/imphnen-utils/src/logger.rs index f697c01..3cc08ea 100644 --- a/imphnen-utils/src/logger.rs +++ b/imphnen-utils/src/logger.rs @@ -1,18 +1,12 @@ -use dotenvy::dotenv; -use tracing_subscriber::{EnvFilter, fmt}; - -/// Initializes the logger using tracing and tracing-subscriber. -/// Loads environment variables from `.env` and sets log level from `RUST_LOG`. -pub fn init_logger() { - dotenv().ok(); - - - // Set up the tracing subscriber with EnvFilter from RUST_LOG - let filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new("warn")) - .unwrap(); - - fmt() - .with_env_filter(filter) - .init(); -} +use dotenvy::dotenv; +use tracing_subscriber::{EnvFilter, fmt}; + +pub fn init_logger() { + dotenv().ok(); + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("warn")) + .expect("valid log filter"); + + fmt().with_env_filter(filter).init(); +} diff --git a/imphnen-utils/src/pagination.rs b/imphnen-utils/src/pagination.rs index 0aa18bd..ae7c83a 100644 --- a/imphnen-utils/src/pagination.rs +++ b/imphnen-utils/src/pagination.rs @@ -1,4 +1,4 @@ pub use paginator_axum::PaginationQuery; -pub use paginator_rs::{PaginatorBuilder, PaginationParams}; -pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; +pub use paginator_rs::{PaginationParams, PaginatorBuilder}; pub use paginator_sea_orm::paginate_with_sort; +pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta}; diff --git a/imphnen-utils/src/response_format.rs b/imphnen-utils/src/response_format.rs index 05e8da3..fc859d0 100644 --- a/imphnen-utils/src/response_format.rs +++ b/imphnen-utils/src/response_format.rs @@ -1,91 +1,110 @@ +use crate::errors::AppError; use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, + Json, + http::StatusCode, + response::{IntoResponse, Response}, }; +use imphnen_entities::error_dto::error::Error; +use paginator_utils::PaginatorResponse; use serde::Serialize; use serde_json::json; -use paginator_utils::PaginatorResponse; -use imphnen_entities::error_dto::error::Error; -use crate::errors::AppError; impl From for AppError { - fn from(error: Error) -> Self { - match error { - Error::Db(detail) => AppError::InternalServerError(format!("Database error: {detail}")), - Error::Anyhow(detail) => AppError::InternalServerError(format!("Internal server error: {detail}")), - Error::StatusCode(status) => AppError::InternalServerError(format!("HTTP error: {status}")), - Error::Auth(detail) => AppError::AuthenticationError(format!("Authentication error: {detail}")), - Error::Validation(detail) => AppError::ValidationError(format!("Validation error: {detail}")), - } - } + fn from(error: Error) -> Self { + match error { + Error::Db(detail) => { + AppError::InternalServerError(format!("Database error: {detail}")) + } + Error::Anyhow(detail) => { + AppError::InternalServerError(format!("Internal server error: {detail}")) + } + Error::StatusCode(status) => { + AppError::InternalServerError(format!("HTTP error: {status}")) + } + Error::Auth(detail) => { + AppError::AuthenticationError(format!("Authentication error: {detail}")) + } + Error::Validation(detail) => { + AppError::ValidationError(format!("Validation error: {detail}")) + } + } + } } pub struct ApiSuccess(pub T); impl IntoResponse for ApiSuccess { - fn into_response(self) -> Response { - ( - StatusCode::OK, - Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })), - ) - .into_response() - } + fn into_response(self) -> Response { + ( + StatusCode::OK, + Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })), + ) + .into_response() + } } pub struct ApiCreated(pub T); impl IntoResponse for ApiCreated { - fn into_response(self) -> Response { - ( - StatusCode::CREATED, - Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })), - ) - .into_response() - } + fn into_response(self) -> Response { + ( + StatusCode::CREATED, + Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })), + ) + .into_response() + } } pub struct ApiPaginated(pub PaginatorResponse); impl IntoResponse for ApiPaginated { - fn into_response(self) -> Response { - ( - StatusCode::OK, - Json(json!({ - "data": self.0.data, - "meta": self.0.meta, - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() - } + fn into_response(self) -> Response { + ( + StatusCode::OK, + Json(json!({ + "data": self.0.data, + "meta": self.0.meta, + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() + } } pub struct ApiMessage { - pub status: StatusCode, - pub message: String, + pub status: StatusCode, + pub message: String, } impl ApiMessage { - pub fn ok(message: impl Into) -> Self { - Self { status: StatusCode::OK, message: message.into() } - } + pub fn ok(message: impl Into) -> Self { + Self { + status: StatusCode::OK, + message: message.into(), + } + } - pub fn created(message: impl Into) -> Self { - Self { status: StatusCode::CREATED, message: message.into() } - } + pub fn created(message: impl Into) -> Self { + Self { + status: StatusCode::CREATED, + message: message.into(), + } + } - pub fn new(status: StatusCode, message: impl Into) -> Self { - Self { status, message: message.into() } - } + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + } + } } impl IntoResponse for ApiMessage { - fn into_response(self) -> Response { - ( - self.status, - Json(json!({ "message": self.message, "version": env!("CARGO_PKG_VERSION") })), - ) - .into_response() - } + fn into_response(self) -> Response { + ( + self.status, + Json(json!({ "message": self.message, "version": env!("CARGO_PKG_VERSION") })), + ) + .into_response() + } } diff --git a/imphnen-utils/src/sanitization.rs b/imphnen-utils/src/sanitization.rs index 7377000..458e881 100644 --- a/imphnen-utils/src/sanitization.rs +++ b/imphnen-utils/src/sanitization.rs @@ -1,208 +1,91 @@ -//! Input sanitization utilities for security -//! -//! This module provides utilities to sanitize user input and prevent -//! common security vulnerabilities like XSS, HTML injection, SQL injection, etc. -//! Specifically optimized for PostgreSQL backend (SurrealDB migration complete). - -use regex::Regex; -use std::sync::LazyLock; - -// Note: HTML escaping is done via char-by-char mapping for better performance -// No regex needed for basic HTML entity escaping - -/// PostgreSQL-specific SQL injection patterns -/// -/// Comprehensive pattern set targeting PostgreSQL vulnerabilities while maintaining -/// compatibility with standard SQL injection prevention -static SQL_INJECTION_PATTERNS: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|truncate|vacuum|analyze|reindex|cluster|copy|exec|script|javascript|onerror|onload|with|from|where|join|group by|order by|limit|offset|having|distinct|into|values|union all|union distinct|::|%|:=|current_user|session_user|user|version|current_date|current_time|now|pg_sleep|pg_user|pg_database|pg_tables|pg_columns|chr|ascii|substring|position|strpos|concat|concat_ws|string_agg|array_agg|array_to_string|string_to_array)").unwrap() -}); - -/// Path traversal patterns -static PATH_TRAVERSAL_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"\.\.(/|\\)").unwrap() -}); - -/// Sanitize HTML by escaping special characters -/// -/// # Example -/// ```rust -/// use imphnen_utils::sanitize_html; -/// -/// let dirty = ""; -/// let clean = sanitize_html(dirty); -/// assert_eq!(clean, "<script>alert('xss')</script>"); -/// ``` -pub fn sanitize_html(input: &str) -> String { - input - .chars() - .map(|c| match c { - '<' => "<".to_string(), - '>' => ">".to_string(), - '"' => """.to_string(), - '\'' => "'".to_string(), - '&' => "&".to_string(), - _ => c.to_string(), - }) - .collect() -} - -/// Sanitize string to prevent SQL injection and other dangerous patterns -/// -/// PostgreSQL-optimized sanitization that removes potentially dangerous patterns -/// while preserving legitimate user input where possible -pub fn sanitize_dangerous_patterns(input: &str) -> String { - // First pass: Remove SQL injection patterns - let without_sql_injection = SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]"); - - // Second pass: Additional PostgreSQL-specific protection - let without_postgres_specific = without_sql_injection.replace(";--", ";[FILTERED]"); - - without_postgres_specific.to_owned() -} - -/// Check if string contains path traversal attempts -pub fn contains_path_traversal(input: &str) -> bool { - PATH_TRAVERSAL_REGEX.is_match(input) -} - -/// Sanitize a string for safe usage in file names -/// -/// Removes or replaces characters that could cause issues in file systems -pub fn sanitize_filename(input: &str) -> String { - input - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - c if c.is_control() => '_', - c => c, - }) - .collect() -} - -/// Sanitize user input text (removes HTML and dangerous patterns) -/// -/// Use this for fields like names, descriptions, bios, etc. -pub fn sanitize_user_text(input: &str) -> String { - let without_html = sanitize_html(input); - sanitize_dangerous_patterns(&without_html) -} - -/// Trim and normalize whitespace in a string -pub fn normalize_whitespace(input: &str) -> String { - input - .split_whitespace() - .collect::>() - .join(" ") - .trim() - .to_string() -} - -/// Validate and sanitize email format -pub fn sanitize_email(email: &str) -> Option { - let trimmed = email.trim().to_lowercase(); - - // Basic email validation - if trimmed.contains('@') && trimmed.contains('.') { - Some(trimmed) - } else { - None - } -} - -/// Sanitize URL to prevent javascript: and data: schemes -pub fn sanitize_url(url: &str) -> Option { - let trimmed = url.trim(); - - // Block dangerous URL schemes - let lower = trimmed.to_lowercase(); - if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("vbscript:") { - return None; - } - - // Allow http, https, and relative URLs - if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("/") { - Some(trimmed.to_string()) - } else { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sanitize_html() { - assert_eq!( - sanitize_html(""), - "<script>alert('xss')</script>" - ); - assert_eq!( - sanitize_html("Normal text"), - "Normal text" - ); - } - - #[test] - fn test_sanitize_dangerous_patterns() { - // Test basic SQL injection - assert!(sanitize_dangerous_patterns("SELECT * FROM users").contains("[FILTERED]")); - - // Test PostgreSQL-specific patterns - assert!(sanitize_dangerous_patterns("SELECT current_user;").contains("[FILTERED]")); - assert!(sanitize_dangerous_patterns("SELECT version();").contains("[FILTERED]")); - assert!(sanitize_dangerous_patterns("SELECT 'a'::text;").contains("[FILTERED]")); - assert!(sanitize_dangerous_patterns("SELECT 'a'%'b';").contains("[FILTERED]")); - - // Test comment injection - assert!(sanitize_dangerous_patterns("'; DROP TABLE users; --").contains("[FILTERED]")); - - // Test legitimate input remains unchanged - assert_eq!( - sanitize_dangerous_patterns("Normal search query using 'quotes' and ; semicolons"), - "Normal search query using 'quotes' and ; semicolons" - ); - - // Test PostgreSQL function filtering - assert!(sanitize_dangerous_patterns("SELECT pg_sleep(10);").contains("[FILTERED]")); - assert!(sanitize_dangerous_patterns("SELECT concat('a', 'b');").contains("[FILTERED]")); - } - - #[test] - fn test_path_traversal() { - assert!(contains_path_traversal("../../../etc/passwd")); - assert!(contains_path_traversal("..\\windows\\system32")); - assert!(!contains_path_traversal("normal/path/to/file")); - } - - #[test] - fn test_sanitize_filename() { - assert_eq!( - sanitize_filename("file.txt"), - "file_name_.txt" - ); - assert_eq!( - sanitize_filename("normal_file.pdf"), - "normal_file.pdf" - ); - } - - #[test] - fn test_sanitize_url() { - assert_eq!( - sanitize_url("https://example.com"), - Some("https://example.com".to_string()) - ); - assert_eq!(sanitize_url("javascript:alert('xss')"), None); - assert_eq!(sanitize_url("data:text/html,"), None); - } - - #[test] - fn test_normalize_whitespace() { - assert_eq!( - normalize_whitespace(" multiple spaces "), - "multiple spaces" - ); - } -} +use regex::Regex; +use std::sync::LazyLock; + +static SQL_INJECTION_PATTERNS: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|truncate|vacuum|analyze|reindex|cluster|copy|exec|script|javascript|onerror|onload|with|from|where|join|group by|order by|limit|offset|having|distinct|into|values|union all|union distinct|::|%|:=|current_user|session_user|user|version|current_date|current_time|now|pg_sleep|pg_user|pg_database|pg_tables|pg_columns|chr|ascii|substring|position|strpos|concat|concat_ws|string_agg|array_agg|array_to_string|string_to_array)").expect("valid sql injection regex") +}); + +static PATH_TRAVERSAL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\.\.(/|\\)").expect("valid path traversal regex")); + +pub fn sanitize_html(input: &str) -> String { + input + .chars() + .map(|c| match c { + '<' => "<".to_string(), + '>' => ">".to_string(), + '"' => """.to_string(), + '\'' => "'".to_string(), + '&' => "&".to_string(), + _ => c.to_string(), + }) + .collect() +} + +pub fn sanitize_dangerous_patterns(input: &str) -> String { + let without_sql_injection = + SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]"); + let without_postgres_specific = + without_sql_injection.replace(";--", ";[FILTERED]"); + without_postgres_specific.to_owned() +} + +pub fn contains_path_traversal(input: &str) -> bool { + PATH_TRAVERSAL_REGEX.is_match(input) +} + +pub fn sanitize_filename(input: &str) -> String { + input + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect() +} + +pub fn sanitize_user_text(input: &str) -> String { + let without_html = sanitize_html(input); + sanitize_dangerous_patterns(&without_html) +} + +pub fn normalize_whitespace(input: &str) -> String { + input + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +pub fn sanitize_email(email: &str) -> Option { + let trimmed = email.trim().to_lowercase(); + + if trimmed.contains('@') && trimmed.contains('.') { + Some(trimmed) + } else { + None + } +} + +pub fn sanitize_url(url: &str) -> Option { + let trimmed = url.trim(); + + let lower = trimmed.to_lowercase(); + if lower.starts_with("javascript:") + || lower.starts_with("data:") + || lower.starts_with("vbscript:") + { + return None; + } + + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("/") + { + Some(trimmed.to_string()) + } else { + None + } +}