Compare commits

..
Author SHA1 Message Date
asepharyana 7a5c560347 docs: materi + AI agent RAG (finding #6) 2026-08-05 17:26:48 +07:00
asepharyana 164c1860da feat(dimentorin): AI agent RAG (embedding materi -> Qdrant + chat via 9router)
- ENV: AI_LLM_BASE_URL/API_KEY/MODEL, AI_EMBEDDING_MODEL (gemini-embedding-001), QDRANT_URL
- ai_agent module: chunking materi, embed_text + chat_completion (9router, SSE parse), Qdrant repo (dimentorin_materi collection, 3072d cosine)
- routes: POST /ai/chat (RAG answer + sources), POST /ai/materials/{id}/index, POST /ai/reindex
- e2e verified: reindex 3 chunks; chat 'ownership' -> materi Rust paling relevan 0.88; chat 'endpoint axum' -> materi Axum 0.82; jawaban gronding konteks
2026-08-05 17:15:11 +07:00
asepharyana ac8177d87e feat(dimentorin): materials module (CRUD konten mentoring, public+protected routes)
- app_materials table: mentor_id, title, slug, category, description, content
- domain/application/infrastructure pola articles: repo postgres, service, DTO ZodValidate
- routes: GET /materials (public, published only), GET /materials/{id|slug|categories}, POST/PUT/DELETE (auth, author-only)
- verified e2e: create 3 materi sebagai mentor, list, slug, categories
2026-08-05 17:01:59 +07:00
asepharyana b4a9972ad7 docs: payment lifecycle complete (auto-paid refresh + dashboards) 2026-08-05 15:58:47 +07:00
asepharyana 9af1c2d163 feat(dimentorin): session mentor can confirm their payments (not just admins)
- mentor can confirm payments of sessions they own (see transfer arrive)
- admin/Admin Pembayaran still allowed; others forbidden
- e2e verified: mentor confirm QRIS payment -> paid + session confirmed
2026-08-05 15:56:35 +07:00
asepharyana 495e043088 feat(dimentorin): GET /payments/session/{id} for mentee/mentor dashboard 2026-08-05 15:24:41 +07:00
asepharyana 390f46b0e7 feat(dimentorin): payment status refresh via Midtrans + auto-paid auto-confirm
- store provider_order_id (Midtrans order id) on payment create
- POST /payments/{id}/refresh: query Midtrans v2/{order}/status (settlement/capture → paid + confirm session)
- fix: status URL must end with /status; force Accept-Encoding: identity (istio gzip mangling)
- e2e verified: simulated VA payment in sandbox → refresh → paid + session confirmed
2026-08-05 15:15:43 +07:00
asepharyana aa0b659b48 docs: SMTP fixed + Midtrans provider status in audit findings 2026-08-05 13:38:02 +07:00
asepharyana 9214b32139 feat(dimentorin): Midtrans payment provider (sandbox VA + QRIS)
- Env: MIDTRANS_MERCHANT_ID / CLIENT_KEY / SERVER_KEY (masked in Debug)
- midtrans_provider: Core API v2/charge for bank_transfer (VA BCA) and qris (gopay)
- PaymentServiceImpl: when MIDTRANS_MERCHANT_ID set -> provider=midtrans, external_ref = real VA number / QR string; falls back to manual refs otherwise
- SMTP: Google app password working (send-otp 200, OTP stored)
- E2E verified (sandbox): VA externalRef=47329093597744219189188, QRIS qr_string EMVCo, confirm->paid
- Credentials stored in BWS (dimentorin_midtrans_*, dimentorin_smtp_*)
2026-08-05 13:34:59 +07:00
asepharyana 2ea6cd3d17 docs: payment flow status in audit findings 2026-08-05 09:32:37 +07:00
asepharyana 6d5af29de8 feat(dimentorin): confirm payment auto-confirms linked session
- confirm_payment now transitions linked session pending->confirmed
- e2e verified: book -> create payment -> admin confirm -> session confirmed + payment paid
2026-08-05 09:32:04 +07:00
asepharyana 444c98074f feat(dimentorin): payment module - gateway-agnostic VA/QRIS with admin confirm
- app_payments table + PaymentEntity (amount from mentor mentoring_rate + service fee 2000)
- PaymentRepository (postgres) + PaymentServiceImpl (create/get/confirm/list)
- Routes: POST /payments/sessions/{id}/create, GET /payments/me, GET /payments/{id}, POST /payments/{id}/confirm
- confirm guarded by Admin/Admin Pembayaran role; ownership guard mentee-only view
- provider=manual default (swap midtrans/xendit later), e2e verified: VA+QRIS create, confirm 200, re-confirm 409, non-admin 403, foreign payment 403
2026-08-05 09:10:39 +07:00
asepharyana c6ed5c5c19 fix(dimentorin): verify-email validates OTP before activating user
- new app_otp_cache table + OtpCache entity (ResourceEnum::OtpCache)
- PostgresOtpRepository upsert/find/delete keyed by email
- register/resend persist otp_hash+expiry after email sent (no orphan OTP)
- verify_email validates via OtpManager::validate_otp_hash, single-use delete
- 8 unit tests pass, e2e verified: wrong OTP 400, correct OTP 200
2026-08-04 23:35:34 +07:00
asepharyana 3692b81324 feat(dimentorin): mentor stats endpoint public - total sessions, unique mentees, avg rating
- GET /mentors/{id}/stats (public, no auth)
- resolve mentor profile id -> user id in get_mentor_sessions (FK uses app_users.id)
2026-08-04 23:09:08 +07:00
asepharyana 9b5efeff87 fix(dimentorin): register mentor flow - zod regex attr, uuid id override in user & mentor repos
- Remove broken zod_rs regex() attribute (always fails, blocks password validation)
- user repo create: honor entity.id instead of silently generating new v4 (broke FK mentor.user_id)
- mentor repo create: set id explicitly (was NULL not-null violation)
2026-08-04 20:03:08 +07:00
asepharyana 1d34d29b0b feat(dimentorin): complete articles module - HTTP layer, routes, categories fix, seed 2026-08-04 19:16:45 +07:00
asepharyana 7d1078f52a fix(dimentorin): resolve mentor profile id to user id before booking session 2026-08-04 18:34:13 +07:00
asepharyana 67d3f2fced feat(dimentorin): add public mentor list & detail endpoints (verified only)
Nix Build & Deploy / build (push) Canceled after 0s
Nix Build & Deploy / deploy (push) Canceled after 0s
2026-08-04 17:55:59 +07:00
maulanasdqnandClaude Opus 4.6 b68e362a02 fix: extract user_id (UUID) from JWT instead of email in session handlers
Nix Build & Deploy / build (push) Canceled after 0s
Nix Build & Deploy / deploy (push) Canceled after 0s
All session handlers were passing the user's email to service methods
that expected a UUID, causing "Invalid user ID" errors. Now extracts
user_id from JWT claims instead.

Fixed: get_my_sessions, post_book_session, put_update_session_status,
post_submit_feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:00:32 +07:00
maulanasdqnandClaude Opus 4.6 db44c5a51f feat: add roadmap CRUD module to CMS
New endpoints under /v1/landing/cms:
- GET /roadmap — public, paginated list
- GET /roadmap/detail/{id} — public, detail
- POST /roadmap/vote/{id} — public, increment votes
- POST /roadmap/create — protected (Administrator)
- PATCH /roadmap/update/{id} — protected (Administrator)
- DELETE /roadmap/delete/{id} — protected (Administrator)

Table: roadmap_items (id, title, description, status, votes, is_deleted, created_at, updated_at)
Status values: upcoming, in_progress, completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:42:15 +07:00
maulanasdqnandClaude Opus 4.6 6570bbf752 fix: add all frontend origins to default CORS allowed list
Added backoffice, hackathon, qr, and infra subdomains to the default
CORS_ALLOWED_ORIGINS. Previously only gacha, imphnen.dev, and
dimentorin were allowed, causing Network Error on other apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:04:48 +07:00
maulanasdqnandClaude Opus 4.6 5667a0d608 feat: add include query param and sessions to unified /me endpoint
GET /v1/iam/users/me now supports ?include=hackathon,qr,mentor,sessions
to control which module profiles are returned. Omitting the param
returns all modules. Also added dimentorin sessions data.

Examples:
  /v1/iam/users/me                        → all modules
  /v1/iam/users/me?include=hackathon      → only hackathon profile
  /v1/iam/users/me?include=mentor,sessions → mentor + sessions only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:28:55 +07:00
maulanasdqnandClaude Opus 4.6 a4bbc73c7e feat: unify /me endpoint to aggregate all module profiles
GET /v1/iam/users/me now returns hackathon, QR, and mentor profiles
alongside the core IAM user data. Module-specific profiles are
included as optional fields when the user exists in those modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:47:27 +07:00
maulanasdqnandClaude Opus 4.6 729335014f fix: use correct Swagger security scheme name across all modules
Changed "bearer_auth" to "Bearer" in utoipa security annotations
for QR and Hackathon handlers to match the registered SecurityAddon
scheme name. Fixes Swagger UI not sending Authorization header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:11:01 +07:00
maulanasdqnandClaude Opus 4.6 852e9652ee fix: correct middleware layer ordering in QR routes
Same issue as hackathon routes - qr_auth_middleware needs Arc<PgPool>
from Extension but was outermost layer. Swapped order so pool is
available when middleware runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:32:39 +07:00
maulanasdqnandClaude Opus 4.6 f225ee8969 fix: correct middleware layer ordering in hackathon routes
The hackathon_auth_middleware requires Arc<PgPool> from Extension,
but was applied as the outermost layer (running before Extension(pool)
was injected). Swapped layer order so pool Extension is outermost,
making it available when the auth middleware runs.

Fixes 500 errors on all /v1/hackathon/* authenticated endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:21:30 +07:00
maulanasdqnandClaude Opus 4.6 4ee00f1fe5 fix: resolve permission UUIDs to names in role DTO
The build_role_dto function was using permission UUIDs as both id and
name fields. Now resolves UUIDs to human-readable names via
PermissionsEnum::iter() lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:52:50 +07:00
maulanasdqnandClaude Sonnet 4.6 d014a94ea4 feat: add response examples to all hackathon and QR swagger endpoints
Each endpoint now shows a realistic JSON example (data wrapper + version)
so developers can see the exact response shape without calling the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:37:06 +07:00
maulanasdqnandClaude Sonnet 4.6 e1bc336baa feat: add utoipa path annotations to hackathon and QR handlers, register in swagger
All hackathon endpoints (/v1/hackathon/*) and QR endpoints (/v1/qr/*)
are now visible in the Swagger UI at /docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 01:22:38 +07:00
maulanasdqnandClaude Sonnet 4.6 8144ab40e9 fix: update remaining /v1/users path to /v1/iam/users in swagger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:46:56 +07:00
maulanasdqnandClaude Sonnet 4.6 76201d8d3e feat: add module prefixes to all routes and load all modules in swagger
Route structure:
  /v1/iam/auth/*         (was /v1/auth/*)
  /v1/iam/users/*        (was /v1/users/*)
  /v1/iam/roles/*        (was /v1/roles/*)
  /v1/iam/permissions/*  (was /v1/permissions/*)
  /v1/landing/cms/events/*        (was /v1/cms/landing/events/*)
  /v1/landing/cms/testimonials/*  (was /v1/cms/landing/testimonials/*)
  /v1/dimentorin/mentors/*   (was /v1/mentors/*)
  /v1/dimentorin/sessions/*  (was /v1/sessions/*)
  /v1/gacha/*            (unchanged)
  /v1/hackathon/*        (unchanged)
  /v1/qr/*               (unchanged)

Swagger: add gacha_credits endpoints which were missing from OpenAPI spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:26:29 +07:00
maulanasdqnandClaude Sonnet 4.6 c38b718eb4 fix: migrate all route path params from :param to {param} syntax
Axum 0.8+ requires {param} capture group syntax.
:param style was causing runtime panic on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:56:59 +07:00
maulanasdqnandClaude Sonnet 4.6 222495472a ci: allow deploy on workflow_dispatch trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:26:38 +07:00
maulanasdqnandClaude Sonnet 4.6 12db8cc6b4 ci: fix SSH pipe drop on long nixos-rebuild, add workflow_dispatch
- Add ServerAliveInterval=30/ServerAliveCountMax=40 to prevent broken
  pipe during nixos-rebuild switch (up to 20min keepalive)
- Remove continue-on-error so deploy failures are visible
- Add workflow_dispatch trigger for manual reruns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:44:10 +07:00
maulanasdqnandClaude Sonnet 4.6 331a4a4e88 feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS
- 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 <noreply@anthropic.com>
2026-04-02 22:29:08 +07:00
maulanasdqnandClaude Sonnet 4.6 2ae43b3bcc refactor: centralize auth system across all modules
All modules now use the main IAM JWT (ACCESS_TOKEN_SECRET) for authentication,
removing three separate auth systems (hackathon Supabase, hackathon JWT, QR JWT).

Changes:
- hackathon: replace HackathonJwtService with decode_access_token() from imphnen-libs
  - remove entire src/auth/ (Supabase signup/login/GitHub/forgot-reset)
  - remove common/hackathon_jwt.rs, common/supabase_client.rs
  - remove Supabase from HackathonConfig (JWT, GitHub OAuth, Supabase anon/service keys)
  - replace Supabase Storage with MinioService from imphnen-libs
  - all route jwt params removed; hackathon_router takes MinioService instead
- qr: replace QrJwtService with decode_access_token() from imphnen-libs
  - remove entire src/auth/ (register/login/Google OAuth/refresh)
  - remove common/qr_jwt.rs, src/config.rs
  - qr_auth_middleware now lazy-upserts users into QR DB on first access
  - qr_router(pool) — no config needed
- gateway: create MinioService once and pass to hackathon_router; qr_router simplified

Users now register/login via /v1/auth/* and use the same JWT for all endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:33:02 +07:00
maulanasdqnandClaude Sonnet 4.6 4bba182ea3 feat: migrate imphnen-backend-qr into workspace as imphnen-qr crate
Ports the Go QR campaign overlay service to a self-contained Rust crate
nested at /v1/qr/... in the gateway.

Features:
- Auth: register, login, Google OAuth, JWT refresh (bcrypt compat with Go DB)
- Users: profile management + admin CRUD (list/role/delete)
- Campaigns: create (auto-generates QR PNG via qrcode crate), list,
  activate, delete; process-image endpoint overlays active campaign QR
  onto uploaded images (bottom-right corner, image crate)
- QR pool connects to imphnen_qr database via QR_DATABASE_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:03:39 +07:00
maulanasdqnandClaude Sonnet 4.6 5715e75593 fix: remove unused imports in imphnen-hackathon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:47:23 +07:00
maulanasdqnandClaude Sonnet 4.6 11442c6285 feat: migrate imphnen-backend-hackathon into workspace as imphnen-hackathon crate
Consolidates the standalone hackathon backend (16 crates) into a single
imphnen-hackathon crate following the existing clean architecture patterns.
All endpoints are exposed under /v1/hackathon/ via the gateway.

Features migrated:
- Auth: Supabase-based signup/login/GitHub OAuth/password reset (own JWT)
- Users: profile management with team listing
- Teams: CRUD with city validation, deadline enforcement, invite system
- Invitations: team member invitations with accept/reject flow
- Join Requests: team join request workflow
- Chat: team messaging with author/leader delete permissions
- Submissions: project submission lifecycle (draft→pending→submitted)
- Storage: Supabase Storage file upload endpoints
- Certificates: public user certificate data endpoint
- Winners: public winners listing
- Admin: admin-only CRUD for all entities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:15:00 +07:00
maulanasdqn 05a5b39195 fix: pre-fetch swagger-ui assets for Nix sandbox build 2026-04-02 14:09:26 +07:00
maulanasdqn 052fee4b78 chore: update nixpkgs to 2026-04-01 (Rust 1.85+ for edition2024) 2026-04-02 14:04:50 +07:00
maulanasdqn c825a78c6f ci: trigger nix build & deploy pipeline 2026-04-02 14:01:02 +07:00
maulanasdqnandClaude Sonnet 4.6 c4280e9953 ci: replace legacy workflows with nix-build.yml
Switch from cargo-based build + SCP deploy to Nix build + Cachix +
infra flake.lock update pattern, matching imphnen-frontend-service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:49:57 +07:00
maulanasdqnandClaude Sonnet 4.6 30128a8fe2 nix: expose overlay and NixOS module for infrastructure deployment
- Fix default.nix to build from workspace (imphnen-backend/Cargo.toml)
- Add overlays.default adding pkgs.imphnen-backend
- Add nixosModules.backend via nixos-module.nix
- NixOS module defines systemd service on port 8081 with environmentFile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:44:23 +07:00
maulanasdqnandClaude Sonnet 4.6 e432a1a743 refactor: migrate to clean architecture with trait-based DI (v0.2.0)
Complete architectural overhaul across all 12 crates:

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:39:52 +07:00
Maulana Sodiqin 1b3366d735 Merge pull request #47 from IMPHNEN/feat/postgress
postgress
2026-01-15 14:20:18 +07:00
763 changed files with 33134 additions and 39592 deletions
+4 -30
View File
@@ -1,36 +1,6 @@
# RUST_ENV : Mode environment aplikasi (misal: development, production)
# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error)
# PORT : Port yang digunakan backend service
# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket)
# SURREALDB_USERNAME : Username untuk SurrealDB
# SURREALDB_PASSWORD : Password untuk SurrealDB
# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan
# SURREALDB_DBNAME : Nama database SurrealDB
# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia)
# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia)
# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP)
# SMTP_PASSWORD : Password email SMTP
# SMTP_NAME : Nama pengirim email (misal: nama aplikasi)
# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com)
# REDISDB_URL : URL/host Redis database
# FE_URL : URL frontend aplikasi (misal: http://localhost)
# MINIO_ENDPOINT : Endpoint MinIO object storage
# MINIO_BUCKET_NAME : Nama bucket default MinIO
# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER
# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD
# MINIO_ROOT_USER : Username root MinIO (default: minioadmin)
# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin)
# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google)
# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google
RUST_ENV=development RUST_ENV=development
RUST_LOG=debug RUST_LOG=debug
PORT=4099 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 ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com SMTP_EMAIL=your-email@example.com
@@ -57,3 +27,7 @@ SSLMODE=require
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
RETRY_DELAY=1 RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback 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
-65
View File
@@ -1,65 +0,0 @@
name: Deploy to Ancikri
on:
push:
branches:
- develop
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build the project
run: cargo build --release
- name: Stop service on VPS before upload
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.VPS_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_PORT }}
script: |
set -e
echo "Stopping the service before uploading the binary"
sudo systemctl stop imphnen-backend-service
- name: Upload artifact to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_PORT }}
source: ./target/release/*
target: /opt/imphnen-backend-service/imphnen-backend-service
rm: true
overwrite: true
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.VPS_ANCIKRI_IP }}
username: ${{ secrets.VPS_ANCIKRI_USER }}
key: ${{ secrets.VPS_ANCIKRI_SSH_KEY }}
port: ${{ secrets.VPS_ANCIKRI_PORT }}
script: |
set -e
echo "Restarting the service"
sudo systemctl daemon-reload
sudo systemctl restart imphnen-backend-service
echo "Deployment completed successfully"
+78
View File
@@ -0,0 +1,78 @@
name: Nix Build & Deploy
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: msdqn
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build
run: nix build .#default -o result
- name: Push to Cachix
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
run: cachix push msdqn result
deploy:
needs: build
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup SSH
env:
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
- name: Update infra flake.lock
run: |
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
cd /tmp/infra
nix flake update imphnen-backend
if git diff --quiet flake.lock; then
echo "flake.lock unchanged, skipping"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "chore: update imphnen-backend-service to ${GITHUB_SHA::7}"
git push
fi
- name: Deploy to server
run: |
ssh -i ~/.ssh/deploy_key \
-o ConnectTimeout=30 \
-o StrictHostKeyChecking=accept-new \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=40 \
root@167.235.70.37 \
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -50'
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
-19
View File
@@ -1,19 +0,0 @@
name: Rust
on:
push:
branches: ["develop"]
pull_request:
branches: ["develop"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
Generated
+793 -125
View File
File diff suppressed because it is too large Load Diff
+28 -15
View File
@@ -1,18 +1,20 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"tests", "imphnen-entities",
"imphnen-entities", # Most basic - core data structures "imphnen-macros",
"imphnen-macros", # Macros "imphnen-libs",
"imphnen-libs", # Depends on entities "imphnen-storage",
"imphnen-utils", # Depends on libs and entities "imphnen-email",
"imphnen-middleware",# Utility for permissions "imphnen-utils",
"imphnen-iam", # Core auth service, depends on libs, utils, entities "imphnen-middleware",
"imphnen-cms", # Content management, depends on core services "imphnen-iam",
"imphnen-gacha", # Game mechanics, depends on core services "imphnen-cms",
"imphnen-dimentorin",# Learning platform, depends on core services "imphnen-gacha",
"imphnen-gateway", # API gateway, depends on all services "imphnen-dimentorin",
"imphnen-backend", # Main application, depends on all services "imphnen-hackathon",
"imphnen-gateway",
"imphnen-backend",
] ]
@@ -28,7 +30,7 @@ tokio = { version = "1.47.1", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] } argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
chrono = "0.4.41" chrono = "0.4.41"
utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] } lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
thiserror = "2.0.14" thiserror = "2.0.14"
@@ -37,7 +39,12 @@ rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1" rand_distr = "0.5.1"
tower-http = { version = "0.6.6", features = ["cors", "trace"] } tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.3" http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] } zod-rs = { version = "0.4", features = ["macros"] }
zod-rs-util = "0.4"
paginator-rs = "0.2"
paginator-utils = "0.2"
paginator-sea-orm = { version = "0.2", features = ["sqlx-postgres", "runtime-tokio"] }
paginator-axum = "0.2"
lazy_static = "1.5.0" lazy_static = "1.5.0"
regex = "1.11.1" regex = "1.11.1"
axum-test = "17.3.0" axum-test = "17.3.0"
@@ -59,6 +66,7 @@ hyper = "1.6.0"
hyper-util = "0.1.16" hyper-util = "0.1.16"
minio = "0.3.0" minio = "0.3.0"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] } sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "json", "macros"] }
num_cpus = "1.16.0" num_cpus = "1.16.0"
@@ -71,7 +79,6 @@ tokio-tungstenite = "0.27"
url = "2.5" url = "2.5"
futures-util = "0.3" futures-util = "0.3"
http = "1.3" http = "1.3"
tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" } imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" } imphnen-cms = { path = "./imphnen-cms" }
imphnen-libs = { path = "./imphnen-libs" } imphnen-libs = { path = "./imphnen-libs" }
@@ -83,6 +90,12 @@ imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" } imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" } imphnen-middleware = { path = "./imphnen-middleware" }
imphnen-macros = { path = "./imphnen-macros" } imphnen-macros = { path = "./imphnen-macros" }
imphnen-hackathon = { path = "./imphnen-hackathon" }
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"] }
[profile.release] [profile.release]
lto = "fat" lto = "fat"
+20 -12
View File
@@ -11,30 +11,38 @@ WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
RUN mkdir -p imphnen-backend/src imphnen-cms/src imphnen-dimentorin/src \ RUN mkdir -p \
imphnen-entities/src imphnen-gacha/src imphnen-gateway/src \ imphnen-backend/src \
imphnen-iam/src imphnen-libs/src imphnen-middleware/src \ imphnen-cms/src \
imphnen-utils/src tests/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 && \ echo "fn main() {}" > imphnen-backend/src/main.rs && \
find . -name "src" -type d -exec sh -c 'echo "// dummy" > "$1/lib.rs"' _ {} \; find . -name "src" -type d -exec sh -c 'touch "$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-backend ./imphnen-backend
COPY imphnen-cms ./imphnen-cms COPY imphnen-cms ./imphnen-cms
COPY imphnen-dimentorin ./imphnen-dimentorin COPY imphnen-dimentorin ./imphnen-dimentorin
COPY imphnen-email ./imphnen-email
COPY imphnen-entities ./imphnen-entities COPY imphnen-entities ./imphnen-entities
COPY imphnen-gacha ./imphnen-gacha COPY imphnen-gacha ./imphnen-gacha
COPY imphnen-gateway ./imphnen-gateway COPY imphnen-gateway ./imphnen-gateway
COPY imphnen-hackathon ./imphnen-hackathon
COPY imphnen-iam ./imphnen-iam COPY imphnen-iam ./imphnen-iam
COPY imphnen-libs ./imphnen-libs COPY imphnen-libs ./imphnen-libs
COPY imphnen-macros ./imphnen-macros
COPY imphnen-middleware ./imphnen-middleware COPY imphnen-middleware ./imphnen-middleware
COPY imphnen-storage ./imphnen-storage
COPY imphnen-utils ./imphnen-utils 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" \ 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 && \ cargo build -p imphnen-backend --release && \
-35
View File
@@ -1,35 +0,0 @@
@echo off
setlocal
:: Cek apakah file .env ada
if not exist ".env" (
echo File .env tidak ditemukan di direktori saat ini.
exit /b 1
)
echo Memuat variabel dari .env...
:: Baca file .env baris per baris
for /f "tokens=*" %%a in ('type ".env" ^| findstr /v "^$" ^| findstr /v "^#"') do (
echo.%%a | findstr "=" >nul && (
for /f "tokens=1,2 delims==" %%b in ("%%a") do (
set "key=%%b"
set "value=%%c"
:: Trim whitespace
call :trimValue key value
echo Set variabel: %%b=%%c
setx %%b %%c >nul
)
)
)
echo.
echo Semua variabel telah dimuat.
endlocal
goto :eof
:: Fungsi trim (sederhana)
:trimValue
set "%1=%[%1]%"
set "%2=%[%2]%"
goto :eof
-30
View File
@@ -1,30 +0,0 @@
function Set-TempEnvFromDotEnv {
param (
[string]$envFilePath
)
if (-Not (Test-Path $envFilePath)) {
Write-Error "The .env file at path '$envFilePath' does not exist."
return
}
$envContent = Get-Content $envFilePath
foreach ($line in $envContent) {
$trimmedLine = $line.Trim()
if (-Not [string]::IsNullOrWhiteSpace($trimmedLine) -and -Not $trimmedLine.StartsWith("#")) {
$keyValue = $trimmedLine -split "=", 2
if ($keyValue.Length -eq 2) {
$key = $keyValue[0].Trim()
$value = $keyValue[1].Trim()
[System.Environment]::SetEnvironmentVariable($key, $value, [System.EnvironmentVariableTarget]::Process)
Write-Host "Set temporary environment variable: $key=$value"
}
}
}
Write-Host "All environment variables from '$envFilePath' have been set temporarily."
}
Set-TempEnvFromDotEnv -envFilePath ".env"
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set_temp_env_from_dotenv() {
local env_file_path="$1"
if [[ ! -f "$env_file_path" ]]; then
echo "Error: The .env file at path '$env_file_path' does not exist."
return 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
trimmed_line=$(echo "$line" | xargs)
if [[ -n "$trimmed_line" && ! "$trimmed_line" =~ ^# ]]; then
key=$(echo "$trimmed_line" | cut -d '=' -f 1 | xargs)
value=$(echo "$trimmed_line" | cut -d '=' -f 2- | xargs)
export "$key=$value"
echo "Set temporary environment variable: $key=$value"
fi
done < "$env_file_path"
echo "All environment variables from '$env_file_path' have been set temporarily."
}
set_temp_env_from_dotenv ".env"
+24 -14
View File
@@ -1,15 +1,25 @@
{pkgs ? import <nixpkgs> {}}: let { pkgs ? import <nixpkgs> { } }:
manifest = (pkgs.lib.importTOML ./Cargo.toml).package; let
rustDeps = pkgs.callPackage ./Cargo.nix {}; swaggerUi = pkgs.fetchurl {
packageEntry = rustDeps.workspaceMembers.${manifest.name}; url = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.17.14.zip";
deps = packageEntry.build.cargoDeps or null; hash = "sha256-SBJE0IEgl7Efuu73n3HZQrFxYX+cn5UU5jrL4T5xzNw=";
};
in in
pkgs.rustPlatform.buildRustPackage { pkgs.rustPlatform.buildRustPackage {
pname = manifest.name; pname = "imphnen-backend";
version = manifest.version; version = (pkgs.lib.importTOML ./imphnen-backend/Cargo.toml).package.version;
cargoDeps = deps; src = pkgs.lib.cleanSource ./.;
src = pkgs.lib.cleanSource ./.; cargoLock.lockFile = ./Cargo.lock;
cargoLock.lockFile = ./Cargo.lock; cargoBuildFlags = [
nativeBuildInputs = [pkgs.openssl pkgs.pkg-config]; "--package"
buildInputs = [pkgs.openssl]; "imphnen-backend"
} "--bin"
"api"
];
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ];
preBuild = ''
export SWAGGER_UI_DOWNLOAD_URL="file://${swaggerUi}"
'';
doCheck = false;
}
-49
View File
@@ -1,49 +0,0 @@
#!/bin/bash
# Script deploy semua binary hasil build Rust (untuk Linux)
set -e
REMOTE_USER="asephs"
REMOTE_HOST="70.153.9.42"
REMOTE_PATH="/home/asephs/imphnen-backend-service"
# Build release binary
echo "🔧 Building Rust project..."
cargo build --release
# Filter hanya file executable tanpa ekstensi .exe, .rlib, atau .d
BINARIES=$(find target/release -maxdepth 1 -type f ! -name "*.exe" ! -name "*.rlib" ! -name "*.d")
if [ -z "$BINARIES" ]; then
echo "❌ Tidak ada binary Linux (.exe/.rlib/.d diabaikan)"
exit 1
fi
# Upload semua binary yang valid ke server
echo "🚀 Mengirim binary ke server..."
rsync -avz --compress-level=9 --progress \
$BINARIES \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/target/release/
# Jalankan ulang service utama di VPS
echo "♻️ Restart service utama di server..."
ssh $REMOTE_USER@$REMOTE_HOST << 'EOF'
set -e
cd /home/asephs/imphnen-backend-service
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
# Pastikan semua binary bisa dieksekusi
chmod +x target/release/*
# Restart service utama (misalnya api.d)
if pm2 list | grep -q api; then
pm2 restart api --update-env
else
pm2 start target/release/api.d --name api
fi
EOF
echo "✅ Deploy semua binary Linux selesai ke $REMOTE_HOST:$REMOTE_PATH"
+32 -31
View File
@@ -1,42 +1,43 @@
{pkgs, ...}: let { pkgs, ... }:
let
baseImage = pkgs.ociTools.pullImage { baseImage = pkgs.ociTools.pullImage {
imageName = "ubuntu"; imageName = "ubuntu";
tag = "latest"; tag = "latest";
}; };
in in
pkgs.dockerTools.buildImage { pkgs.dockerTools.buildImage {
name = "imphnen-cms-api"; name = "imphnen-backend-service";
fromImage = baseImage; fromImage = baseImage;
copyToRoot = pkgs.buildEnv { copyToRoot = pkgs.buildEnv {
name = "imphnen-cms-api"; name = "imphnen-backend-service";
paths = [ paths = [
(pkgs.stdenv.mkDerivation { (pkgs.stdenv.mkDerivation {
name = "imphnen-cms-api"; name = "imphnen-backend-service";
src = ./src; src = ./src;
buildInputs = [ buildInputs = [
pkgs.rustc pkgs.rustc
pkgs.cargo pkgs.cargo
pkgs.openssl pkgs.openssl
pkgs.pkg-config pkgs.pkg-config
]; ];
buildPhase = '' buildPhase = ''
cargo build --release cargo build --release
''; '';
installPhase = '' installPhase = ''
mkdir -p $out/bin mkdir -p $out/bin
cp target/release/najm-course-api $out/bin/ cp target/release/imphnen-backend-service $out/bin/
''; '';
}) })
]; ];
}; };
config = { config = {
Cmd = ["/bin/imphnen-cms-api"]; Cmd = [ "/bin/imphnen-backend-service" ];
WorkingDir = "/bin"; WorkingDir = "/bin";
}; };
} }
+74
View File
@@ -0,0 +1,74 @@
# Dimentorin — Catatan Temuan Infra (Dev Audit, 2026-08-04)
Dokumen ini mencatat temuan yang membutuhkan perhatian tim sebelum produksi.
Semua diuji lokal (Postgres `dimentorin`, backend :4099).
## 1. SMTP email verification broken (blocker aktivasi user baru) — ✅ FIXED (2026-08-05, Google App Password)
- Endpoint `POST /v1/iam/auth/send-otp` gagal: `SMTP transport error (535): Username and Password not accepted` — kredensial `.env` (`SMTP_EMAIL=dev@example.com`, `SMTP_PASSWORD=dev`) ditolak Google SMTP.
- `POST /v1/iam/auth/verify-email` tetap butuh OTP untuk memanggil, tapi lihat poin 2.
- **Dampak**: mentee/mentor baru tak bisa menerima OTP lewat email → tak bisa aktivasi → tak bisa login, kecuali via verify-email langsung.
- **Diperlukan**: SMTP credential institution yang valid (Gmail App Password atau SMTP relay), sebaiknya dari BWS secret management, bukan hardcode.
## 2. ✅ FIXED — verify-email TIDAK memverifikasi OTP (security issue)
**Status: FIXED di branch feat/dimentorin-postgres (2026-08-04).**
`imphnen-iam/src/auth/application/mod.rs``verify_email()`:
- OTP sekarang dipersist ke tabel **`app_otp_cache`** (entity baru `imphnen-entities/src/seaorm/common/otp_cache.rs`, resource `app_otp_cache` sudah direncanakan di `ResourceEnum::OtpCache`).
- `register()` & `resend_otp()` menyimpan `otp_hash` + `expires_at` setelah email terkirim (kalau email gagal, tidak ada OTP yatim / OTP lama tidak di-overwrite).
- `verify_email()` memanggil `OtpManager::validate_otp_hash(stored_hash, expires_at, payload.otp)` sebelum set `is_active`. `validate_otp_hash` ditambahkan ke `OtpManager` (pure hash+expiry tanpa perlu plaintext code).
- OTP **single-use**: di-delete setelah verifikasi sukses. Reuse / OTP tanpa cache / OTP expired semua ditolak (400).
- Uji lokal (Postgres, :4099): OTP salah → 400 "Invalid or expired OTP", user tetap inactive; OTP benar → 200 "Email verified successfully", user aktif, OTP dihapus; verify ulang → 400 "User already active"; email tanpa OTP → 400 "No OTP issued".
- Tabel dibuat via SQL manual (`create_schema.rs` ditambah `otp_cache` untuk bootstrap penuh).
## 3. (OK, sudah benar) Register mentor + booking
- `POST /v1/dimentorin/mentors/create` → 200, user + mentor profile dibuat, status `pending`, user tak tampil di list public sampai verified.
- `POST /v1/dimentorin/mentors/{id}/sessions/create` → 200, session pending.
- Kedua endpoint fungsional setelah fix UUID (commit 9b5efef).
## Rekomendasi
Tangani #1 dan #2 sebelum go-live. #2 adalah kelas bug "OTP di-generate tapi tak dipakai" — sisi verifikasi email saat ini tidak lebih dari form "set is_active=true tanpa autentikasi".
## 4. ✅ DONE — Payment flow (alur bisnis menjual)
**Status: DONE di feat/dimentorin-postgres (2026-08-05).**
- Tabel `app_payments`: amount (dari `mentoring_rate` mentor) + service_fee 2000 + total; method `va`/`qris`/`manual`; provider `manual` default (swap Midtrans/Xendit nanti — cukup ganti nilai `provider` dan implementasi `generate_external_ref`/notifikasi webhook).
- Routes protected: `POST /payments/sessions/{id}/create`, `GET /payments/me`, `GET /payments/{id}`, `POST /payments/{id}/confirm`.
- Guard: mentee hanya bisa akses payment miliknya (403 kalau bukan); confirm hanya Admin / Admin Pembayaran.
- `confirm_payment` otomatis mengubah session terkait `pending` -> `confirmed` (loop bisnis lengkap: book -> bayar -> sesi terkonfirmasi -> feedback).
- FE: PaymentStep pilih VA/QRIS, rate real dari `mentoring_rate`; modal appointment: book -> create payment -> tampil VA/QR dengan `external_ref` + total + expiry -> success. Service lib: `postCreatePayment/getMyPayments/getPaymentById/postConfirmPayment`.
- E2E verified (lokal :4099): create VA dan QRIS, confirm 200, re-confirm 409, non-admin 403, akses payment orang lain 403, session auto-confirmed.
- TODO produksi: isi kredensial payment gateway (Midtrans/Xendit) + webhook callback; SMTP masih blocker (#1).
## 5. Payment lifecycle complete: auto-paid refresh + dashboards (2026-08-05)
- POST /payments/{id}/refresh: polls Midtrans v2/{order_id}/status; settlement/capture -> payment paid + session confirmed automatically (e2e verified via sandbox simulator: VA paid -> refresh -> paid + confirmed)
- GET /payments/session/{id}: payments for one session, accessible by that session's mentee or mentor (powers both dashboards)
- Session mentor may confirm their own payments (previously admins only) — verified e2e
- FE: /mentoring/my-sessions (mentee) + /mentoring/mentor-dashboard (mentor), both with live payment status and refresh/confirm actions; QRIS step renders real QR from qr_string
## 6. Materi + AI Agent RAG (2026-08-05, commit 164c186 BE / 7294040 FE)
Fitur yang "harusnya ada" menurut user (Figma hanya berisi Design System, halaman Materi/AI Agent tidak ada di file) — dibangun dari pemahaman alur bisnis mentoring.
**Backend — modul materials** (`imphnen-dimentorin/src/materials/`):
- `app_materials` table: mentor_id, title, slug, category, description, content, cover_url, is_published
- Pola articles: domain/repository/service + postgres repo + DTO ZodValidate
- Routes: GET /materials (public, published), /materials/{id|slug|categories}, POST/PUT/DELETE (auth, author-only)
- ENV baru: AI_LLM_BASE_URL/API_KEY/MODEL, AI_EMBEDDING_MODEL, QDRANT_URL
**Backend — AI agent RAG** (`imphnen-dimentorin/src/ai_agent/`):
- Chunking materi (700 chars, overlap 80) -> embed via 9router `gemini/gemini-embedding-001` (3072 dim!)
- Qdrant collection `dimentorin_materi` (3072d cosine, point id = u64 dari uuid xor index — Qdrant TOLAK string non-UUID)
- Chat: embed question -> search top-4 -> LLM (`text` -> gemini-3.1-flash-lite) jawab dengan konteks + sources
- Routes: POST /ai/chat, POST /ai/materials/{id}/index, POST /ai/reindex
- 9router chat SELALU SSE-streaming walau tanpa stream:true — parser harus agregate `data:` lines
**Verified e2e**: reindex 3 chunks; chat 'ownership' -> source materi Rust 0.88; chat 'endpoint axum' -> Axum 0.82; browser: list materi, detail, chatbox jawab + sources 84/64/61%.
**Pitfall**: embedding Gemini = 3072 dim (bukan 768); Qdrant point id harus u64/UUID; model embedding yang berfungsi di 9router = `gemini/gemini-embedding-001` (llama-nemotron -> 'No credentials for provider: openai').
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1739020877, "lastModified": 1775036866,
"narHash": "sha256-mIvECo/NNdJJ/bXjNqIh8yeoSjVLAuDuTUzAo7dzs8Y=", "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a79cfe0ebd24952b580b1cf08cd906354996d547", "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github" "type": "github"
}, },
"original": { "original": {
+37 -22
View File
@@ -5,28 +5,43 @@
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
}; };
outputs = { outputs =
self, {
nixpkgs, self,
}: let nixpkgs,
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-darwin" "aarch64-linux"]; }:
pkgsFor = system: let
import nixpkgs { supportedSystems = [
inherit system; "x86_64-linux"
config = { "x86_64-darwin"
allowUnfree = true; "aarch64-darwin"
"aarch64-linux"
];
pkgsFor =
system:
import nixpkgs {
inherit system;
config.allowUnfree = true;
}; };
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix { };
});
overlays.default = final: _prev: {
imphnen-backend = final.callPackage ./default.nix { };
}; };
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in { nixosModules.backend = ./nixos-module.nix;
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix {}; devShells = forAllSystems (system: {
}); default = (pkgsFor system).callPackage ./shell.nix { };
devShells = forAllSystems (system: { });
default = (pkgsFor system).callPackage ./shell.nix {};
}); dockerImages = forAllSystems (system: {
dockerImages = forAllSystems (system: { tryOutApi = (pkgsFor system).callPackage ./docker.nix { };
tryOutApi = (pkgsFor system).callPackage ./docker.nix {}; });
}); };
};
} }
+1 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imphnen-backend" name = "imphnen-backend"
version = "0.1.0" version = "0.3.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@@ -63,7 +63,6 @@ serde_json.workspace = true
utoipa.workspace = true utoipa.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
regex.workspace = true regex.workspace = true
validator.workspace = true
axum-test.workspace = true axum-test.workspace = true
rand.workspace = true rand.workspace = true
tokio.workspace = true tokio.workspace = true
-4
View File
@@ -1,13 +1,9 @@
// 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_gateway::gateway_service;
use imphnen_libs::axum_init; use imphnen_libs::axum_init;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
axum_init(|postgres_db| async { axum_init(|postgres_db| async {
// Gateway service now uses PostgreSQL exclusively (SeaORM)
// SurrealDB dependencies have been completely removed
gateway_service(postgres_db).await gateway_service(postgres_db).await
}) })
.await; .await;
+67 -69
View File
@@ -1,93 +1,91 @@
#![allow(clippy::all)] #![allow(clippy::all)]
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{Statement, ConnectionTrait}; use sea_orm::{ConnectionTrait, Statement};
use std::error::Error;
use std::env; use std::env;
use std::error::Error;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
// New default behavior: execute by default; use --dry-run to preview only. let dry_run = args
let dry_run = args.iter().any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry"); .iter()
let force = args.iter().any(|s| s == "--force" || s == "-f"); .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!("🔎 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"); 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![
let tables = vec![ "gacha_claims",
"gacha_claims", "gacha_rolls",
"gacha_rolls", "gacha_items",
"gacha_items", "gacha_credits",
"gacha_credits", "audit_logs",
"audit_logs", "rate_limits",
"rate_limits", "testimonials",
"testimonials", "events",
"events", "app_mentors",
"app_mentors", "app_sessions",
"app_sessions", "app_roles_permissions",
"app_roles_permissions", "app_permissions",
"app_permissions", "app_roles",
"app_roles", "app_users",
"app_users", ];
];
let postgres_config = PostgresConfig::from_env()?; let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?; let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn; let db = &pg_conn.conn;
// Filter tables that actually exist in the database let mut existing_tables: Vec<&str> = vec![];
let mut existing_tables: Vec<&str> = vec![]; for t in tables.iter() {
for t in tables.iter() { let check_sql = format!(
let check_sql = format!(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;", "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;",
t t
); );
let stmt = Statement::from_string(db.get_database_backend(), check_sql); let stmt = Statement::from_string(db.get_database_backend(), check_sql);
if let Ok(Some(row)) = pg_conn.query_one(stmt).await { if let Ok(Some(row)) = pg_conn.query_one(stmt).await {
let exists_val: Option<bool> = row.try_get("", "exists").ok(); let exists_val: Option<bool> = row.try_get("", "exists").ok();
if exists_val.unwrap_or(false) { if exists_val.unwrap_or(false) {
existing_tables.push(t); existing_tables.push(t);
} }
} }
} }
if existing_tables.is_empty() { if existing_tables.is_empty() {
println!("No configured tables found to clear - nothing to do."); println!("No configured tables found to clear - nothing to do.");
return Ok(()); return Ok(());
} }
let truncate_sql = format!( let truncate_sql = format!(
"TRUNCATE TABLE {} RESTART IDENTITY CASCADE;", "TRUNCATE TABLE {} RESTART IDENTITY CASCADE;",
existing_tables.join(", ") existing_tables.join(", ")
); );
println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql); 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 = imphnen_libs::ENV.rust_env.clone();
let env_name = std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()); if env_name == "production" && !force {
if env_name == "production" && !force { println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override.");
println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override."); return Ok(());
return Ok(()); }
}
if dry_run { if dry_run {
println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production)."); println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production).");
return Ok(()); return Ok(());
} }
println!("Executing truncate...\n"); println!("Executing truncate...\n");
let postgres_config = PostgresConfig::from_env()?; let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?; let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn; let db = &pg_conn.conn;
let stmt = Statement::from_string(db.get_database_backend(), truncate_sql); let stmt = Statement::from_string(db.get_database_backend(), truncate_sql);
match pg_conn.execute(stmt).await { match pg_conn.execute(stmt).await {
Ok(_) => println!("✅ Successfully cleared DB tables"), Ok(_) => println!("✅ Successfully cleared DB tables"),
Err(e) => println!("❌ Failed to clear DB tables: {}", e), Err(e) => println!("❌ Failed to clear DB tables: {}", e),
} }
Ok(()) Ok(())
} }
+58 -42
View File
@@ -1,65 +1,81 @@
#![allow(clippy::all)] #![allow(clippy::all)]
use sea_orm::{ConnectionTrait, Database, Schema, DbBackend, EntityTrait};
use imphnen_libs::postgres::PostgresConfig;
use imphnen_entities::seaorm::{auth, common, gacha}; use imphnen_entities::seaorm::{auth, common, gacha};
use imphnen_libs::postgres::PostgresConfig;
use sea_orm::sea_query::Table; use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, Database, DbBackend, EntityTrait, Schema};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🛠️ Creating database schema..."); println!("🛠️ Creating database schema...");
let config = PostgresConfig::from_env()?; let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?; let db = Database::connect(&config.database_url).await?;
let builder = db.get_database_backend(); let builder = db.get_database_backend();
println!(" Database connected. Creating/updating tables..."); println!(" Database connected. Creating/updating tables...");
// Dropping and recreating tables to ensure schema is up-to-date drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?;
// This is safer for development/testing environments to prevent schema drift. drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity)
drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?; .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_users", auth::users::Entity).await?; drop_and_create_table(
drop_and_create_table(&db, builder, "app_roles_permissions", auth::roles_permissions::Entity).await?; &db,
drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?; builder,
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity).await?; "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, "app_articles", common::articles::Entity)
.await?;
drop_and_create_table(&db, builder, "events", common::events::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, "testimonials", common::testimonials::Entity)
drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity).await?; .await?;
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::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, "otp_cache", common::otp_cache::Entity).await?;
drop_and_create_table(&db, builder, "payments", common::payments::Entity)
.await?;
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity).await?; drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity)
drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity).await?; .await?;
drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity).await?; drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity)
drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity).await?; .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."); println!("✅ Schema creation completed.");
Ok(()) Ok(())
} }
async fn drop_and_create_table<E>( async fn drop_and_create_table<E>(
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
builder: DbBackend, builder: DbBackend,
name: &str, name: &str,
entity: E, entity: E,
) -> Result<(), Box<dyn std::error::Error>> // Return Result ) -> Result<(), Box<dyn std::error::Error>>
where 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();
let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned(); // Added .cascade() db.execute(builder.build(&drop_stmt)).await?;
db.execute(builder.build(&drop_stmt)).await?; // Propagate error println!(" Dropped table if exists: {}", name);
println!(" Dropped table if exists: {}", name);
// Create table let mut create_stmt = schema.create_table_from_entity(entity);
let mut create_stmt = schema.create_table_from_entity(entity); create_stmt.if_not_exists();
create_stmt.if_not_exists();
db.execute(builder.build(&create_stmt)).await?; // Propagate error db.execute(builder.build(&create_stmt)).await?;
println!(" ✅ Created table: {}", name); println!(" ✅ Created table: {}", name);
Ok(()) Ok(())
} }
+13 -14
View File
@@ -4,18 +4,17 @@ use imphnen_libs::jsonwebtoken::encode_access_token;
use std::env; use std::env;
fn main() { fn main() {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.len() < 2 { if args.len() < 2 {
eprintln!("Usage: mk_token <email_or_sub>"); eprintln!("Usage: mk_token <email_or_sub>");
std::process::exit(1); std::process::exit(1);
} }
let sub = args[1].clone(); let sub = args[1].clone();
// Use sub as both sub and user_id match encode_access_token(sub.clone(), sub.clone()) {
match encode_access_token(sub.clone(), sub.clone()) { Ok(token) => println!("{}", token),
Ok(token) => println!("{}", token), Err(e) => {
Err(e) => { eprintln!("Failed to generate token: {:?}", e);
eprintln!("Failed to generate token: {:?}", e); std::process::exit(2);
std::process::exit(2); }
} }
}
} }
+40
View File
@@ -0,0 +1,40 @@
#![allow(clippy::all)]
use chrono::Utc;
use imphnen_entities::seaorm::common::articles::{
ActiveModel as ArticleActiveModel, Entity as ArticlesEntity,
};
use imphnen_libs::postgres::PostgresConfig;
use sea_orm::{ActiveModelTrait, ActiveValue, Database};
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?;
let articles = vec![
("Cara Memulai Karier di UI/UX Design", "cara-memulai-karier-ui-ux-design", "UI/UX & Design", "Panduan lengkap untuk masuk ke dunia UI/UX design, dari skill yang dibutuhkan hingga portofolio.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. UI/UX design adalah bidang yang menjanjikan. Artikel ini membahas langkah awal memulai karier sebagai UI/UX designer, tools yang wajib dikuasai seperti Figma, serta cara membangun portofolio yang menarik bagi perekrut."),
("Belajar Rust: Panduan Pemula 2026", "belajar-rust-panduan-pemula-2026", "Software/Web Dev", "Bahasa pemrograman Rust sedang naik daun. Pelajari konsep ownership dan borrow checker.", "Rust adalah bahasa pemrograman yang fokus pada performa dan keamanan memori. Dalam artikel ini kita membahas ownership, borrowing, dan cara setup environment Rust di Linux dan Windows, serta contoh project sederhana."),
("Mengenal Machine Learning untuk Data Analyst", "mengenal-machine-learning-data-analyst", "Data & AI", "Peran Data Analyst berevolusi dengan hadirnya machine learning. Simak panduannya.", "Machine learning membuka peluang besar bagi data analyst. Artikel ini menjelaskan perbedaan data analysis dan machine learning, serta roadmap belajar dari Python, pandas, sampai scikit-learn."),
];
for (title, slug, category, excerpt, content) in articles {
let am = ArticleActiveModel {
id: ActiveValue::Set(Uuid::new_v4()),
title: ActiveValue::Set(title.to_string()),
slug: ActiveValue::Set(slug.to_string()),
category: ActiveValue::Set(category.to_string()),
excerpt: ActiveValue::Set(excerpt.to_string()),
content: ActiveValue::Set(content.to_string()),
cover_url: ActiveValue::Set(None),
author_name: ActiveValue::Set(Some("IMPHNEN Editorial".to_string())),
is_published: ActiveValue::Set(true),
created_at: ActiveValue::Set(Utc::now()),
updated_at: ActiveValue::Set(Utc::now()),
};
am.insert(&db).await?;
println!("✅ Inserted article: {}", slug);
}
println!("🟢 All articles seeded");
Ok(())
}
+26 -16
View File
@@ -1,11 +1,15 @@
#![allow(clippy::all)] #![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_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::common::events::{ActiveModel as EventsActiveModel, Entity as EventEntity}; use sea_orm::{
use sea_orm::{ActiveValue::Set, ActiveModelTrait, EntityTrait, ColumnTrait, QueryFilter}; ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter,
};
use std::error::Error;
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc; // Removed NaiveDateTime as it was unused
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -54,7 +58,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-09-20T13:00:00Z", "2025-09-20T13:00:00Z",
"2025-09-22T15:00:00Z", "2025-09-22T15:00:00Z",
), ),
// Additional Events
( (
"Rust Programming Bootcamp", "Rust Programming Bootcamp",
"Intensive 3-day bootcamp to master Rust fundamentals and advanced concepts.", "Intensive 3-day bootcamp to master Rust fundamentals and advanced concepts.",
@@ -154,18 +157,20 @@ async fn main() -> Result<(), Box<dyn Error>> {
price, price,
location, location,
is_online, is_online,
start_date_str, // Renamed to avoid conflict start_date_str,
end_date_str, // Renamed to avoid conflict end_date_str,
) in events ) in events
{ {
// Check if event already exists by name let existing = EventEntity::find()
let existing = EventEntity::find().filter(<EventEntity as EntityTrait>::Column::Name.eq(name)).one(db).await?; .filter(<EventEntity as EntityTrait>::Column::Name.eq(name))
.one(db)
.await?;
if existing.is_some() { if existing.is_some() {
println!("️ Skipping (already exists): {name}"); println!("️ Skipping (already exists): {name}");
continue; continue;
} }
let uuid = Uuid::new_v4(); // Generate a Uuid let uuid = Uuid::new_v4();
let mut event_model: EventsActiveModel = Default::default(); let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(uuid); event_model.id = Set(uuid);
event_model.name = Set(name.to_string()); event_model.name = Set(name.to_string());
@@ -174,12 +179,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
event_model.price = Set(price); event_model.price = Set(price);
event_model.is_online = Set(is_online); event_model.is_online = Set(is_online);
event_model.location = Set(location.clone()); 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.start_date = Set(
event_model.end_date = Set(chrono::DateTime::parse_from_rfc3339(end_date_str)?.with_timezone(&chrono::Utc)); chrono::DateTime::parse_from_rfc3339(start_date_str)?
event_model.is_deleted = Set(false); // Explicitly set is_deleted .with_timezone(&chrono::Utc),
event_model.created_at = Set(Utc::now()); // Explicitly set created_at );
event_model.updated_at = Set(Utc::now()); // Explicitly set updated_at 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?; event_model.insert(db).await?;
+30 -24
View File
@@ -1,13 +1,13 @@
#![allow(clippy::all)] #![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_items::ActiveModel as GachaItemActiveModel;
use imphnen_entities::seaorm::gacha::gacha_rolls::ActiveModel as GachaRollActiveModel; use imphnen_entities::seaorm::gacha::gacha_rolls::ActiveModel as GachaRollActiveModel;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
use sea_orm::ActiveValue::Set; use sea_orm::ActiveValue::Set;
use uuid::Uuid;
use sea_orm::ConnectionTrait; use sea_orm::ConnectionTrait;
use std::error::Error;
use uuid::Uuid;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -15,18 +15,25 @@ async fn main() -> Result<(), Box<dyn Error>> {
let pg_conn = PostgresConnection::new(config).await?; let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn; let db = &pg_conn.conn;
// Check if gacha item already exists let check_item_sql =
let check_item_sql = "SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1"; "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 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 { let gacha_item_uuid = if let Some(ref row) = item_result {
// Item exists, get its ID
row.try_get("", "id")? row.try_get("", "id")?
} else { } else {
// Item doesn't exist, create it let _ = pg_conn
// 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 .execute(sea_orm::Statement::from_string(
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(); db.get_database_backend(),
"DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string(),
))
.await
.ok();
// Create gacha item via SeaORM
let new_uuid = Uuid::new_v4(); let new_uuid = Uuid::new_v4();
let mut item_model: GachaItemActiveModel = Default::default(); let mut item_model: GachaItemActiveModel = Default::default();
item_model.id = Set(new_uuid); item_model.id = Set(new_uuid);
@@ -47,7 +54,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
new_uuid 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 gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default(); let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id); roll_model.id = Set(gacha_roll_id);
@@ -61,18 +67,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc())); roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?; roll_model.insert(db).await?;
println!("Gacha Roll seeded successfully!"); println!("Gacha Roll seeded successfully!");
let gacha_roll_id = Uuid::new_v4(); let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default(); let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id); roll_model.id = Set(gacha_roll_id);
roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); 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.gacha_id = Set(Uuid::new_v4().to_string());
roll_model.item_id = Set(gacha_item_uuid); roll_model.item_id = Set(gacha_item_uuid);
roll_model.weight = Set(1.0); roll_model.weight = Set(1.0);
roll_model.quantity = Set(10); roll_model.quantity = Set(10);
roll_model.is_deleted = Set(false); roll_model.is_deleted = Set(false);
roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc())); roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc())); roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?; roll_model.insert(db).await?;
println!("✅ Gacha items and rolls seeded."); println!("✅ Gacha items and rolls seeded.");
Ok(()) Ok(())
} }
+34 -16
View File
@@ -1,13 +1,18 @@
#![allow(clippy::all)] #![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::hash_password;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait,
QueryFilter,
};
use serde_json::json; use serde_json::json;
use std::error::Error; 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; use uuid::Uuid;
#[tokio::main] #[tokio::main]
@@ -16,17 +21,28 @@ async fn main() -> Result<(), Box<dyn Error>> {
let pg_conn = PostgresConnection::new(config).await?; let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn; 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
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(); .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() let role = RoleEntity::find()
.filter(RoleColumn::Name.eq("Mentor")) .filter(RoleColumn::Name.eq("Mentor"))
.one(db) .one(db)
.await? .await?
.ok_or("Role 'Mentor' not found")?; .ok_or("Role 'Mentor' not found")?;
// Insert user with Mentor role
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let mut user_model: UsersActiveModel = Default::default(); let mut user_model: UsersActiveModel = Default::default();
user_model.id = Set(user_id); user_model.id = Set(user_id);
@@ -43,21 +59,23 @@ async fn main() -> Result<(), Box<dyn Error>> {
user_model.updated_at = Set(chrono::Utc::now()); user_model.updated_at = Set(chrono::Utc::now());
user_model.insert(db).await?; user_model.insert(db).await?;
// Insert mentor
let mentor_id = Uuid::new_v4(); let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default(); let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id); mentor_model.id = Set(mentor_id);
mentor_model.user_id = Set(user_id); mentor_model.user_id = Set(user_id);
mentor_model.industries = Set(Some(json!( ["Software", "Education"] ))); mentor_model.industries = Set(Some(json!(["Software", "Education"])));
mentor_model.expertise = Set(Some(json!( ["Rust", "Microservices"] ))); mentor_model.expertise = Set(Some(json!(["Rust", "Microservices"])));
mentor_model.languages = Set(Some(json!( ["Indonesian", "English"] ))); mentor_model.languages = Set(Some(json!(["Indonesian", "English"])));
mentor_model.current_company = Set(Some("PT Contoh".to_string())); mentor_model.current_company = Set(Some("PT Contoh".to_string()));
mentor_model.current_role = Set(Some("Senior Backend Engineer".to_string())); mentor_model.current_role = Set(Some("Senior Backend Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5)); 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_mentee_level = Set(Some("beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["online", "offline"] ))); 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.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.mentoring_rate = Set(Some(100000.0));
mentor_model.status = Set(Some("verified".to_string())); mentor_model.status = Set(Some("verified".to_string()));
mentor_model.is_deleted = Set(false); mentor_model.is_deleted = Set(false);
+7 -9
View File
@@ -1,14 +1,14 @@
#![allow(clippy::all)] #![allow(clippy::all)]
use imphnen_iam::PermissionsEnum; use chrono::Utc;
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::ActiveModel as PermissionActiveModel;
use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity; 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 sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait}; use std::error::Error;
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -54,17 +54,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::DeleteMentors, PermissionsEnum::DeleteMentors,
PermissionsEnum::Administrator, PermissionsEnum::Administrator,
] { ] {
// permission.id() returns a string, try parse to uuid let parsed_id =
let parsed_id = Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4()); 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?; let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?;
if existing.is_some() { if existing.is_some() {
println!("️ Skipping (already exists): {permission}"); println!("️ Skipping (already exists): {permission}");
continue; continue;
} }
// Insert permission using active model
let mut perm_model: PermissionActiveModel = Default::default(); let mut perm_model: PermissionActiveModel = Default::default();
perm_model.id = Set(parsed_id); perm_model.id = Set(parsed_id);
perm_model.name = Set(permission.to_string()); perm_model.name = Set(permission.to_string());
+7 -11
View File
@@ -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_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::roles::{RoleBuilder, Entity as RoleEntity};
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait};
use std::error::Error;
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc; // Added chrono
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -50,19 +50,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
), ),
]; ];
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()); 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?; let existing = RoleEntity::find_by_id(uuid).one(db).await?;
if existing.is_some() { if existing.is_some() {
println!("️ Skipping (already exists): {name}"); println!("️ Skipping (already exists): {name}");
continue; 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() let role_model = RoleBuilder::new()
.name(name.to_string()) .name(name.to_string())
.description("System generated role".to_string()) .description("System generated role".to_string())
@@ -71,9 +67,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
.build()?; .build()?;
let mut role_model = role_model; let mut role_model = role_model;
role_model.id = Set(uuid); role_model.id = Set(uuid);
role_model.is_system_role = Set(true); // Set the missing field role_model.is_system_role = Set(true);
role_model.created_at = Set(Utc::now()); // Set created_at role_model.created_at = Set(Utc::now());
role_model.updated_at = Set(Utc::now()); // Set updated_at role_model.updated_at = Set(Utc::now());
role_model.insert(db).await?; role_model.insert(db).await?;
println!("✅ Inserted role: {name}"); println!("✅ Inserted role: {name}");
@@ -1,35 +1,32 @@
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 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::ActiveValue::Set;
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
use sea_orm::ActiveModelTrait;
use uuid::Uuid;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::error::Error;
use uuid::Uuid;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?; let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?; let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn; 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'."
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'."); );
let roles_permissions = vec![ let roles_permissions = vec![
( (
"f6b03f25-e416-4893-ac88-caaa690afb07", "f6b03f25-e416-4893-ac88-caaa690afb07",
vec![ vec![PermissionsEnum::Administrator],
// Only Administrator permission - grants access to everything
PermissionsEnum::Administrator,
],
), ),
( (
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![ vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus, PermissionsEnum::ReadOwnMentorStatus,
@@ -64,7 +61,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
( (
"50133429-f4b1-4249-9f97-7b86e6ee9d86", "50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![ vec![
// Staff should be able to list roles and permissions in tests
PermissionsEnum::ReadListRoles, PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions, PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadListUsers, PermissionsEnum::ReadListUsers,
@@ -91,12 +87,13 @@ async fn main() -> Result<(), Box<dyn Error>> {
for (role_id, permissions) in roles_permissions { for (role_id, permissions) in roles_permissions {
let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4()); 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( let json_permissions = JsonValue::Array(
permissions.iter().map(|p| JsonValue::String(p.id())).collect() 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? { if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? {
let mut am: RoleActiveModel = role_model.into(); let mut am: RoleActiveModel = role_model.into();
am.permissions = Set(Some(json_permissions)); am.permissions = Set(Some(json_permissions));
+65 -63
View File
@@ -1,77 +1,79 @@
#![allow(clippy::all)] #![allow(clippy::all)]
use std::error::Error; use chrono::Utc;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use imphnen_entities::seaorm::common::events::ActiveModel as EventsActiveModel; use imphnen_entities::seaorm::common::events::ActiveModel as EventsActiveModel;
use imphnen_entities::seaorm::common::testimonials::ActiveModel as TestimonialsActiveModel; use imphnen_entities::seaorm::common::testimonials::ActiveModel as TestimonialsActiveModel;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveValue::Set;
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
use uuid::Uuid; use sea_orm::ActiveValue::Set;
use serde_json::json; use serde_json::json;
use chrono::Utc; use std::error::Error;
use uuid::Uuid;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?; let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?; let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn; let db = &pg_conn.conn;
// Seed Events - handle existing data let uuid = Uuid::new_v4().to_string();
let uuid = Uuid::new_v4().to_string(); let mut event_model: EventsActiveModel = Default::default();
let mut event_model: EventsActiveModel = Default::default(); event_model.id = Set(Uuid::parse_str(&uuid)?);
event_model.id = Set(Uuid::parse_str(&uuid)?); event_model.name = Set("Test Event".to_string());
event_model.name = Set("Test Event".to_string()); event_model.description = Set("Test event description".to_string());
event_model.description = Set("Test event description".to_string()); event_model.detail_link = Set("https://example.com/event".to_string());
event_model.detail_link = Set("https://example.com/event".to_string()); event_model.price = Set(50.0);
event_model.price = Set(50.0); event_model.is_online = Set(true);
event_model.is_online = Set(true); event_model.start_date = Set(Utc::now());
event_model.start_date = Set(Utc::now()); event_model.end_date = Set(Utc::now() + chrono::Duration::days(1));
event_model.end_date = Set(Utc::now() + chrono::Duration::days(1)); event_model.location = Set(None);
event_model.location = Set(None); event_model.is_deleted = Set(false);
event_model.is_deleted = Set(false); match event_model.insert(db).await {
match event_model.insert(db).await { Ok(_) => println!("✅ Inserted test event"),
Ok(_) => println!("✅ Inserted test event"), Err(_) => {
Err(_) => println!("⚠️ Test event already exists or could not be inserted, skipping"), println!("⚠️ Test event already exists or could not be inserted, skipping")
}; }
};
// Seed Testimonials - handle existing data let mut testimonial_model: TestimonialsActiveModel = Default::default();
let mut testimonial_model: TestimonialsActiveModel = Default::default(); testimonial_model.id =
testimonial_model.id = Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?); Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?);
testimonial_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); testimonial_model.user_id =
testimonial_model.role = Set("Student".to_string()); Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
testimonial_model.content = Set("This is a great platform!".to_string()); testimonial_model.role = Set("Student".to_string());
testimonial_model.is_deleted = Set(false); testimonial_model.content = Set("This is a great platform!".to_string());
match testimonial_model.insert(db).await { testimonial_model.is_deleted = Set(false);
Ok(_) => println!("✅ Inserted test testimonial"), match testimonial_model.insert(db).await {
Err(_) => println!("⚠️ Test testimonial already exists or could not be inserted, skipping"), 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 mentor_id = Uuid::new_v4(); let mut mentor_model: MentorsActiveModel = Default::default();
let mut mentor_model: MentorsActiveModel = Default::default(); mentor_model.id = Set(mentor_id);
mentor_model.id = Set(mentor_id); mentor_model.user_id =
// Use the admin user ID instead of a random one Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
mentor_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?); mentor_model.industries = Set(Some(json!(["Technology", "Education"])));
mentor_model.industries = Set(Some(json!( ["Technology", "Education"] ))); mentor_model.expertise = Set(Some(json!(["Software Development"])));
mentor_model.expertise = Set(Some(json!( ["Software Development"] ))); mentor_model.languages = Set(Some(json!(["English", "Indonesian"])));
mentor_model.languages = Set(Some(json!( ["English", "Indonesian"] ))); mentor_model.current_company = Set(Some("Tech Corp".to_string()));
mentor_model.current_company = Set(Some("Tech Corp".to_string())); mentor_model.current_role = Set(Some("Senior Engineer".to_string()));
mentor_model.current_role = Set(Some("Senior Engineer".to_string())); mentor_model.years_of_experience = Set(Some(5));
mentor_model.years_of_experience = Set(Some(5)); mentor_model.topics_of_interest = Set(Some(json!(["Rust", "Web Development"])));
mentor_model.topics_of_interest = Set(Some(json!( ["Rust", "Web Development"] ))); mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string()));
mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string())); mentor_model.preferred_mentoring_formats = Set(Some(json!(["1:1", "Group"])));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["1:1", "Group"] ))); mentor_model.availability_commitment = Set(Some("Weekly".to_string()));
mentor_model.availability_commitment = Set(Some("Weekly".to_string())); mentor_model.mentoring_rate = Set(Some(100.0));
mentor_model.mentoring_rate = Set(Some(100.0)); mentor_model.status = Set(Some("active".to_string()));
mentor_model.status = Set(Some("active".to_string())); mentor_model.is_deleted = Set(false);
mentor_model.is_deleted = Set(false); mentor_model.created_at = Set(chrono::Utc::now());
mentor_model.created_at = Set(chrono::Utc::now()); mentor_model.updated_at = Set(chrono::Utc::now());
mentor_model.updated_at = Set(chrono::Utc::now()); mentor_model.insert(db).await?;
// Create mentor record via SeaORM active model println!("✅ Inserted test mentor via SeaORM");
mentor_model.insert(db).await?;
println!("✅ Inserted test mentor via SeaORM");
println!("✅ All test data seeded successfully"); println!("✅ All test data seeded successfully");
Ok(()) Ok(())
} }
+138 -135
View File
@@ -1,14 +1,14 @@
#![allow(clippy::all)] #![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::hash_password;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection}; use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::users::Entity as UserEntity; // Added for dynamic role lookup
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
use uuid::Uuid;
use std::error::Error;
use chrono::Utc; use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
use std::error::Error;
use uuid::Uuid;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -17,145 +17,148 @@ async fn main() -> Result<(), Box<dyn Error>> {
let db = &pg_conn.conn; let db = &pg_conn.conn;
let users = vec![ let users = vec![
( (
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2", "c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com", "admin@example.com",
"Admin", "Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07", "f6b03f25-e416-4893-ac88-caaa690afb07",
), ),
( (
"a4d23fb5-9e31-423c-9842-fbd6e75a5298", "a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com", "staff@example.com",
"Staff", "Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86", "50133429-f4b1-4249-9f97-7b86e6ee9d86",
), ),
( (
"d5e89c12-72af-4b1a-abc3-ff1234567890", "d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com", "user@example.com",
"User", "User",
"5713cb37-dc02-4e87-8048-d7a41d352059", "5713cb37-dc02-4e87-8048-d7a41d352059",
), ),
( (
"665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4", "665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4",
"testuser1@example.com", "testuser1@example.com",
"Test User 1", "Test User 1",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID "5713cb37-dc02-4e87-8048-d7a41d352059",
), ),
( (
"3972c139-a450-416c-93b0-c42539dc780f", "3972c139-a450-416c-93b0-c42539dc780f",
"testuser2@example.com", "testuser2@example.com",
"Test User 2", "Test User 2",
"5713cb37-dc02-4e87-8048-d7a41d352059", "5713cb37-dc02-4e87-8048-d7a41d352059",
), ),
( (
"b426c0a9-0efb-4e26-b078-4f18767255f3", "b426c0a9-0efb-4e26-b078-4f18767255f3",
"testuser3@example.com", "testuser3@example.com",
"Test User 3", "Test User 3",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID "5713cb37-dc02-4e87-8048-d7a41d352059",
), ),
// Additional Users for Volume and Variety (
( "11111111-1111-1111-1111-111111111111",
"11111111-1111-1111-1111-111111111111", "user4@example.com",
"user4@example.com", "User Four",
"User Four", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "22222222-2222-2222-2222-222222222222",
"22222222-2222-2222-2222-222222222222", "user5@example.com",
"user5@example.com", "User Five",
"User Five", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "33333333-3333-3333-3333-333333333333",
"33333333-3333-3333-3333-333333333333", "mentor2@example.com",
"mentor2@example.com", "Mentor Two",
"Mentor Two", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", // Mentor Role ),
), (
( "44444444-4444-4444-4444-444444444444",
"44444444-4444-4444-4444-444444444444", "staff2@example.com",
"staff2@example.com", "Staff Two",
"Staff Two", "50133429-f4b1-4249-9f97-7b86e6ee9d86",
"50133429-f4b1-4249-9f97-7b86e6ee9d86", // Staff Role ),
), (
( "55555555-5555-5555-5555-555555555555",
"55555555-5555-5555-5555-555555555555", "user6@example.com",
"user6@example.com", "User Six",
"User Six", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "66666666-6666-6666-6666-666666666666",
"66666666-6666-6666-6666-666666666666", "user7@example.com",
"user7@example.com", "User Seven",
"User Seven", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "77777777-7777-7777-7777-777777777777",
"77777777-7777-7777-7777-777777777777", "user8@example.com",
"user8@example.com", "User Eight",
"User Eight", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "88888888-8888-8888-8888-888888888888",
"88888888-8888-8888-8888-888888888888", "user9@example.com",
"user9@example.com", "User Nine",
"User Nine", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
), (
( "99999999-9999-9999-9999-999999999999",
"99999999-9999-9999-9999-999999999999", "user10@example.com",
"user10@example.com", "User Ten",
"User Ten", "5713cb37-dc02-4e87-8048-d7a41d352059",
"5713cb37-dc02-4e87-8048-d7a41d352059", ),
),
]; ];
for (id, email, fullname, role_id_str) in users { // role_id_str directly contains UUID for (id, email, fullname, role_id_str) in users {
let role_uuid = Some(Uuid::parse_str(role_id_str) let role_uuid = Some(
.map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?); 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)?;
let uid = Uuid::parse_str(id)?; // Should always be valid UUID strings from test data
let names: Vec<&str> = fullname.split_whitespace().collect(); let names: Vec<&str> = fullname.split_whitespace().collect();
let first_name = names.first().map(|s| s.to_string()); let first_name = names.first().map(|s| s.to_string());
let last_name = if names.len() > 1 { Some(names[1..].join(" ")) } else { None }; let last_name = if names.len() > 1 {
Some(names[1..].join(" "))
} else {
None
};
let password = "password"; let password = "password";
let hashed = hash_password(password).unwrap(); let hashed = hash_password(password).unwrap();
// Explicit Upsert Logic let existing_user = UserEntity::find_by_id(uid).one(db).await?;
let existing_user = UserEntity::find_by_id(uid).one(db).await?; let is_update = existing_user.is_some();
let is_update = existing_user.is_some();
let mut user_model: UsersActiveModel = if let Some(existing) = existing_user { let mut user_model: UsersActiveModel = if let Some(existing) = existing_user {
println!("🔄 Updating user: {fullname} ({email})"); println!("🔄 Updating user: {fullname} ({email})");
existing.into_active_model() existing.into_active_model()
} else { } else {
println!("✅ Inserting user: {fullname} ({email})"); println!("✅ Inserting user: {fullname} ({email})");
let mut active: UsersActiveModel = Default::default(); let mut active: UsersActiveModel = Default::default();
active.id = Set(uid); active.id = Set(uid);
active.created_at = Set(Utc::now()); active.created_at = Set(Utc::now());
active active
}; };
user_model.email = Set(email.to_string()); user_model.email = Set(email.to_string());
user_model.password_hash = Set(hashed); user_model.password_hash = Set(hashed);
user_model.username = Set(email.to_string()); user_model.username = Set(email.to_string());
user_model.first_name = Set(first_name); user_model.first_name = Set(first_name);
user_model.last_name = Set(last_name); user_model.last_name = Set(last_name);
user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string())); user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string()));
user_model.is_verified = Set(true); user_model.is_verified = Set(true);
user_model.is_active = Set(true); user_model.is_active = Set(true);
user_model.role_id = Set(role_uuid); user_model.role_id = Set(role_uuid);
user_model.updated_at = Set(Utc::now()); user_model.updated_at = Set(Utc::now());
if is_update { if is_update {
user_model.update(db).await?; user_model.update(db).await?;
} else { } else {
user_model.insert(db).await?; user_model.insert(db).await?;
} }
} }
println!("✅ All Users seeded"); println!("✅ All Users seeded");
Ok(()) Ok(())
} }
+323 -296
View File
@@ -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; 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] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Starting PostgreSQL Connection Test"); println!("🚀 Starting PostgreSQL Connection Test");
println!("====================================="); println!("=====================================");
// Load configuration from environment let config = PostgresConfig::from_env()?;
let config = PostgresConfig::from_env()?; println!("✅ Configuration loaded successfully");
println!("✅ Configuration loaded successfully"); println!(
println!(" Database URL: {}", config.database_url.replace("postgres://", "postgres://****:****@")); " Database URL: {}",
println!(" Pool size: {}", config.pool_size); config
println!(" Connect timeout: {}s", config.connect_timeout); .database_url
println!(" Retry attempts: {}", config.retry_attempts); .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...");
println!("\n🔌 Testing PostgreSQL connection..."); match test_connection(config).await {
match test_connection(config).await { Ok(()) => {
Ok(()) => { println!("✅ All PostgreSQL tests passed successfully!");
println!("✅ All PostgreSQL tests passed successfully!"); Ok(())
Ok(()) }
} Err(e) => {
Err(e) => { println!("❌ PostgreSQL test failed: {}", e);
println!("❌ PostgreSQL test failed: {}", e); Err(e.into())
Err(e.into()) }
} }
}
} }
async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> { async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> {
// Create connection println!(" Creating PostgreSQL connection...");
println!(" Creating PostgreSQL connection..."); let postgres_conn = PostgresConnection::new(config).await?;
let postgres_conn = PostgresConnection::new(config).await?; let connection = Arc::new(postgres_conn);
let connection = Arc::new(postgres_conn); println!(" ✅ Connection established successfully");
println!(" ✅ Connection established successfully");
// Test basic connectivity println!(" Testing basic connectivity...");
println!(" Testing basic connectivity..."); test_basic_connectivity(&connection).await?;
test_basic_connectivity(&connection).await?; println!(" ✅ Basic connectivity test passed");
println!(" ✅ Basic connectivity test passed");
// Test table existence println!(" Testing table existence...");
println!(" Testing table existence..."); test_table_existence(&connection).await?;
test_table_existence(&connection).await?; println!(" ✅ Table existence test passed");
println!(" ✅ Table existence test passed");
// Test CRUD operations println!(" Testing CRUD operations...");
println!(" Testing CRUD operations..."); test_crud_operations(&connection).await?;
test_crud_operations(&connection).await?; println!(" ✅ CRUD operations test passed");
println!(" ✅ CRUD operations test passed");
// Test transaction support println!(" Testing transaction support...");
println!(" Testing transaction support..."); test_transactions(&connection).await?;
test_transactions(&connection).await?; println!(" ✅ Transaction support test passed");
println!(" ✅ Transaction support test passed");
// Test error handling println!(" Testing error handling...");
println!(" Testing error handling..."); test_error_handling(&connection).await?;
test_error_handling(&connection).await?; println!(" ✅ Error handling test passed");
println!(" ✅ Error handling test passed");
Ok(()) Ok(())
} }
async fn test_basic_connectivity(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> { async fn test_basic_connectivity(
// Execute a simple query connection: &Arc<PostgresConnection>,
let statement = sea_orm::Statement::from_string( ) -> Result<(), PostgresError> {
connection.get_database_backend(), let statement = sea_orm::Statement::from_string(
"SELECT 1 as test_value, current_timestamp as current_time".to_string() connection.get_database_backend(),
); "SELECT 1 as test_value, current_timestamp as current_time".to_string(),
);
let result = connection.query_one(statement).await? let result = connection.query_one(statement).await?.ok_or_else(|| {
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("No results returned".to_string())))?; PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"No results returned".to_string(),
))
})?;
// Verify we got expected results let test_value: Option<i32> = result.try_get("", "test_value").ok();
let test_value: Option<i32> = result.try_get("", "test_value").ok(); let current_time: Option<String> = result.try_get("", "current_time").ok();
let current_time: Option<String> = result.try_get("", "current_time").ok();
if test_value != Some(1) { if test_value != Some(1) {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
format!("Expected test_value=1, got {:?}", test_value) format!("Expected test_value=1, got {:?}", test_value),
))); )));
} }
if current_time.is_none() { if current_time.is_none() {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom( return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"Expected current_time to be set".to_string() "Expected current_time to be set".to_string(),
))); )));
} }
println!(" 📝 Query result: test_value={:?}, current_time={:?}", test_value, current_time); println!(
Ok(()) " 📝 Query result: test_value={:?}, current_time={:?}",
test_value, current_time
);
Ok(())
} }
async fn test_table_existence(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> { async fn test_table_existence(
// Test if our tables exist connection: &Arc<PostgresConnection>,
use sea_orm::EntityTrait; ) -> Result<(), PostgresError> {
use sea_orm::EntityTrait;
println!(" 📋 Checking users table..."); println!(" 📋 Checking users table...");
let user_count = UsersEntity::find() let user_count = UsersEntity::find()
.count(&connection.conn) .count(&connection.conn)
.await .await
.map_err(PostgresError::ConnectionError)?; .map_err(PostgresError::ConnectionError)?;
println!(" 📊 Users table accessible, current count: {}", user_count); println!(
" 📊 Users table accessible, current count: {}",
user_count
);
println!(" 📋 Checking roles table..."); println!(" 📋 Checking roles table...");
let role_count = RolesEntity::find() let role_count = RolesEntity::find()
.count(&connection.conn) .count(&connection.conn)
.await .await
.map_err(PostgresError::ConnectionError)?; .map_err(PostgresError::ConnectionError)?;
println!(" 📊 Roles table accessible, current count: {}", role_count); println!(
" 📊 Roles table accessible, current count: {}",
role_count
);
Ok(()) Ok(())
} }
async fn test_crud_operations(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> { async fn test_crud_operations(
use sea_orm::{ActiveModelTrait, Set}; connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
use sea_orm::{ActiveModelTrait, Set};
// Create test user println!(" Creating test user...");
println!(" Creating test user..."); let test_user_id = Uuid::new_v4();
let test_user_id = Uuid::new_v4(); let now = Utc::now();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id), id: Set(test_user_id),
email: Set(format!("test_user_{}@example.com", test_user_id)), email: Set(format!("test_user_{}@example.com", test_user_id)),
password_hash: Set("test_password_hash".to_string()), password_hash: Set("test_password_hash".to_string()),
username: Set(format!("testuser_{}", test_user_id)), username: Set(format!("testuser_{}", test_user_id)),
first_name: Set(Some("Test".to_string())), first_name: Set(Some("Test".to_string())),
last_name: Set(Some("User".to_string())), last_name: Set(Some("User".to_string())),
avatar_url: Set(None), avatar_url: Set(None),
is_verified: Set(false), is_verified: Set(false),
is_active: Set(true), is_active: Set(true),
metadata: Set(None), metadata: Set(None),
role_id: Set(None), role_id: Set(None),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
deleted_at: Set(None), deleted_at: Set(None),
}; };
let created_user = user_model.insert(&connection.conn) let created_user = user_model
.await .insert(&connection.conn)
.map_err(PostgresError::ConnectionError)?; .await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Created user with ID: {}", created_user.id); println!(" ✅ Created user with ID: {}", created_user.id);
// Read user println!(" 🔍 Reading test user...");
println!(" 🔍 Reading test user..."); let found_user = UsersEntity::find_by_id(test_user_id)
let found_user = UsersEntity::find_by_id(test_user_id) .one(&connection.conn)
.one(&connection.conn) .await
.await .map_err(PostgresError::ConnectionError)?
.map_err(PostgresError::ConnectionError)? .ok_or_else(|| {
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("User not found after creation".to_string())))?; PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"User not found after creation".to_string(),
))
})?;
println!(" ✅ Found user: {} ({})", found_user.username, found_user.email); println!(
" ✅ Found user: {} ({})",
found_user.username, found_user.email
);
// Update user println!(" ✏️ Updating test user...");
println!(" ✏️ Updating test user..."); let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel =
let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel = found_user.into(); found_user.into();
update_model.first_name = Set(Some("Updated".to_string())); update_model.first_name = Set(Some("Updated".to_string()));
update_model.updated_at = Set(Utc::now()); update_model.updated_at = Set(Utc::now());
let updated_user = update_model.update(&connection.conn) let updated_user = update_model
.await .update(&connection.conn)
.map_err(PostgresError::ConnectionError)?; .await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Updated user first name to: {:?}", updated_user.first_name); println!(
" ✅ Updated user first name to: {:?}",
updated_user.first_name
);
// Delete user println!(" 🗑️ Deleting test user...");
println!(" 🗑️ Deleting test user..."); UsersEntity::delete_by_id(updated_user.id)
UsersEntity::delete_by_id(updated_user.id) .exec(&connection.conn)
.exec(&connection.conn) .await
.await .map_err(PostgresError::ConnectionError)?;
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Test user deleted successfully"); println!(" ✅ Test user deleted successfully");
Ok(()) Ok(())
} }
async fn test_transactions(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> { async fn test_transactions(
println!(" 💰 Testing transaction support..."); connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
println!(" 💰 Testing transaction support...");
// Test transaction with rollback let transaction_result = connection
let transaction_result = connection.conn.transaction(|txn| { .conn
Box::pin(async move { .transaction(|txn| {
// Create a test user within transaction Box::pin(async move {
let test_user_id = Uuid::new_v4(); let test_user_id = Uuid::new_v4();
let now = Utc::now(); let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel { let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id), id: Set(test_user_id),
email: Set(format!("transaction_test_{}@example.com", test_user_id)), email: Set(format!("transaction_test_{}@example.com", test_user_id)),
password_hash: Set("transaction_password_hash".to_string()), password_hash: Set("transaction_password_hash".to_string()),
username: Set(format!("transaction_user_{}", test_user_id)), username: Set(format!("transaction_user_{}", test_user_id)),
first_name: Set(Some("Transaction".to_string())), first_name: Set(Some("Transaction".to_string())),
last_name: Set(Some("Test".to_string())), last_name: Set(Some("Test".to_string())),
avatar_url: Set(None), avatar_url: Set(None),
is_verified: Set(false), is_verified: Set(false),
is_active: Set(true), is_active: Set(true),
metadata: Set(None), metadata: Set(None),
role_id: Set(None), role_id: Set(None),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
deleted_at: Set(None), deleted_at: Set(None),
}; };
let _created_user = user_model.insert(txn) let _created_user = user_model.insert(txn).await?;
.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()))
Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string())) })
}) })
}).await; .await;
// Transaction should fail and rollback match transaction_result {
match transaction_result { Err(e) => {
Err(e) => { let e_text = format!("{:?}", e);
let e_text = format!("{:?}", e); if e_text.contains("Simulated transaction failure") {
if e_text.contains("Simulated transaction failure") { println!(" ✅ Transaction failed as expected, rollback successful");
println!(" ✅ Transaction failed as expected, rollback successful"); } else {
} else { return Err(PostgresError::OperationFailed(format!(
return Err(PostgresError::OperationFailed(format!("Unexpected transaction result: {}", e_text))); "Unexpected transaction result: {}",
} e_text
} )));
Ok(_) => { }
return Err(PostgresError::OperationFailed("Unexpected transaction result: transaction unexpectedly succeeded".to_string())); }
} 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())
let user_exists = UsersEntity::find_by_id(Uuid::nil()) // Use nil UUID as we don't know the actual ID .one(&connection.conn)
.one(&connection.conn) .await
.await .map_err(PostgresError::ConnectionError)?
.map_err(PostgresError::ConnectionError)? .is_some();
.is_some();
if user_exists { if user_exists {
println!(" ⚠️ User found despite rollback - this might indicate an issue"); println!(" ⚠️ User found despite rollback - this might indicate an issue");
} else { } else {
println!(" ✅ Transaction rollback verified - user not found"); println!(" ✅ Transaction rollback verified - user not found");
} }
Ok(()) Ok(())
} }
async fn test_error_handling(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> { async fn test_error_handling(
println!(" ⚠️ Testing error handling..."); connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
println!(" ⚠️ Testing error handling...");
// Test invalid UUID println!(" 🔍 Testing invalid UUID handling...");
println!(" 🔍 Testing invalid UUID handling..."); let invalid_uuid = Uuid::nil();
let invalid_uuid = Uuid::nil(); // This should exist or be handled gracefully
match UsersEntity::find_by_id(invalid_uuid) match UsersEntity::find_by_id(invalid_uuid)
.one(&connection.conn) .one(&connection.conn)
.await .await
.map_err(PostgresError::ConnectionError)? .map_err(PostgresError::ConnectionError)?
{ {
Some(_) => println!(" ✅ Found user with nil UUID (expected in some cases)"), Some(_) => {
None => println!("No user found with nil UUID (expected)"), 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...");
println!(" 🔍 Testing invalid query handling..."); let invalid_statement = sea_orm::Statement::from_string(
let invalid_statement = sea_orm::Statement::from_string( connection.get_database_backend(),
connection.get_database_backend(), "SELECT * FROM non_existent_table".to_string(),
"SELECT * FROM non_existent_table".to_string() );
);
match connection.execute(invalid_statement).await { match connection.execute(invalid_statement).await {
Err(_) => println!(" ✅ Invalid query properly handled with error"), Err(_) => println!(" ✅ Invalid query properly handled with error"),
Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"), Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"),
} }
Ok(()) Ok(())
} }
/// Additional utility functions for comprehensive testing
pub mod test_utils { pub mod test_utils {
use super::*; use super::*;
/// Create a test PostgreSQL configuration pub fn create_test_config() -> PostgresConfig {
pub fn create_test_config() -> PostgresConfig { PostgresConfig {
PostgresConfig { database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test"
database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test".to_string(), .to_string(),
pool_size: 5, pool_size: 5,
connect_timeout: 10, connect_timeout: 10,
idle_timeout: 30, idle_timeout: 30,
max_lifetime: Some(600), max_lifetime: Some(600),
retry_attempts: 2, retry_attempts: 2,
retry_delay: 1, retry_delay: 1,
} }
} }
/// Create a test user model pub fn create_test_user_model() -> UserModel {
pub fn create_test_user_model() -> UserModel { UserModel {
UserModel { id: Uuid::new_v4(),
id: Uuid::new_v4(), email: format!("test_{}@example.com", Uuid::new_v4()),
email: format!("test_{}@example.com", Uuid::new_v4()), password_hash: "test_password_hash".to_string(),
password_hash: "test_password_hash".to_string(), username: format!("testuser_{}", Uuid::new_v4()),
username: format!("testuser_{}", Uuid::new_v4()), first_name: Some("Test".to_string()),
first_name: Some("Test".to_string()), last_name: Some("User".to_string()),
last_name: Some("User".to_string()), avatar_url: None,
avatar_url: None, is_verified: false,
is_verified: false, is_active: true,
is_active: true, metadata: None,
metadata: None, role_id: None,
role_id: None, created_at: Utc::now(),
created_at: Utc::now(), updated_at: Utc::now(),
updated_at: Utc::now(), deleted_at: None,
deleted_at: None, }
} }
}
/// Create a test role model pub fn create_test_role_model() -> RoleModel {
pub fn create_test_role_model() -> RoleModel { RoleModel {
RoleModel { id: Uuid::new_v4(),
id: Uuid::new_v4(), name: format!("test_role_{}", Uuid::new_v4()),
name: format!("test_role_{}", Uuid::new_v4()), description: "Test role description".to_string(),
description: "Test role description".to_string(), permissions: Some(serde_json::json!(["test.permission"])),
permissions: Some(serde_json::json!(["test.permission"])), is_system_role: false,
is_system_role: false, is_default: false,
is_default: false, created_at: Utc::now(),
created_at: Utc::now(), updated_at: Utc::now(),
updated_at: Utc::now(), deleted_at: None,
deleted_at: None, }
} }
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_create_test_config() { fn test_create_test_config() {
let config = test_utils::create_test_config(); let config = test_utils::create_test_config();
assert_eq!(config.pool_size, 5); assert_eq!(config.pool_size, 5);
assert_eq!(config.connect_timeout, 10); assert_eq!(config.connect_timeout, 10);
assert!(config.database_url.contains("imphnen_test")); assert!(config.database_url.contains("imphnen_test"));
} }
#[test] #[test]
fn test_create_test_user_model() { fn test_create_test_user_model() {
let user = test_utils::create_test_user_model(); let user = test_utils::create_test_user_model();
assert!(!user.email.is_empty()); assert!(!user.email.is_empty());
assert!(!user.username.is_empty()); assert!(!user.username.is_empty());
assert!(user.is_active); assert!(user.is_active);
// is_admin field removed; instead, check role-based permission or is_active }
}
#[test] #[test]
fn test_create_test_role_model() { fn test_create_test_role_model() {
let role = test_utils::create_test_role_model(); let role = test_utils::create_test_role_model();
assert!(!role.name.is_empty()); assert!(!role.name.is_empty());
assert!(role.permissions.is_some()); assert!(role.permissions.is_some());
assert!(!role.is_system_role); assert!(!role.is_system_role);
} }
} }
+2 -5
View File
@@ -5,9 +5,6 @@ use imphnen_libs::axum_init;
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let _ = axum_init(|postgres_conn| async { let _ =
// PostgreSQL is now the primary database - SurrealDB has been completely removed axum_init(|postgres_conn| async { gateway_service(postgres_conn).await }).await;
gateway_service(postgres_conn).await
})
.await;
} }
+12 -2
View File
@@ -1,9 +1,10 @@
[package] [package]
name = "imphnen-cms" name = "imphnen-cms"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait.workspace = true
imphnen-iam.workspace = true imphnen-iam.workspace = true
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
@@ -14,7 +15,8 @@ serde_json.workspace = true
utoipa.workspace = true utoipa.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
regex.workspace = true regex.workspace = true
validator.workspace = true zod-rs.workspace = true
zod-rs-util.workspace = true
axum-test.workspace = true axum-test.workspace = true
rand.workspace = true rand.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -27,5 +29,13 @@ tracing.workspace = true
sea-orm.workspace = true sea-orm.workspace = true
uuid.workspace = true uuid.workspace = true
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] [package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://" VALID_URL_REGEX = "^https?://"
@@ -0,0 +1,43 @@
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;
pub struct EventServiceImpl {
repo: Arc<dyn EventRepository>,
}
impl EventServiceImpl {
pub fn new(repo: Arc<dyn EventRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl EventService for EventServiceImpl {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<EventEntity>, AppError> {
self.repo.find_all(params).await
}
async fn get(&self, id: Uuid) -> Result<EventEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn create(&self, entity: EventEntity) -> Result<(), AppError> {
self.repo.create(entity).await
}
async fn update(&self, entity: EventEntity) -> Result<(), AppError> {
self.repo.update(entity).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
}
@@ -0,0 +1,3 @@
pub mod event_service;
pub use event_service::EventServiceImpl;
+18
View File
@@ -0,0 +1,18 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct EventEntity {
pub id: Uuid,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub location: Option<String>,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+7
View File
@@ -0,0 +1,7 @@
pub mod event;
pub mod repository;
pub mod service;
pub use event::EventEntity;
pub use repository::EventRepository;
pub use service::EventService;
@@ -0,0 +1,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;
#[async_trait]
pub trait EventRepository: Send + Sync {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<EventEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<EventEntity, AppError>;
async fn create(&self, entity: EventEntity) -> Result<(), AppError>;
async fn update(&self, entity: EventEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
+18
View File
@@ -0,0 +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;
#[async_trait]
pub trait EventService: Send + Sync {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<EventEntity>, AppError>;
async fn get(&self, id: Uuid) -> Result<EventEntity, AppError>;
async fn create(&self, entity: EventEntity) -> Result<(), AppError>;
async fn update(&self, entity: EventEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,131 @@
use crate::events::domain::event::EventEntity;
use chrono::{DateTime, Utc};
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsCreateRequestDto {
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub location: Option<String>,
pub is_online: bool,
}
impl ZodValidate for EventsCreateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
impl From<EventsCreateRequestDto> for EventEntity {
fn from(dto: EventsCreateRequestDto) -> Self {
EventEntity {
id: Uuid::new_v4(),
name: dto.name,
description: dto.description,
detail_link: dto.detail_link,
price: dto.price,
is_online: dto.is_online,
is_deleted: false,
location: dto.location,
start_date: dto.start_date,
end_date: dto.end_date,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsUpdateRequestDto {
pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
pub price: f64,
pub is_online: bool,
pub description: String,
pub detail_link: String,
pub location: Option<String>,
}
impl ZodValidate for EventsUpdateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsListItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub location: Option<String>,
pub is_deleted: bool,
}
impl From<EventEntity> for EventsListItemDto {
fn from(e: EventEntity) -> Self {
EventsListItemDto {
id: e.id.to_string(),
name: e.name,
description: e.description,
detail_link: e.detail_link,
price: e.price,
is_online: e.is_online,
start_date: e.start_date.to_rfc3339(),
end_date: e.end_date.to_rfc3339(),
created_at: e.created_at.to_rfc3339(),
location: e.location,
is_deleted: e.is_deleted,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsDetailItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
impl From<EventEntity> for EventsDetailItemDto {
fn from(e: EventEntity) -> Self {
EventsDetailItemDto {
id: e.id.to_string(),
name: e.name,
description: e.description,
detail_link: e.detail_link,
price: e.price,
is_online: e.is_online,
start_date: e.start_date.to_rfc3339(),
end_date: e.end_date.to_rfc3339(),
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
location: e.location,
}
}
}
@@ -0,0 +1,180 @@
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 imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess};
use paginator_axum::PaginationQuery;
use paginator_utils::PaginatorResponse;
use std::sync::Arc;
use uuid::Uuid;
#[utoipa::path(
get,
path = "/v1/landing/cms/events",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
),
responses(
(status = 200, description = "[PUBLIC] Get event list")
),
tag = "Events"
)]
pub async fn get_event_list(
Extension(service): Extension<Arc<dyn EventService>>,
PaginationQuery(params): PaginationQuery,
) -> Response {
match service.list(params).await {
Ok(result) => {
let mapped = PaginatorResponse {
data: result
.data
.into_iter()
.map(EventsListItemDto::from)
.collect::<Vec<_>>(),
meta: result.meta,
};
ApiPaginated(mapped).into_response()
}
Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string())
.into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/landing/cms/events/detail/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[PUBLIC] Get event by ID", body = ResponseSuccessDto<EventsDetailItemDto>)
),
tag = "Events"
)]
pub async fn get_event_by_id(
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => {
return ApiMessage::new(
axum::http::StatusCode::BAD_REQUEST,
format!("Invalid UUID: {e}"),
)
.into_response();
}
};
match service.get(uuid).await {
Ok(event) => ApiSuccess(EventsDetailItemDto::from(event)).into_response(),
Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string())
.into_response(),
}
}
#[utoipa::path(
post,
security(("Bearer" = [])),
path = "/v1/landing/cms/events/create",
request_body = EventsCreateRequestDto,
responses(
(status = 201, description = "[ADMIN] Create new event")
),
tag = "Events"
)]
pub async fn post_create_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
ValidatedJson(payload): ValidatedJson<EventsCreateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let entity = payload.into();
service.create(entity).await?;
Ok(ApiMessage::created("Event created"))
})
}
#[utoipa::path(
patch,
security(("Bearer" = [])),
path = "/v1/landing/cms/events/update/{id}",
params(
("id" = String, Path, description = "Event ID")
),
request_body = EventsUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Update event")
),
tag = "Events"
)]
pub async fn patch_update_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<EventsUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
let existing = service.get(uuid).await?;
let entity = crate::events::domain::EventEntity {
id: existing.id,
name: payload.name,
description: payload.description,
detail_link: payload.detail_link,
price: payload.price,
is_online: payload.is_online,
location: payload.location,
start_date: payload.start_date,
end_date: payload.end_date,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: chrono::Utc::now(),
};
service.update(entity).await?;
Ok(ApiMessage::ok("Event updated"))
})
}
#[utoipa::path(
delete,
security(("Bearer" = [])),
path = "/v1/landing/cms/events/delete/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[ADMIN] Soft delete event")
),
tag = "Events"
)]
pub async fn delete_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn EventService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
service.delete(uuid).await?;
Ok(ApiMessage::ok("Event deleted"))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{events_protected_routes, events_public_routes};
@@ -0,0 +1,35 @@
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 axum::{
Extension, Router,
routing::{delete, get, patch, post},
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;
fn build_service(db: DatabaseConnection) -> Arc<dyn EventService> {
let repo = Arc::new(PostgresEventRepository::new(db));
Arc::new(EventServiceImpl::new(repo))
}
pub fn events_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/events", get(get_event_list))
.route("/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("/events/create", post(post_create_event))
.route("/events/update/{id}", patch(patch_update_event))
.route("/events/delete/{id}", delete(delete_event))
.layer(Extension(service))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_event_repository;
pub use postgres_event_repository::PostgresEventRepository;
@@ -0,0 +1,161 @@
use crate::events::domain::{event::EventEntity, repository::EventRepository};
use async_trait::async_trait;
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;
fn to_entity(model: EventsModel) -> EventEntity {
EventEntity {
id: model.id,
name: model.name,
description: model.description,
detail_link: model.detail_link,
price: model.price,
is_online: model.is_online,
is_deleted: model.is_deleted,
location: model.location,
start_date: model.start_date,
end_date: model.end_date,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresEventRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresEventRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl EventRepository for PostgresEventRepository {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<EventEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = EventsEntity::find().filter(EventsColumn::IsDeleted.eq(false));
if let Some(ref search) = params.search {
query = query.filter(EventsColumn::Name.contains(&search.query));
}
query = match params.sort_by.as_deref() {
Some("name") => match params.sort_direction {
Some(SortDirection::Desc) => query.order_by(EventsColumn::Name, Order::Desc),
_ => query.order_by(EventsColumn::Name, Order::Asc),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => {
query.order_by(EventsColumn::CreatedAt, Order::Asc)
}
_ => query.order_by(EventsColumn::CreatedAt, Order::Desc),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator
.num_items()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let events = paginator
.fetch_page((page - 1) as u64)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data = events.into_iter().map(to_entity).collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(&self, id: Uuid) -> Result<EventEntity, AppError> {
let event = EventsEntity::find_by_id(id)
.filter(EventsColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?;
Ok(to_entity(event))
}
async fn create(&self, entity: EventEntity) -> Result<(), AppError> {
let active_model = EventsActiveModel {
id: ActiveValue::Set(entity.id),
name: ActiveValue::Set(entity.name),
description: ActiveValue::Set(entity.description),
detail_link: ActiveValue::Set(entity.detail_link),
price: ActiveValue::Set(entity.price),
is_online: ActiveValue::Set(entity.is_online),
is_deleted: ActiveValue::Set(false),
location: ActiveValue::Set(entity.location),
start_date: ActiveValue::Set(entity.start_date),
end_date: ActiveValue::Set(entity.end_date),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
EventsEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn update(&self, entity: EventEntity) -> Result<(), AppError> {
let mut active_model: EventsActiveModel = EventsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?
.into();
active_model.name = ActiveValue::Set(entity.name);
active_model.description = ActiveValue::Set(entity.description);
active_model.detail_link = ActiveValue::Set(entity.detail_link);
active_model.price = ActiveValue::Set(entity.price);
active_model.is_online = ActiveValue::Set(entity.is_online);
active_model.location = ActiveValue::Set(entity.location);
active_model.start_date = ActiveValue::Set(entity.start_date);
active_model.end_date = ActiveValue::Set(entity.end_date);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
let mut active_model: EventsActiveModel = EventsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Event not found".to_string()))?
.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::{events_protected_routes, events_public_routes};
+8 -8
View File
@@ -1,9 +1,9 @@
pub mod v1; pub mod events;
pub mod roadmap;
pub mod testimonials;
pub mod qr;
pub use v1::landing; pub use events::{events_protected_routes, events_public_routes};
pub use v1::landing::events; pub use roadmap::{roadmap_protected_routes, roadmap_public_routes};
pub use v1::landing::testimonials; pub use testimonials::{testimonials_protected_routes, testimonials_public_routes};
pub use v1::landing::events::events_public_routes; pub use qr::qr_router;
pub use v1::landing::events::events_protected_routes;
pub use v1::landing::testimonials::testimonials_public_routes;
pub use v1::landing::testimonials::testimonials_protected_routes;
@@ -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<dyn CampaignRepository>,
}
impl QrCampaignServiceImpl {
pub fn new(repo: Arc<dyn CampaignRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl QrCampaignService for QrCampaignServiceImpl {
async fn create(
&self,
name: String,
url: String,
created_by: Uuid,
) -> Result<CampaignEntity, AppError> {
let qr = QrCode::new(url.as_bytes())
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let qr_img = qr
.render::<image::Luma<u8>>()
.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<Vec<CampaignEntity>, AppError> {
self.repo.find_all().await
}
async fn get_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError> {
self.repo.find_active_qr_data().await
}
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError> {
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<u8>) -> Result<Vec<u8>, 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)
}
}
@@ -0,0 +1 @@
pub mod campaign_service;
@@ -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<Utc>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
pub struct CreateCampaignInput {
pub name: String,
pub url: String,
pub created_by: Uuid,
pub qr_code_data: Vec<u8>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -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<CampaignEntity, AppError>;
async fn find_all(&self) -> Result<Vec<CampaignEntity>, AppError>;
async fn find_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError>;
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -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<CampaignEntity, AppError>;
async fn list_all(&self) -> Result<Vec<CampaignEntity>, AppError>;
async fn get_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError>;
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn process_image(&self, image_bytes: Vec<u8>) -> Result<Vec<u8>, AppError>;
}
@@ -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<Utc>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,204 @@
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,
};
#[utoipa::path(
post,
path = "/v1/qr/campaigns",
request_body = CreateCampaignRequest,
responses(
(status = 201, description = "Create a QR campaign",
example = json!({
"data": {
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"name": "Imphnen Hackathon 2025",
"url": "https://imphnen.dev/register",
"is_active": false,
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2025-12-31T23:59:59Z",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
},
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("Bearer" = []))
)]
pub async fn create_campaign_handler(
Extension(service): Extension<Arc<dyn QrCampaignService>>,
Extension(auth_user): Extension<QrAuthUser>,
Json(body): Json<CreateCampaignRequest>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
get,
path = "/v1/qr/campaigns",
responses(
(status = 200, description = "Admin: list all QR campaigns",
example = json!({
"data": [
{
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"name": "Imphnen Hackathon 2025",
"url": "https://imphnen.dev/register",
"is_active": true,
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2025-12-31T23:59:59Z",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-05T00:00:00Z"
}
],
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("Bearer" = []))
)]
pub async fn list_campaigns_handler(
Extension(service): Extension<Arc<dyn QrCampaignService>>,
Extension(auth_user): Extension<QrAuthUser>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
put,
path = "/v1/qr/campaigns/{id}/activate",
params(("id" = Uuid, Path, description = "Campaign ID")),
responses(
(status = 200, description = "Admin: activate a campaign (deactivates all others)",
example = json!({
"data": {
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"name": "Imphnen Hackathon 2025",
"url": "https://imphnen.dev/register",
"is_active": true,
"created_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2025-12-31T23:59:59Z",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-05T10:00:00Z"
},
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("Bearer" = []))
)]
pub async fn activate_campaign_handler(
Extension(service): Extension<Arc<dyn QrCampaignService>>,
Extension(auth_user): Extension<QrAuthUser>,
Path(id): Path<Uuid>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
delete,
path = "/v1/qr/campaigns/{id}",
params(("id" = Uuid, Path, description = "Campaign ID")),
responses(
(status = 200, description = "Admin: delete a campaign",
example = json!({"message": "Campaign deleted successfully", "version": "0.3.0"})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("Bearer" = []))
)]
pub async fn delete_campaign_handler(
Extension(service): Extension<Arc<dyn QrCampaignService>>,
Extension(auth_user): Extension<QrAuthUser>,
Path(id): Path<Uuid>,
) -> Result<Response, AppError> {
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(),
)
}
#[utoipa::path(
post,
path = "/v1/qr/campaigns/process-image",
responses(
(status = 200, description = "Process QR code image — send multipart/form-data with field 'file'. Returns PNG image bytes.",
content_type = "image/png"),
(status = 400, description = "No file provided or invalid image"),
(status = 401, description = "Unauthorized")
),
tag = "QR - Campaigns",
security(("Bearer" = []))
)]
pub async fn process_image_handler(
Extension(service): Extension<Arc<dyn QrCampaignService>>,
Extension(_auth_user): Extension<QrAuthUser>,
mut multipart: Multipart,
) -> Result<Response, AppError> {
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())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -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<PgPool>) -> Router {
let repo: Arc<dyn CampaignRepository> =
Arc::new(PostgresCampaignRepository::new(pool.clone()));
let service: Arc<dyn QrCampaignService> =
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(from_fn(qr_auth_middleware))
.layer(Extension(pool))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1 @@
pub mod postgres_campaign_repository;
@@ -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<chrono::Utc>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<CampaignRow> 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<PgPool>,
}
impl PostgresCampaignRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl CampaignRepository for PostgresCampaignRepository {
async fn create(
&self,
input: CreateCampaignInput,
) -> Result<CampaignEntity, AppError> {
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<Vec<CampaignEntity>, 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<Option<Vec<u8>>, AppError> {
let row = sqlx::query_as::<_, (Vec<u8>,)>(
"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<CampaignEntity, AppError> {
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(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::qr_campaigns_routes;
+1
View File
@@ -0,0 +1 @@
pub mod qr_auth;
+69
View File
@@ -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<Arc<PgPool>>,
mut request: Request<Body>,
next: Next,
) -> Result<Response, 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_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)
}
+15
View File
@@ -0,0 +1,15 @@
pub mod campaigns;
pub mod middleware;
pub mod users;
use axum::Router;
use sea_orm::DatabaseConnection;
use sqlx::PgPool;
use std::sync::Arc;
pub fn qr_router(db: DatabaseConnection) -> Router {
let pool: Arc<PgPool> = 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))
}
@@ -0,0 +1 @@
pub mod user_service;
@@ -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<dyn UserRepository>,
}
impl QrUserServiceImpl {
pub fn new(repo: Arc<dyn UserRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl QrUserService for QrUserServiceImpl {
async fn get_profile(&self, user_id: Uuid) -> Result<UserEntity, AppError> {
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<UserEntity, AppError> {
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<Vec<UserEntity>, AppError> {
self.repo.find_all().await
}
async fn update_role(
&self,
id: Uuid,
role: String,
) -> Result<UserEntity, AppError> {
self.repo.update_role(id, role).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
}
+19
View File
@@ -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<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
pub struct UpdateUserInput {
pub name: Option<String>,
pub email: Option<String>,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -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<Option<UserEntity>, AppError>;
async fn find_all(&self) -> Result<Vec<UserEntity>, AppError>;
async fn update(
&self,
id: Uuid,
input: UpdateUserInput,
) -> Result<UserEntity, AppError>;
async fn update_role(
&self,
id: Uuid,
role: String,
) -> Result<UserEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -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<UserEntity, AppError>;
async fn update_profile(
&self,
user_id: Uuid,
input: UpdateUserInput,
) -> Result<UserEntity, AppError>;
async fn list_all(&self) -> Result<Vec<UserEntity>, AppError>;
async fn update_role(
&self,
id: Uuid,
role: String,
) -> Result<UserEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleRequest {
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,
}
@@ -0,0 +1,199 @@
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},
},
};
#[utoipa::path(
get,
path = "/v1/qr/users/me",
responses(
(status = 200, description = "Get my QR user profile",
example = json!({
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "user@example.com",
"name": "Budi Santoso",
"role": "user",
"provider": "google",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
},
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized")
),
tag = "QR - Users",
security(("Bearer" = []))
)]
pub async fn get_me_handler(
Extension(service): Extension<Arc<dyn QrUserService>>,
Extension(auth_user): Extension<QrAuthUser>,
) -> Result<Response, AppError> {
let user = service.get_profile(auth_user.user_id).await?;
Ok(ApiSuccess(user).into_response())
}
#[utoipa::path(
put,
path = "/v1/qr/users/me",
request_body = UpdateProfileRequest,
responses(
(status = 200, description = "Update my QR user profile",
example = json!({
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "updated@example.com",
"name": "Budi Santoso Updated",
"role": "user",
"provider": "google",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-15T00:00:00Z"
},
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized")
),
tag = "QR - Users",
security(("Bearer" = []))
)]
pub async fn update_me_handler(
Extension(service): Extension<Arc<dyn QrUserService>>,
Extension(auth_user): Extension<QrAuthUser>,
Json(body): Json<UpdateProfileRequest>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
get,
path = "/v1/qr/users",
responses(
(status = 200, description = "Admin: list all QR users",
example = json!({
"data": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "user@example.com",
"name": "Budi Santoso",
"role": "user",
"provider": "google",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
},
{
"id": "4gb96g75-6828-5673-c4gd-3d074g77bgb7",
"email": "admin@example.com",
"name": "Admin User",
"role": "admin",
"provider": "google",
"created_at": "2024-12-01T00:00:00Z",
"updated_at": "2024-12-01T00:00:00Z"
}
],
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("Bearer" = []))
)]
pub async fn list_users_handler(
Extension(service): Extension<Arc<dyn QrUserService>>,
Extension(auth_user): Extension<QrAuthUser>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
put,
path = "/v1/qr/users/{id}/role",
params(("id" = Uuid, Path, description = "User ID")),
request_body = UpdateRoleRequest,
responses(
(status = 200, description = "Admin: update user role",
example = json!({
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "user@example.com",
"name": "Budi Santoso",
"role": "admin",
"provider": "google",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-20T00:00:00Z"
},
"version": "0.3.0"
})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("Bearer" = []))
)]
pub async fn update_role_handler(
Extension(service): Extension<Arc<dyn QrUserService>>,
Extension(auth_user): Extension<QrAuthUser>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateRoleRequest>,
) -> Result<Response, AppError> {
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())
}
#[utoipa::path(
delete,
path = "/v1/qr/users/{id}",
params(("id" = Uuid, Path, description = "User ID")),
responses(
(status = 200, description = "Admin: delete QR user",
example = json!({"message": "User deleted successfully", "version": "0.3.0"})),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("Bearer" = []))
)]
pub async fn delete_user_handler(
Extension(service): Extension<Arc<dyn QrUserService>>,
Extension(auth_user): Extension<QrAuthUser>,
Path(id): Path<Uuid>,
) -> Result<Response, AppError> {
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(),
)
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -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<PgPool>) -> Router {
let repo: Arc<dyn UserRepository> =
Arc::new(PostgresUserRepository::new(pool.clone()));
let service: Arc<dyn QrUserService> = 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(from_fn(qr_auth_middleware))
.layer(Extension(pool))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1 @@
pub mod postgres_user_repository;
@@ -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<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<UserRow> 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<PgPool>,
}
impl PostgresUserRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<UserEntity>, 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<Vec<UserEntity>, 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<UserEntity, AppError> {
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<UserEntity, AppError> {
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(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::qr_users_routes;
@@ -0,0 +1,3 @@
pub mod roadmap_service;
pub use roadmap_service::RoadmapServiceImpl;
@@ -0,0 +1,47 @@
use crate::roadmap::domain::{RoadmapEntity, RoadmapRepository, RoadmapService};
use async_trait::async_trait;
use imphnen_utils::AppError;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use std::sync::Arc;
use uuid::Uuid;
pub struct RoadmapServiceImpl {
repo: Arc<dyn RoadmapRepository>,
}
impl RoadmapServiceImpl {
pub fn new(repo: Arc<dyn RoadmapRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl RoadmapService for RoadmapServiceImpl {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<RoadmapEntity>, AppError> {
self.repo.find_all(params).await
}
async fn get(&self, id: Uuid) -> Result<RoadmapEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError> {
self.repo.create(entity).await
}
async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError> {
self.repo.update(entity).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
async fn vote(&self, id: Uuid) -> Result<(), AppError> {
self.repo.increment_votes(id).await
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod roadmap;
pub mod repository;
pub mod service;
pub use roadmap::RoadmapEntity;
pub use repository::RoadmapRepository;
pub use service::RoadmapService;
@@ -0,0 +1,19 @@
use super::roadmap::RoadmapEntity;
use async_trait::async_trait;
use imphnen_utils::AppError;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
#[async_trait]
pub trait RoadmapRepository: Send + Sync {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<RoadmapEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<RoadmapEntity, AppError>;
async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError>;
async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn increment_votes(&self, id: Uuid) -> Result<(), AppError>;
}
+14
View File
@@ -0,0 +1,14 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct RoadmapEntity {
pub id: Uuid,
pub title: String,
pub description: String,
pub status: String,
pub votes: i32,
pub is_deleted: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+19
View File
@@ -0,0 +1,19 @@
use super::roadmap::RoadmapEntity;
use async_trait::async_trait;
use imphnen_utils::AppError;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
#[async_trait]
pub trait RoadmapService: Send + Sync {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<RoadmapEntity>, AppError>;
async fn get(&self, id: Uuid) -> Result<RoadmapEntity, AppError>;
async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError>;
async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn vote(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,98 @@
use crate::roadmap::domain::roadmap::RoadmapEntity;
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RoadmapCreateRequestDto {
pub title: String,
pub description: String,
#[schema(example = "upcoming")]
pub status: String,
}
impl ZodValidate for RoadmapCreateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
impl From<RoadmapCreateRequestDto> for RoadmapEntity {
fn from(dto: RoadmapCreateRequestDto) -> Self {
RoadmapEntity {
id: Uuid::new_v4(),
title: dto.title,
description: dto.description,
status: dto.status,
votes: 0,
is_deleted: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RoadmapUpdateRequestDto {
pub title: String,
pub description: String,
#[schema(example = "upcoming")]
pub status: String,
}
impl ZodValidate for RoadmapUpdateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RoadmapListItemDto {
pub id: String,
pub title: String,
pub description: String,
pub status: String,
pub votes: i32,
pub is_deleted: bool,
pub created_at: String,
}
impl From<RoadmapEntity> for RoadmapListItemDto {
fn from(e: RoadmapEntity) -> Self {
RoadmapListItemDto {
id: e.id.to_string(),
title: e.title,
description: e.description,
status: e.status,
votes: e.votes,
is_deleted: e.is_deleted,
created_at: e.created_at.to_rfc3339(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RoadmapDetailItemDto {
pub id: String,
pub title: String,
pub description: String,
pub status: String,
pub votes: i32,
pub created_at: String,
pub updated_at: String,
}
impl From<RoadmapEntity> for RoadmapDetailItemDto {
fn from(e: RoadmapEntity) -> Self {
RoadmapDetailItemDto {
id: e.id.to_string(),
title: e.title,
description: e.description,
status: e.status,
votes: e.votes,
created_at: e.created_at.to_rfc3339(),
updated_at: e.updated_at.to_rfc3339(),
}
}
}
@@ -0,0 +1,208 @@
use super::dto::{
RoadmapCreateRequestDto, RoadmapDetailItemDto, RoadmapListItemDto,
RoadmapUpdateRequestDto,
};
use crate::roadmap::domain::RoadmapService;
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 imphnen_utils::{ApiMessage, ApiPaginated, ApiSuccess};
use paginator_axum::PaginationQuery;
use paginator_utils::PaginatorResponse;
use std::sync::Arc;
use uuid::Uuid;
#[utoipa::path(
get,
path = "/v1/landing/cms/roadmap",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
),
responses(
(status = 200, description = "[PUBLIC] Get roadmap list")
),
tag = "Roadmap"
)]
pub async fn get_roadmap_list(
Extension(service): Extension<Arc<dyn RoadmapService>>,
PaginationQuery(params): PaginationQuery,
) -> Response {
match service.list(params).await {
Ok(result) => {
let mapped = PaginatorResponse {
data: result
.data
.into_iter()
.map(RoadmapListItemDto::from)
.collect::<Vec<_>>(),
meta: result.meta,
};
ApiPaginated(mapped).into_response()
}
Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string())
.into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/landing/cms/roadmap/detail/{id}",
params(
("id" = String, Path, description = "Roadmap item ID")
),
responses(
(status = 200, description = "[PUBLIC] Get roadmap item by ID", body = ResponseSuccessDto<RoadmapDetailItemDto>)
),
tag = "Roadmap"
)]
pub async fn get_roadmap_by_id(
Extension(service): Extension<Arc<dyn RoadmapService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => {
return ApiMessage::new(
axum::http::StatusCode::BAD_REQUEST,
format!("Invalid UUID: {e}"),
)
.into_response();
}
};
match service.get(uuid).await {
Ok(item) => ApiSuccess(RoadmapDetailItemDto::from(item)).into_response(),
Err(e) => ApiMessage::new(axum::http::StatusCode::NOT_FOUND, e.to_string())
.into_response(),
}
}
#[utoipa::path(
post,
security(("Bearer" = [])),
path = "/v1/landing/cms/roadmap/create",
request_body = RoadmapCreateRequestDto,
responses(
(status = 201, description = "[ADMIN] Create new roadmap item")
),
tag = "Roadmap"
)]
pub async fn post_create_roadmap(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn RoadmapService>>,
ValidatedJson(payload): ValidatedJson<RoadmapCreateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let entity = payload.into();
service.create(entity).await?;
Ok(ApiMessage::created("Roadmap item created"))
})
}
#[utoipa::path(
patch,
security(("Bearer" = [])),
path = "/v1/landing/cms/roadmap/update/{id}",
params(
("id" = String, Path, description = "Roadmap item ID")
),
request_body = RoadmapUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Update roadmap item")
),
tag = "Roadmap"
)]
pub async fn patch_update_roadmap(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn RoadmapService>>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<RoadmapUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
let existing = service.get(uuid).await?;
let entity = crate::roadmap::domain::RoadmapEntity {
id: existing.id,
title: payload.title,
description: payload.description,
status: payload.status,
votes: existing.votes,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: chrono::Utc::now(),
};
service.update(entity).await?;
Ok(ApiMessage::ok("Roadmap item updated"))
})
}
#[utoipa::path(
delete,
security(("Bearer" = [])),
path = "/v1/landing/cms/roadmap/delete/{id}",
params(
("id" = String, Path, description = "Roadmap item ID")
),
responses(
(status = 200, description = "[ADMIN] Soft delete roadmap item")
),
tag = "Roadmap"
)]
pub async fn delete_roadmap(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn RoadmapService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
service.delete(uuid).await?;
Ok(ApiMessage::ok("Roadmap item deleted"))
})
}
#[utoipa::path(
post,
path = "/v1/landing/cms/roadmap/vote/{id}",
params(
("id" = String, Path, description = "Roadmap item ID")
),
responses(
(status = 200, description = "[PUBLIC] Vote for a roadmap item")
),
tag = "Roadmap"
)]
pub async fn post_vote_roadmap(
Extension(service): Extension<Arc<dyn RoadmapService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => {
return ApiMessage::new(
axum::http::StatusCode::BAD_REQUEST,
format!("Invalid UUID: {e}"),
)
.into_response();
}
};
match service.vote(uuid).await {
Ok(()) => ApiMessage::ok("Vote recorded").into_response(),
Err(e) => ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, e.to_string())
.into_response(),
}
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{roadmap_protected_routes, roadmap_public_routes};
@@ -0,0 +1,36 @@
use super::handlers::{
delete_roadmap, get_roadmap_by_id, get_roadmap_list, patch_update_roadmap,
post_create_roadmap, post_vote_roadmap,
};
use crate::roadmap::application::RoadmapServiceImpl;
use crate::roadmap::domain::RoadmapService;
use crate::roadmap::infrastructure::persistence::PostgresRoadmapRepository;
use axum::{
Extension, Router,
routing::{delete, get, patch, post},
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;
fn build_service(db: DatabaseConnection) -> Arc<dyn RoadmapService> {
let repo = Arc::new(PostgresRoadmapRepository::new(db));
Arc::new(RoadmapServiceImpl::new(repo))
}
pub fn roadmap_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/roadmap", get(get_roadmap_list))
.route("/roadmap/detail/{id}", get(get_roadmap_by_id))
.route("/roadmap/vote/{id}", post(post_vote_roadmap))
.layer(Extension(service))
}
pub fn roadmap_protected_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/roadmap/create", post(post_create_roadmap))
.route("/roadmap/update/{id}", patch(patch_update_roadmap))
.route("/roadmap/delete/{id}", delete(delete_roadmap))
.layer(Extension(service))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_roadmap_repository;
pub use postgres_roadmap_repository::PostgresRoadmapRepository;
@@ -0,0 +1,171 @@
use crate::roadmap::domain::{roadmap::RoadmapEntity, repository::RoadmapRepository};
use async_trait::async_trait;
use imphnen_entities::seaorm::common::roadmap_items::{
ActiveModel as RoadmapActiveModel, Column as RoadmapColumn, Entity as RoadmapEntity_,
Model as RoadmapModel,
};
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;
fn to_entity(model: RoadmapModel) -> RoadmapEntity {
RoadmapEntity {
id: model.id,
title: model.title,
description: model.description,
status: model.status,
votes: model.votes,
is_deleted: model.is_deleted,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresRoadmapRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresRoadmapRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl RoadmapRepository for PostgresRoadmapRepository {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<RoadmapEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = RoadmapEntity_::find().filter(RoadmapColumn::IsDeleted.eq(false));
if let Some(ref search) = params.search {
query = query.filter(RoadmapColumn::Title.contains(&search.query));
}
query = match params.sort_by.as_deref() {
Some("title") => match params.sort_direction {
Some(SortDirection::Desc) => query.order_by(RoadmapColumn::Title, Order::Desc),
_ => query.order_by(RoadmapColumn::Title, Order::Asc),
},
Some("votes") => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(RoadmapColumn::Votes, Order::Asc),
_ => query.order_by(RoadmapColumn::Votes, Order::Desc),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => {
query.order_by(RoadmapColumn::CreatedAt, Order::Asc)
}
_ => query.order_by(RoadmapColumn::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 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<RoadmapEntity, AppError> {
let item = RoadmapEntity_::find_by_id(id)
.filter(RoadmapColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))?;
Ok(to_entity(item))
}
async fn create(&self, entity: RoadmapEntity) -> Result<(), AppError> {
let active_model = RoadmapActiveModel {
id: ActiveValue::Set(entity.id),
title: ActiveValue::Set(entity.title),
description: ActiveValue::Set(entity.description),
status: ActiveValue::Set(entity.status),
votes: ActiveValue::Set(0),
is_deleted: ActiveValue::Set(false),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
RoadmapEntity_::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn update(&self, entity: RoadmapEntity) -> Result<(), AppError> {
let mut active_model: RoadmapActiveModel = RoadmapEntity_::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))?
.into();
active_model.title = ActiveValue::Set(entity.title);
active_model.description = ActiveValue::Set(entity.description);
active_model.status = ActiveValue::Set(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 delete(&self, id: Uuid) -> Result<(), AppError> {
let mut active_model: RoadmapActiveModel = RoadmapEntity_::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Roadmap item 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(())
}
async fn increment_votes(&self, id: Uuid) -> Result<(), AppError> {
let item = RoadmapEntity_::find_by_id(id)
.filter(RoadmapColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Roadmap item not found".to_string()))?;
let new_votes = item.votes + 1;
let mut active_model: RoadmapActiveModel = item.into();
active_model.votes = ActiveValue::Set(new_votes);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::{roadmap_protected_routes, roadmap_public_routes};
@@ -0,0 +1,3 @@
pub mod testimonial_service;
pub use testimonial_service::TestimonialServiceImpl;
@@ -0,0 +1,48 @@
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;
pub struct TestimonialServiceImpl {
repo: Arc<dyn TestimonialRepository>,
}
impl TestimonialServiceImpl {
pub fn new(repo: Arc<dyn TestimonialRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl TestimonialService for TestimonialServiceImpl {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<TestimonialEntity>, AppError> {
self.repo.find_all(params).await
}
async fn get(&self, id: Uuid) -> Result<TestimonialEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn create(
&self,
entity: TestimonialEntity,
) -> Result<TestimonialEntity, AppError> {
self.repo.create(entity).await
}
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> {
self.repo.update(entity).await
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.delete(id).await
}
}
@@ -0,0 +1,7 @@
pub mod repository;
pub mod service;
pub mod testimonial;
pub use repository::TestimonialRepository;
pub use service::TestimonialService;
pub use testimonial::TestimonialEntity;
@@ -0,0 +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;
#[async_trait]
pub trait TestimonialRepository: Send + Sync {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<TestimonialEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<TestimonialEntity, AppError>;
async fn create(
&self,
entity: TestimonialEntity,
) -> Result<TestimonialEntity, AppError>;
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,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;
#[async_trait]
pub trait TestimonialService: Send + Sync {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<TestimonialEntity>, AppError>;
async fn get(&self, id: Uuid) -> Result<TestimonialEntity, AppError>;
async fn create(
&self,
entity: TestimonialEntity,
) -> Result<TestimonialEntity, AppError>;
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,13 @@
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct TestimonialEntity {
pub id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub role: String,
pub content: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -0,0 +1,83 @@
use crate::testimonials::domain::testimonial::TestimonialEntity;
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zod_rs::prelude::*;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct TestimonialsCreateRequestDto {
#[zod(min_length(1), max_length(100))]
pub role: String,
#[zod(min_length(1), max_length(1000))]
pub content: String,
}
impl ZodValidate for TestimonialsCreateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct TestimonialsUpdateRequestDto {
#[zod(min_length(1), max_length(100))]
pub role: String,
#[zod(min_length(1), max_length(1000))]
pub content: String,
}
impl ZodValidate for TestimonialsUpdateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TestimonialsListItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
pub is_deleted: bool,
}
impl From<TestimonialEntity> for TestimonialsListItemDto {
fn from(e: TestimonialEntity) -> Self {
TestimonialsListItemDto {
id: e.id.to_string(),
user_id: e.user_id.to_string(),
user_fullname: e.user_fullname,
role: e.role,
content: e.content,
created_at: e.created_at,
is_deleted: e.is_deleted,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TestimonialsDetailItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
pub updated_at: String,
}
impl From<TestimonialEntity> for TestimonialsDetailItemDto {
fn from(e: TestimonialEntity) -> Self {
TestimonialsDetailItemDto {
id: e.id.to_string(),
user_id: e.user_id.to_string(),
user_fullname: e.user_fullname,
role: e.role,
content: e.content,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}

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