Compare commits

..
Author SHA1 Message Date
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
677 changed files with 27647 additions and 39592 deletions
+23 -49
View File
@@ -1,51 +1,21 @@
# RUST_ENV : Mode environment aplikasi (misal: development, production)
# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error)
# PORT : Port yang digunakan backend service
# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket)
# SURREALDB_USERNAME : Username untuk SurrealDB
# SURREALDB_PASSWORD : Password untuk SurrealDB
# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan
# SURREALDB_DBNAME : Nama database SurrealDB
# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia)
# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia)
# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP)
# SMTP_PASSWORD : Password email SMTP
# SMTP_NAME : Nama pengirim email (misal: nama aplikasi)
# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com)
# REDISDB_URL : URL/host Redis database
# FE_URL : URL frontend aplikasi (misal: http://localhost)
# MINIO_ENDPOINT : Endpoint MinIO object storage
# MINIO_BUCKET_NAME : Nama bucket default MinIO
# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER
# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD
# MINIO_ROOT_USER : Username root MinIO (default: minioadmin)
# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin)
# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google)
# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google
RUST_ENV=development
RUST_LOG=debug
PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root
SURREALDB_PASSWORD=root
SURREALDB_NAMESPACE=test
SURREALDB_DBNAME=test
ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_NAME="Your App Name"
SMTP_HOST=smtp.gmail.com
REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECURE=false
GOOGLE_CLIENT_ID="your_google_client_id"
RUST_ENV=development
RUST_LOG=debug
PORT=4099
ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_NAME="Your App Name"
SMTP_HOST=smtp.gmail.com
REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECURE=false
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
POOL_SIZE=10
CONNECT_TIMEOUT=30
@@ -56,4 +26,8 @@ IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
SSLMODE=require
RETRY_ATTEMPTS=3
RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback
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
+791 -125
View File
File diff suppressed because it is too large Load Diff
+28 -15
View File
@@ -1,18 +1,20 @@
[workspace]
resolver = "2"
members = [
"tests",
"imphnen-entities", # Most basic - core data structures
"imphnen-macros", # Macros
"imphnen-libs", # Depends on entities
"imphnen-utils", # Depends on libs and entities
"imphnen-middleware",# Utility for permissions
"imphnen-iam", # Core auth service, depends on libs, utils, entities
"imphnen-cms", # Content management, depends on core services
"imphnen-gacha", # Game mechanics, depends on core services
"imphnen-dimentorin",# Learning platform, depends on core services
"imphnen-gateway", # API gateway, depends on all services
"imphnen-backend", # Main application, depends on all services
"imphnen-entities",
"imphnen-macros",
"imphnen-libs",
"imphnen-storage",
"imphnen-email",
"imphnen-utils",
"imphnen-middleware",
"imphnen-iam",
"imphnen-cms",
"imphnen-gacha",
"imphnen-dimentorin",
"imphnen-hackathon",
"imphnen-gateway",
"imphnen-backend",
]
@@ -28,7 +30,7 @@ tokio = { version = "1.47.1", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1"
chrono = "0.4.41"
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
thiserror = "2.0.14"
@@ -37,7 +39,12 @@ rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] }
zod-rs = { version = "0.4", features = ["macros"] }
zod-rs-util = "0.4"
paginator-rs = "0.2"
paginator-utils = "0.2"
paginator-sea-orm = { version = "0.2", features = ["sqlx-postgres", "runtime-tokio"] }
paginator-axum = "0.2"
lazy_static = "1.5.0"
regex = "1.11.1"
axum-test = "17.3.0"
@@ -59,6 +66,7 @@ hyper = "1.6.0"
hyper-util = "0.1.16"
minio = "0.3.0"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "json", "macros"] }
num_cpus = "1.16.0"
@@ -71,7 +79,6 @@ tokio-tungstenite = "0.27"
url = "2.5"
futures-util = "0.3"
http = "1.3"
tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" }
imphnen-libs = { path = "./imphnen-libs" }
@@ -83,6 +90,12 @@ imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
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]
lto = "fat"
+55 -47
View File
@@ -1,47 +1,55 @@
FROM rust:1.86-alpine AS builder
RUN apk add --no-cache \
curl \
musl-dev \
openssl-dev \
openssl-libs-static \
pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p imphnen-backend/src imphnen-cms/src imphnen-dimentorin/src \
imphnen-entities/src imphnen-gacha/src imphnen-gateway/src \
imphnen-iam/src imphnen-libs/src imphnen-middleware/src \
imphnen-utils/src tests/src && \
echo "fn main() {}" > imphnen-backend/src/main.rs && \
find . -name "src" -type d -exec sh -c 'echo "// dummy" > "$1/lib.rs"' _ {} \;
RUN echo '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
RUN echo -e '[package]\nname = "tests"\nversion = "0.1.0"\nedition = "2021"' > tests/Cargo.toml
COPY imphnen-backend ./imphnen-backend
COPY imphnen-cms ./imphnen-cms
COPY imphnen-dimentorin ./imphnen-dimentorin
COPY imphnen-entities ./imphnen-entities
COPY imphnen-gacha ./imphnen-gacha
COPY imphnen-gateway ./imphnen-gateway
COPY imphnen-iam ./imphnen-iam
COPY imphnen-libs ./imphnen-libs
COPY imphnen-middleware ./imphnen-middleware
COPY imphnen-utils ./imphnen-utils
COPY tests ./tests
RUN RUSTFLAGS="-C target-cpu=generic -C opt-level=s -C panic=abort -C codegen-units=1 -C strip=symbols" \
cargo build -p imphnen-backend --release && \
strip target/release/api && \
upx --best --lzma target/release/api 2>/dev/null || true
FROM scratch AS runner
COPY --from=builder /app/target/release/api /api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/api"]
FROM rust:1.86-alpine AS builder
RUN apk add --no-cache \
curl \
musl-dev \
openssl-dev \
openssl-libs-static \
pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p \
imphnen-backend/src \
imphnen-cms/src \
imphnen-dimentorin/src \
imphnen-email/src \
imphnen-entities/src \
imphnen-gacha/src \
imphnen-gateway/src \
imphnen-hackathon/src \
imphnen-iam/src \
imphnen-libs/src \
imphnen-macros/src \
imphnen-middleware/src \
imphnen-storage/src \
imphnen-utils/src && \
echo "fn main() {}" > imphnen-backend/src/main.rs && \
find . -name "src" -type d -exec sh -c 'touch "$1/lib.rs"' _ {} \;
COPY imphnen-backend ./imphnen-backend
COPY imphnen-cms ./imphnen-cms
COPY imphnen-dimentorin ./imphnen-dimentorin
COPY imphnen-email ./imphnen-email
COPY imphnen-entities ./imphnen-entities
COPY imphnen-gacha ./imphnen-gacha
COPY imphnen-gateway ./imphnen-gateway
COPY imphnen-hackathon ./imphnen-hackathon
COPY imphnen-iam ./imphnen-iam
COPY imphnen-libs ./imphnen-libs
COPY imphnen-macros ./imphnen-macros
COPY imphnen-middleware ./imphnen-middleware
COPY imphnen-storage ./imphnen-storage
COPY imphnen-utils ./imphnen-utils
RUN RUSTFLAGS="-C target-cpu=generic -C opt-level=s -C panic=abort -C codegen-units=1 -C strip=symbols" \
cargo build -p imphnen-backend --release && \
strip target/release/api && \
upx --best --lzma target/release/api 2>/dev/null || true
FROM scratch AS runner
COPY --from=builder /app/target/release/api /api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/api"]
-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"
+25 -15
View File
@@ -1,15 +1,25 @@
{pkgs ? import <nixpkgs> {}}: let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
rustDeps = pkgs.callPackage ./Cargo.nix {};
packageEntry = rustDeps.workspaceMembers.${manifest.name};
deps = packageEntry.build.cargoDeps or null;
in
pkgs.rustPlatform.buildRustPackage {
pname = manifest.name;
version = manifest.version;
cargoDeps = deps;
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [pkgs.openssl pkgs.pkg-config];
buildInputs = [pkgs.openssl];
}
{ pkgs ? import <nixpkgs> { } }:
let
swaggerUi = pkgs.fetchurl {
url = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.17.14.zip";
hash = "sha256-SBJE0IEgl7Efuu73n3HZQrFxYX+cn5UU5jrL4T5xzNw=";
};
in
pkgs.rustPlatform.buildRustPackage {
pname = "imphnen-backend";
version = (pkgs.lib.importTOML ./imphnen-backend/Cargo.toml).package.version;
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
cargoBuildFlags = [
"--package"
"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 {
imageName = "ubuntu";
tag = "latest";
};
in
pkgs.dockerTools.buildImage {
name = "imphnen-cms-api";
pkgs.dockerTools.buildImage {
name = "imphnen-backend-service";
fromImage = baseImage;
fromImage = baseImage;
copyToRoot = pkgs.buildEnv {
name = "imphnen-cms-api";
paths = [
(pkgs.stdenv.mkDerivation {
name = "imphnen-cms-api";
src = ./src;
copyToRoot = pkgs.buildEnv {
name = "imphnen-backend-service";
paths = [
(pkgs.stdenv.mkDerivation {
name = "imphnen-backend-service";
src = ./src;
buildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.openssl
pkgs.pkg-config
];
buildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.openssl
pkgs.pkg-config
];
buildPhase = ''
cargo build --release
'';
buildPhase = ''
cargo build --release
'';
installPhase = ''
mkdir -p $out/bin
cp target/release/najm-course-api $out/bin/
'';
})
];
};
installPhase = ''
mkdir -p $out/bin
cp target/release/imphnen-backend-service $out/bin/
'';
})
];
};
config = {
Cmd = ["/bin/imphnen-cms-api"];
WorkingDir = "/bin";
};
}
config = {
Cmd = [ "/bin/imphnen-backend-service" ];
WorkingDir = "/bin";
};
}
Generated
+27 -27
View File
@@ -1,27 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1739020877,
"narHash": "sha256-mIvECo/NNdJJ/bXjNqIh8yeoSjVLAuDuTUzAo7dzs8Y=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a79cfe0ebd24952b580b1cf08cd906354996d547",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+47 -32
View File
@@ -1,32 +1,47 @@
{
description = "IMPHNEN Backend Service Nix Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-darwin" "aarch64-linux"];
pkgsFor = system:
import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
};
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in {
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix {};
});
devShells = forAllSystems (system: {
default = (pkgsFor system).callPackage ./shell.nix {};
});
dockerImages = forAllSystems (system: {
tryOutApi = (pkgsFor system).callPackage ./docker.nix {};
});
};
}
{
description = "IMPHNEN Backend Service Nix Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-darwin"
"aarch64-linux"
];
pkgsFor =
system:
import nixpkgs {
inherit system;
config.allowUnfree = true;
};
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system: {
default = (pkgsFor system).callPackage ./default.nix { };
});
overlays.default = final: _prev: {
imphnen-backend = final.callPackage ./default.nix { };
};
nixosModules.backend = ./nixos-module.nix;
devShells = forAllSystems (system: {
default = (pkgsFor system).callPackage ./shell.nix { };
});
dockerImages = forAllSystems (system: {
tryOutApi = (pkgsFor system).callPackage ./docker.nix { };
});
};
}
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "imphnen-backend"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
[[bin]]
@@ -63,7 +63,6 @@ serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
axum-test.workspace = true
rand.workspace = true
tokio.workspace = true
+10 -14
View File
@@ -1,14 +1,10 @@
// API entry point using PostgreSQL (SurrealDB migration complete)
// This file has been updated to use SeaORM with PostgreSQL instead of SurrealDB
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
axum_init(|postgres_db| async {
// Gateway service now uses PostgreSQL exclusively (SeaORM)
// SurrealDB dependencies have been completely removed
gateway_service(postgres_db).await
})
.await;
}
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
axum_init(|postgres_db| async {
gateway_service(postgres_db).await
})
.await;
}
+91 -93
View File
@@ -1,93 +1,91 @@
#![allow(clippy::all)]
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{Statement, ConnectionTrait};
use std::error::Error;
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
// New default behavior: execute by default; use --dry-run to preview only.
let dry_run = args.iter().any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry");
let force = args.iter().any(|s| s == "--force" || s == "-f");
println!("🔎 Clear DB script - WARNING: This will remove data from tables\n");
println!("Note: script now runs by default (no --yes required). To preview without executing, use --dry-run.\n");
// List of tables to truncate (order doesn't matter with CASCADE)
let tables = vec![
"gacha_claims",
"gacha_rolls",
"gacha_items",
"gacha_credits",
"audit_logs",
"rate_limits",
"testimonials",
"events",
"app_mentors",
"app_sessions",
"app_roles_permissions",
"app_permissions",
"app_roles",
"app_users",
];
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
// Filter tables that actually exist in the database
let mut existing_tables: Vec<&str> = vec![];
for t in tables.iter() {
let check_sql = format!(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;",
t
);
let stmt = Statement::from_string(db.get_database_backend(), check_sql);
if let Ok(Some(row)) = pg_conn.query_one(stmt).await {
let exists_val: Option<bool> = row.try_get("", "exists").ok();
if exists_val.unwrap_or(false) {
existing_tables.push(t);
}
}
}
if existing_tables.is_empty() {
println!("No configured tables found to clear - nothing to do.");
return Ok(());
}
let truncate_sql = format!(
"TRUNCATE TABLE {} RESTART IDENTITY CASCADE;",
existing_tables.join(", ")
);
println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql);
// Prevent accidental execution in production without explicit force flag
let env_name = std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string());
if env_name == "production" && !force {
println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override.");
return Ok(());
}
if dry_run {
println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production).");
return Ok(());
}
println!("Executing truncate...\n");
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let stmt = Statement::from_string(db.get_database_backend(), truncate_sql);
match pg_conn.execute(stmt).await {
Ok(_) => println!("✅ Successfully cleared DB tables"),
Err(e) => println!("❌ Failed to clear DB tables: {}", e),
}
Ok(())
}
#![allow(clippy::all)]
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{ConnectionTrait, Statement};
use std::env;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
let dry_run = args
.iter()
.any(|s| s == "--dry-run" || s == "--no-exec" || s == "--dry");
let force = args.iter().any(|s| s == "--force" || s == "-f");
println!("🔎 Clear DB script - WARNING: This will remove data from tables\n");
println!("Note: script now runs by default (no --yes required). To preview without executing, use --dry-run.\n");
let tables = vec![
"gacha_claims",
"gacha_rolls",
"gacha_items",
"gacha_credits",
"audit_logs",
"rate_limits",
"testimonials",
"events",
"app_mentors",
"app_sessions",
"app_roles_permissions",
"app_permissions",
"app_roles",
"app_users",
];
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let mut existing_tables: Vec<&str> = vec![];
for t in tables.iter() {
let check_sql = format!(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{}') as exists;",
t
);
let stmt = Statement::from_string(db.get_database_backend(), check_sql);
if let Ok(Some(row)) = pg_conn.query_one(stmt).await {
let exists_val: Option<bool> = row.try_get("", "exists").ok();
if exists_val.unwrap_or(false) {
existing_tables.push(t);
}
}
}
if existing_tables.is_empty() {
println!("No configured tables found to clear - nothing to do.");
return Ok(());
}
let truncate_sql = format!(
"TRUNCATE TABLE {} RESTART IDENTITY CASCADE;",
existing_tables.join(", ")
);
println!("The script will run the following SQL (on the DB configured by env vars):\n\n{}", truncate_sql);
let env_name = imphnen_libs::ENV.rust_env.clone();
if env_name == "production" && !force {
println!("Security: RUST_ENV=production; the script will NOT run without --force. Use --force to override.");
return Ok(());
}
if dry_run {
println!("Dry run enabled. No changes applied. To execute, re-run without --dry-run or use --force (in production).");
return Ok(());
}
println!("Executing truncate...\n");
let postgres_config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(postgres_config).await?;
let db = &pg_conn.conn;
let stmt = Statement::from_string(db.get_database_backend(), truncate_sql);
match pg_conn.execute(stmt).await {
Ok(_) => println!("✅ Successfully cleared DB tables"),
Err(e) => println!("❌ Failed to clear DB tables: {}", e),
}
Ok(())
}
+58 -47
View File
@@ -1,65 +1,76 @@
#![allow(clippy::all)]
use sea_orm::{ConnectionTrait, Database, Schema, DbBackend, EntityTrait};
use imphnen_libs::postgres::PostgresConfig;
use imphnen_entities::seaorm::{auth, common, gacha};
use sea_orm::sea_query::Table;
use imphnen_libs::postgres::PostgresConfig;
use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, Database, DbBackend, EntityTrait, Schema};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🛠️ Creating database schema...");
let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?;
let builder = db.get_database_backend();
println!("🛠️ Creating database schema...");
println!(" Database connected. Creating/updating tables...");
let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?;
let builder = db.get_database_backend();
// Dropping and recreating tables to ensure schema is up-to-date
// This is safer for development/testing environments to prevent schema drift.
drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?;
drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity).await?;
drop_and_create_table(&db, builder, "app_users", auth::users::Entity).await?;
drop_and_create_table(&db, builder, "app_roles_permissions", auth::roles_permissions::Entity).await?;
drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?;
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity).await?;
drop_and_create_table(&db, builder, "events", common::events::Entity).await?;
drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity).await?;
drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity).await?;
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity).await?;
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity).await?;
drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity).await?;
drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity).await?;
drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity).await?;
println!(" Database connected. Creating/updating tables...");
println!("✅ Schema creation completed.");
Ok(())
drop_and_create_table(&db, builder, "app_roles", auth::roles::Entity).await?;
drop_and_create_table(&db, builder, "app_permissions", auth::permissions::Entity)
.await?;
drop_and_create_table(&db, builder, "app_users", auth::users::Entity).await?;
drop_and_create_table(
&db,
builder,
"app_roles_permissions",
auth::roles_permissions::Entity,
)
.await?;
drop_and_create_table(&db, builder, "app_mentors", auth::mentors::Entity).await?;
drop_and_create_table(&db, builder, "app_sessions", auth::sessions::Entity)
.await?;
drop_and_create_table(&db, builder, "events", common::events::Entity).await?;
drop_and_create_table(&db, builder, "testimonials", common::testimonials::Entity)
.await?;
drop_and_create_table(&db, builder, "audit_logs", common::audit_log::Entity)
.await?;
drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity)
.await?;
drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity)
.await?;
drop_and_create_table(&db, builder, "gacha_items", gacha::gacha_items::Entity)
.await?;
drop_and_create_table(&db, builder, "gacha_rolls", gacha::gacha_rolls::Entity)
.await?;
drop_and_create_table(&db, builder, "gacha_claims", gacha::gacha_claims::Entity)
.await?;
println!("✅ Schema creation completed.");
Ok(())
}
async fn drop_and_create_table<E>(
db: &sea_orm::DatabaseConnection,
builder: DbBackend,
name: &str,
entity: E,
) -> Result<(), Box<dyn std::error::Error>> // Return Result
db: &sea_orm::DatabaseConnection,
builder: DbBackend,
name: &str,
entity: E,
) -> Result<(), Box<dyn std::error::Error>>
where
E: EntityTrait,
E: EntityTrait,
{
let schema = Schema::new(builder);
let schema = Schema::new(builder);
// Drop table if it exists
let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned(); // Added .cascade()
db.execute(builder.build(&drop_stmt)).await?; // Propagate error
println!(" Dropped table if exists: {}", name);
let drop_stmt = Table::drop().table(entity).if_exists().cascade().to_owned();
db.execute(builder.build(&drop_stmt)).await?;
println!(" Dropped table if exists: {}", name);
// Create table
let mut create_stmt = schema.create_table_from_entity(entity);
create_stmt.if_not_exists();
let mut create_stmt = schema.create_table_from_entity(entity);
create_stmt.if_not_exists();
db.execute(builder.build(&create_stmt)).await?; // Propagate error
println!(" ✅ Created table: {}", name);
db.execute(builder.build(&create_stmt)).await?;
println!(" ✅ Created table: {}", name);
Ok(())
}
Ok(())
}
+20 -21
View File
@@ -1,21 +1,20 @@
#![allow(clippy::all)]
use imphnen_libs::jsonwebtoken::encode_access_token;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: mk_token <email_or_sub>");
std::process::exit(1);
}
let sub = args[1].clone();
// Use sub as both sub and user_id
match encode_access_token(sub.clone(), sub.clone()) {
Ok(token) => println!("{}", token),
Err(e) => {
eprintln!("Failed to generate token: {:?}", e);
std::process::exit(2);
}
}
}
#![allow(clippy::all)]
use imphnen_libs::jsonwebtoken::encode_access_token;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: mk_token <email_or_sub>");
std::process::exit(1);
}
let sub = args[1].clone();
match encode_access_token(sub.clone(), sub.clone()) {
Ok(token) => println!("{}", token),
Err(e) => {
eprintln!("Failed to generate token: {:?}", e);
std::process::exit(2);
}
}
}
+27 -17
View File
@@ -1,11 +1,15 @@
#![allow(clippy::all)]
use std::error::Error;
use chrono::Utc;
use imphnen_entities::seaorm::common::events::{
ActiveModel as EventsActiveModel, Entity as EventEntity,
};
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::common::events::{ActiveModel as EventsActiveModel, Entity as EventEntity};
use sea_orm::{ActiveValue::Set, ActiveModelTrait, EntityTrait, ColumnTrait, QueryFilter};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter,
};
use std::error::Error;
use uuid::Uuid;
use chrono::Utc; // Removed NaiveDateTime as it was unused
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
@@ -54,7 +58,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-09-20T13:00:00Z",
"2025-09-22T15:00:00Z",
),
// Additional Events
(
"Rust Programming Bootcamp",
"Intensive 3-day bootcamp to master Rust fundamentals and advanced concepts.",
@@ -154,18 +157,20 @@ async fn main() -> Result<(), Box<dyn Error>> {
price,
location,
is_online,
start_date_str, // Renamed to avoid conflict
end_date_str, // Renamed to avoid conflict
start_date_str,
end_date_str,
) in events
{
// Check if event already exists by name
let existing = EventEntity::find().filter(<EventEntity as EntityTrait>::Column::Name.eq(name)).one(db).await?;
let existing = EventEntity::find()
.filter(<EventEntity as EntityTrait>::Column::Name.eq(name))
.one(db)
.await?;
if existing.is_some() {
println!("️ Skipping (already exists): {name}");
continue;
}
let uuid = Uuid::new_v4(); // Generate a Uuid
let uuid = Uuid::new_v4();
let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(uuid);
event_model.name = Set(name.to_string());
@@ -174,12 +179,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
event_model.price = Set(price);
event_model.is_online = Set(is_online);
event_model.location = Set(location.clone());
event_model.start_date = Set(chrono::DateTime::parse_from_rfc3339(start_date_str)?.with_timezone(&chrono::Utc));
event_model.end_date = Set(chrono::DateTime::parse_from_rfc3339(end_date_str)?.with_timezone(&chrono::Utc));
event_model.is_deleted = Set(false); // Explicitly set is_deleted
event_model.created_at = Set(Utc::now()); // Explicitly set created_at
event_model.updated_at = Set(Utc::now()); // Explicitly set updated_at
event_model.start_date = Set(
chrono::DateTime::parse_from_rfc3339(start_date_str)?
.with_timezone(&chrono::Utc),
);
event_model.end_date = Set(
chrono::DateTime::parse_from_rfc3339(end_date_str)?
.with_timezone(&chrono::Utc),
);
event_model.is_deleted = Set(false);
event_model.created_at = Set(Utc::now());
event_model.updated_at = Set(Utc::now());
event_model.insert(db).await?;
@@ -192,4 +202,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
println!("✅ All Events seeded");
Ok(())
}
}
+31 -25
View File
@@ -1,13 +1,13 @@
#![allow(clippy::all)]
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::gacha::gacha_items::ActiveModel as GachaItemActiveModel;
use imphnen_entities::seaorm::gacha::gacha_rolls::ActiveModel as GachaRollActiveModel;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveModelTrait;
use sea_orm::ActiveValue::Set;
use uuid::Uuid;
use sea_orm::ConnectionTrait;
use std::error::Error;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
@@ -15,18 +15,25 @@ async fn main() -> Result<(), Box<dyn Error>> {
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Check if gacha item already exists
let check_item_sql = "SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1";
let item_result = pg_conn.query_one(sea_orm::Statement::from_string(db.get_database_backend(), check_item_sql)).await?;
let check_item_sql =
"SELECT id FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1' LIMIT 1";
let item_result = pg_conn
.query_one(sea_orm::Statement::from_string(
db.get_database_backend(),
check_item_sql,
))
.await?;
let gacha_item_uuid = if let Some(ref row) = item_result {
// Item exists, get its ID
row.try_get("", "id")?
} else {
// Item doesn't exist, create it
// Note: We can't easily delete by a fixed ID since it's a UUID, but the insert will fail if there's a conflict
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string())).await.ok();
// Create gacha item via SeaORM
let _ = pg_conn
.execute(sea_orm::Statement::from_string(
db.get_database_backend(),
"DELETE FROM app_gacha_items WHERE item_code = 'ITEM_TEST_1'".to_string(),
))
.await
.ok();
let new_uuid = Uuid::new_v4();
let mut item_model: GachaItemActiveModel = Default::default();
item_model.id = Set(new_uuid);
@@ -47,7 +54,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
new_uuid
};
// Always try to insert the roll, relying on the database constraints to prevent duplicates if needed
let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id);
@@ -61,18 +67,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?;
println!("Gacha Roll seeded successfully!");
let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id);
roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
roll_model.gacha_id = Set(Uuid::new_v4().to_string());
roll_model.item_id = Set(gacha_item_uuid);
roll_model.weight = Set(1.0);
roll_model.quantity = Set(10);
roll_model.is_deleted = Set(false);
roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?;
let gacha_roll_id = Uuid::new_v4();
let mut roll_model: GachaRollActiveModel = Default::default();
roll_model.id = Set(gacha_roll_id);
roll_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
roll_model.gacha_id = Set(Uuid::new_v4().to_string());
roll_model.item_id = Set(gacha_item_uuid);
roll_model.weight = Set(1.0);
roll_model.quantity = Set(10);
roll_model.is_deleted = Set(false);
roll_model.created_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.updated_at = Set(Some(chrono::Utc::now().naive_utc()));
roll_model.insert(db).await?;
println!("✅ Gacha items and rolls seeded.");
Ok(())
}
+34 -16
View File
@@ -1,13 +1,18 @@
#![allow(clippy::all)]
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use imphnen_entities::seaorm::auth::roles::{
Column as RoleColumn, Entity as RoleEntity,
};
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use imphnen_libs::hash_password;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait,
QueryFilter,
};
use serde_json::json;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use imphnen_entities::seaorm::auth::roles::{Entity as RoleEntity, Column as RoleColumn};
use sea_orm::{ActiveModelTrait, ConnectionTrait, ActiveValue::Set, EntityTrait, QueryFilter, ColumnTrait};
use uuid::Uuid;
#[tokio::main]
@@ -16,17 +21,28 @@ async fn main() -> Result<(), Box<dyn Error>> {
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_mentors WHERE id = 'e6f78d23-83bf-5c2b-bcd4-001345678901'".to_string())).await.ok();
let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), "DELETE FROM app_users WHERE email = 'mentor@example.com'".to_string())).await.ok();
let _ = pg_conn
.execute(sea_orm::Statement::from_string(
db.get_database_backend(),
"DELETE FROM app_mentors WHERE id = 'e6f78d23-83bf-5c2b-bcd4-001345678901'"
.to_string(),
))
.await
.ok();
let _ = pg_conn
.execute(sea_orm::Statement::from_string(
db.get_database_backend(),
"DELETE FROM app_users WHERE email = 'mentor@example.com'".to_string(),
))
.await
.ok();
// Find Mentor role
let role = RoleEntity::find()
.filter(RoleColumn::Name.eq("Mentor"))
.one(db)
.await?
.ok_or("Role 'Mentor' not found")?;
// Insert user with Mentor role
let user_id = Uuid::new_v4();
let mut user_model: UsersActiveModel = Default::default();
user_model.id = Set(user_id);
@@ -43,21 +59,23 @@ async fn main() -> Result<(), Box<dyn Error>> {
user_model.updated_at = Set(chrono::Utc::now());
user_model.insert(db).await?;
// Insert mentor
let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id);
mentor_model.user_id = Set(user_id);
mentor_model.industries = Set(Some(json!( ["Software", "Education"] )));
mentor_model.expertise = Set(Some(json!( ["Rust", "Microservices"] )));
mentor_model.languages = Set(Some(json!( ["Indonesian", "English"] )));
mentor_model.industries = Set(Some(json!(["Software", "Education"])));
mentor_model.expertise = Set(Some(json!(["Rust", "Microservices"])));
mentor_model.languages = Set(Some(json!(["Indonesian", "English"])));
mentor_model.current_company = Set(Some("PT Contoh".to_string()));
mentor_model.current_role = Set(Some("Senior Backend Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5));
mentor_model.topics_of_interest = Set(Some(json!( ["Rust Programming", "Backend Development"] )));
mentor_model.topics_of_interest =
Set(Some(json!(["Rust Programming", "Backend Development"])));
mentor_model.preferred_mentee_level = Set(Some("beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["online", "offline"] )));
mentor_model.availability_commitment = Set(Some("2 jam per minggu untuk mentoring online dan offline".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!(["online", "offline"])));
mentor_model.availability_commitment = Set(Some(
"2 jam per minggu untuk mentoring online dan offline".to_string(),
));
mentor_model.mentoring_rate = Set(Some(100000.0));
mentor_model.status = Set(Some("verified".to_string()));
mentor_model.is_deleted = Set(false);
+79 -81
View File
@@ -1,81 +1,79 @@
#![allow(clippy::all)]
use imphnen_iam::PermissionsEnum;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::permissions::ActiveModel as PermissionActiveModel;
use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait};
use uuid::Uuid;
use chrono::Utc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
for permission in [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
PermissionsEnum::Administrator,
] {
// permission.id() returns a string, try parse to uuid
let parsed_id = Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4());
// Check if permission already exists
let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {permission}");
continue;
}
// Insert permission using active model
let mut perm_model: PermissionActiveModel = Default::default();
perm_model.id = Set(parsed_id);
perm_model.name = Set(permission.to_string());
perm_model.is_deleted = Set(false);
perm_model.created_at = Set(Utc::now());
perm_model.updated_at = Set(Utc::now());
perm_model.insert(db).await?;
println!("✅ Inserted: {permission}");
}
println!("✅ All Permissions seeded");
Ok(())
}
#![allow(clippy::all)]
use chrono::Utc;
use imphnen_entities::seaorm::auth::permissions::ActiveModel as PermissionActiveModel;
use imphnen_entities::seaorm::auth::permissions::Entity as PermissionEntity;
use imphnen_iam::PermissionsEnum;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveModelTrait;
use sea_orm::ActiveValue::Set;
use std::error::Error;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
for permission in [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
PermissionsEnum::Administrator,
] {
let parsed_id =
Uuid::parse_str(&permission.id()).unwrap_or_else(|_| Uuid::new_v4());
let existing = PermissionEntity::find_by_id(parsed_id).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {permission}");
continue;
}
let mut perm_model: PermissionActiveModel = Default::default();
perm_model.id = Set(parsed_id);
perm_model.name = Set(permission.to_string());
perm_model.is_deleted = Set(false);
perm_model.created_at = Set(Utc::now());
perm_model.updated_at = Set(Utc::now());
perm_model.insert(db).await?;
println!("✅ Inserted: {permission}");
}
println!("✅ All Permissions seeded");
Ok(())
}
+9 -13
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_entities::seaorm::auth::roles::{RoleBuilder, Entity as RoleEntity};
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait};
use std::error::Error;
use uuid::Uuid;
use chrono::Utc; // Added chrono
#[tokio::main]
async fn main() -> Result<(), Box<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());
// Check if role already exists
let existing = RoleEntity::find_by_id(uuid).one(db).await?;
if existing.is_some() {
println!("️ Skipping (already exists): {name}");
continue;
}
// Delete existing by id to avoid duplicates (original logic, replaced by existence check)
// let _ = pg_conn.execute(sea_orm::Statement::from_string(db.get_database_backend(), format!("DELETE FROM app_roles WHERE id = '{}'", uuid))).await.ok();
let role_model = RoleBuilder::new()
.name(name.to_string())
.description("System generated role".to_string())
@@ -71,13 +67,13 @@ async fn main() -> Result<(), Box<dyn Error>> {
.build()?;
let mut role_model = role_model;
role_model.id = Set(uuid);
role_model.is_system_role = Set(true); // Set the missing field
role_model.created_at = Set(Utc::now()); // Set created_at
role_model.updated_at = Set(Utc::now()); // Set updated_at
role_model.is_system_role = Set(true);
role_model.created_at = Set(Utc::now());
role_model.updated_at = Set(Utc::now());
role_model.insert(db).await?;
println!("✅ Inserted role: {name}");
}
println!("✅ All Roles seeded");
Ok(())
}
}
+109 -112
View File
@@ -1,112 +1,109 @@
use imphnen_iam::PermissionsEnum;
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
use imphnen_entities::seaorm::auth::roles::ActiveModel as RoleActiveModel;
use sea_orm::ActiveValue::Set;
use sea_orm::EntityTrait;
use sea_orm::ActiveModelTrait;
use uuid::Uuid;
use serde_json::Value as JsonValue;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Ensure indexes are present if needed (placeholders) - we don't modify schema here
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'.");
let roles_permissions = vec![
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
vec![
// Only Administrator permission - grants access to everything
PermissionsEnum::Administrator,
],
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
],
),
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![
// Staff should be able to list roles and permissions in tests
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
vec![PermissionsEnum::ActivateUsers],
),
("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]),
];
for (role_id, permissions) in roles_permissions {
let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4());
// Map permissions enum to JSON array of permission ids
let json_permissions = JsonValue::Array(
permissions.iter().map(|p| JsonValue::String(p.id())).collect()
);
// Find role and update permissions
if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? {
let mut am: RoleActiveModel = role_model.into();
am.permissions = Set(Some(json_permissions));
am.update(db).await?;
println!("✅ Permissions updated for role: {role_id}");
} else {
println!("⚠️ Role with id {role_id} not found, skipping permissions update");
}
}
println!("✅ All roles permissions updated!");
Ok(())
}
use imphnen_entities::seaorm::auth::roles::ActiveModel as RoleActiveModel;
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
use imphnen_iam::PermissionsEnum;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveModelTrait;
use sea_orm::ActiveValue::Set;
use sea_orm::EntityTrait;
use serde_json::Value as JsonValue;
use std::error::Error;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
println!(
"✅ Index 'user_email_index' defined on table 'users' for column 'email'."
);
let roles_permissions = vec![
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
vec![PermissionsEnum::Administrator],
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
],
),
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
vec![PermissionsEnum::ActivateUsers],
),
("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]),
];
for (role_id, permissions) in roles_permissions {
let role_uuid = Uuid::parse_str(role_id).unwrap_or_else(|_| Uuid::new_v4());
let json_permissions = JsonValue::Array(
permissions
.iter()
.map(|p| JsonValue::String(p.id()))
.collect(),
);
if let Some(role_model) = RolesEntity::find_by_id(role_uuid).one(db).await? {
let mut am: RoleActiveModel = role_model.into();
am.permissions = Set(Some(json_permissions));
am.update(db).await?;
println!("✅ Permissions updated for role: {role_id}");
} else {
println!("⚠️ Role with id {role_id} not found, skipping permissions update");
}
}
println!("✅ All roles permissions updated!");
Ok(())
}
+66 -64
View File
@@ -1,77 +1,79 @@
#![allow(clippy::all)]
use std::error::Error;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use chrono::Utc;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use imphnen_entities::seaorm::common::events::ActiveModel as EventsActiveModel;
use imphnen_entities::seaorm::common::testimonials::ActiveModel as TestimonialsActiveModel;
use imphnen_entities::seaorm::auth::mentors::ActiveModel as MentorsActiveModel;
use sea_orm::ActiveValue::Set;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use sea_orm::ActiveModelTrait;
use uuid::Uuid;
use sea_orm::ActiveValue::Set;
use serde_json::json;
use chrono::Utc;
use std::error::Error;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
let config = PostgresConfig::from_env()?;
let pg_conn = PostgresConnection::new(config).await?;
let db = &pg_conn.conn;
// Seed Events - handle existing data
let uuid = Uuid::new_v4().to_string();
let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(Uuid::parse_str(&uuid)?);
event_model.name = Set("Test Event".to_string());
event_model.description = Set("Test event description".to_string());
event_model.detail_link = Set("https://example.com/event".to_string());
event_model.price = Set(50.0);
event_model.is_online = Set(true);
event_model.start_date = Set(Utc::now());
event_model.end_date = Set(Utc::now() + chrono::Duration::days(1));
event_model.location = Set(None);
event_model.is_deleted = Set(false);
match event_model.insert(db).await {
Ok(_) => println!("✅ Inserted test event"),
Err(_) => println!("⚠️ Test event already exists or could not be inserted, skipping"),
};
let uuid = Uuid::new_v4().to_string();
let mut event_model: EventsActiveModel = Default::default();
event_model.id = Set(Uuid::parse_str(&uuid)?);
event_model.name = Set("Test Event".to_string());
event_model.description = Set("Test event description".to_string());
event_model.detail_link = Set("https://example.com/event".to_string());
event_model.price = Set(50.0);
event_model.is_online = Set(true);
event_model.start_date = Set(Utc::now());
event_model.end_date = Set(Utc::now() + chrono::Duration::days(1));
event_model.location = Set(None);
event_model.is_deleted = Set(false);
match event_model.insert(db).await {
Ok(_) => println!("✅ Inserted test event"),
Err(_) => {
println!("⚠️ Test event already exists or could not be inserted, skipping")
}
};
// Seed Testimonials - handle existing data
let mut testimonial_model: TestimonialsActiveModel = Default::default();
testimonial_model.id = Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?);
testimonial_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
testimonial_model.role = Set("Student".to_string());
testimonial_model.content = Set("This is a great platform!".to_string());
testimonial_model.is_deleted = Set(false);
match testimonial_model.insert(db).await {
Ok(_) => println!("✅ Inserted test testimonial"),
Err(_) => println!("⚠️ Test testimonial already exists or could not be inserted, skipping"),
};
let mut testimonial_model: TestimonialsActiveModel = Default::default();
testimonial_model.id =
Set(Uuid::parse_str("00000000-0000-0000-0000-000000000001")?);
testimonial_model.user_id =
Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
testimonial_model.role = Set("Student".to_string());
testimonial_model.content = Set("This is a great platform!".to_string());
testimonial_model.is_deleted = Set(false);
match testimonial_model.insert(db).await {
Ok(_) => println!("✅ Inserted test testimonial"),
Err(_) => println!(
"⚠️ Test testimonial already exists or could not be inserted, skipping"
),
};
// Seed Mentor - handle existing data
let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id);
// Use the admin user ID instead of a random one
mentor_model.user_id = Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
mentor_model.industries = Set(Some(json!( ["Technology", "Education"] )));
mentor_model.expertise = Set(Some(json!( ["Software Development"] )));
mentor_model.languages = Set(Some(json!( ["English", "Indonesian"] )));
mentor_model.current_company = Set(Some("Tech Corp".to_string()));
mentor_model.current_role = Set(Some("Senior Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5));
mentor_model.topics_of_interest = Set(Some(json!( ["Rust", "Web Development"] )));
mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!( ["1:1", "Group"] )));
mentor_model.availability_commitment = Set(Some("Weekly".to_string()));
mentor_model.mentoring_rate = Set(Some(100.0));
mentor_model.status = Set(Some("active".to_string()));
mentor_model.is_deleted = Set(false);
mentor_model.created_at = Set(chrono::Utc::now());
mentor_model.updated_at = Set(chrono::Utc::now());
// Create mentor record via SeaORM active model
mentor_model.insert(db).await?;
println!("✅ Inserted test mentor via SeaORM");
let mentor_id = Uuid::new_v4();
let mut mentor_model: MentorsActiveModel = Default::default();
mentor_model.id = Set(mentor_id);
mentor_model.user_id =
Set(Uuid::parse_str("c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2")?);
mentor_model.industries = Set(Some(json!(["Technology", "Education"])));
mentor_model.expertise = Set(Some(json!(["Software Development"])));
mentor_model.languages = Set(Some(json!(["English", "Indonesian"])));
mentor_model.current_company = Set(Some("Tech Corp".to_string()));
mentor_model.current_role = Set(Some("Senior Engineer".to_string()));
mentor_model.years_of_experience = Set(Some(5));
mentor_model.topics_of_interest = Set(Some(json!(["Rust", "Web Development"])));
mentor_model.preferred_mentee_level = Set(Some("Beginner".to_string()));
mentor_model.preferred_mentoring_formats = Set(Some(json!(["1:1", "Group"])));
mentor_model.availability_commitment = Set(Some("Weekly".to_string()));
mentor_model.mentoring_rate = Set(Some(100.0));
mentor_model.status = Set(Some("active".to_string()));
mentor_model.is_deleted = Set(false);
mentor_model.created_at = Set(chrono::Utc::now());
mentor_model.updated_at = Set(chrono::Utc::now());
mentor_model.insert(db).await?;
println!("✅ Inserted test mentor via SeaORM");
println!("✅ All test data seeded successfully");
Ok(())
}
println!("✅ All test data seeded successfully");
Ok(())
}
+146 -143
View File
@@ -1,14 +1,14 @@
#![allow(clippy::all)]
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use imphnen_entities::seaorm::auth::users::Entity as UserEntity;
use imphnen_libs::hash_password;
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection};
use imphnen_entities::seaorm::auth::users::Entity as UserEntity; // Added for dynamic role lookup
use imphnen_entities::seaorm::auth::users::ActiveModel as UsersActiveModel;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
use uuid::Uuid;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
use std::error::Error;
use chrono::Utc;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
@@ -17,145 +17,148 @@ async fn main() -> Result<(), Box<dyn Error>> {
let db = &pg_conn.conn;
let users = vec![
(
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com",
"Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07",
),
(
"a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com",
"Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com",
"User",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4",
"testuser1@example.com",
"Test User 1",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID
),
(
"3972c139-a450-416c-93b0-c42539dc780f",
"testuser2@example.com",
"Test User 2",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"b426c0a9-0efb-4e26-b078-4f18767255f3",
"testuser3@example.com",
"Test User 3",
"5713cb37-dc02-4e87-8048-d7a41d352059", // Fixed UUID
),
// Additional Users for Volume and Variety
(
"11111111-1111-1111-1111-111111111111",
"user4@example.com",
"User Four",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"22222222-2222-2222-2222-222222222222",
"user5@example.com",
"User Five",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"33333333-3333-3333-3333-333333333333",
"mentor2@example.com",
"Mentor Two",
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", // Mentor Role
),
(
"44444444-4444-4444-4444-444444444444",
"staff2@example.com",
"Staff Two",
"50133429-f4b1-4249-9f97-7b86e6ee9d86", // Staff Role
),
(
"55555555-5555-5555-5555-555555555555",
"user6@example.com",
"User Six",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"66666666-6666-6666-6666-666666666666",
"user7@example.com",
"User Seven",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"77777777-7777-7777-7777-777777777777",
"user8@example.com",
"User Eight",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"88888888-8888-8888-8888-888888888888",
"user9@example.com",
"User Nine",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"99999999-9999-9999-9999-999999999999",
"user10@example.com",
"User Ten",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com",
"Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07",
),
(
"a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com",
"Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com",
"User",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"665a3cfc-ea5f-4bcd-8769-4a6d8d1451d4",
"testuser1@example.com",
"Test User 1",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"3972c139-a450-416c-93b0-c42539dc780f",
"testuser2@example.com",
"Test User 2",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"b426c0a9-0efb-4e26-b078-4f18767255f3",
"testuser3@example.com",
"Test User 3",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"11111111-1111-1111-1111-111111111111",
"user4@example.com",
"User Four",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"22222222-2222-2222-2222-222222222222",
"user5@example.com",
"User Five",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"33333333-3333-3333-3333-333333333333",
"mentor2@example.com",
"Mentor Two",
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
),
(
"44444444-4444-4444-4444-444444444444",
"staff2@example.com",
"Staff Two",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"55555555-5555-5555-5555-555555555555",
"user6@example.com",
"User Six",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"66666666-6666-6666-6666-666666666666",
"user7@example.com",
"User Seven",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"77777777-7777-7777-7777-777777777777",
"user8@example.com",
"User Eight",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"88888888-8888-8888-8888-888888888888",
"user9@example.com",
"User Nine",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
(
"99999999-9999-9999-9999-999999999999",
"user10@example.com",
"User Ten",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
];
for (id, email, fullname, role_id_str) in users { // role_id_str directly contains UUID
let role_uuid = Some(Uuid::parse_str(role_id_str)
.map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?);
// Build SeaORM ActiveModel for users
let uid = Uuid::parse_str(id)?; // Should always be valid UUID strings from test data
let names: Vec<&str> = fullname.split_whitespace().collect();
let first_name = names.first().map(|s| s.to_string());
let last_name = if names.len() > 1 { Some(names[1..].join(" ")) } else { None };
let password = "password";
let hashed = hash_password(password).unwrap();
for (id, email, fullname, role_id_str) in users {
let role_uuid = Some(
Uuid::parse_str(role_id_str)
.map_err(|e| format!("Invalid UUID for role: {role_id_str} - {e}"))?,
);
// Explicit Upsert Logic
let existing_user = UserEntity::find_by_id(uid).one(db).await?;
let is_update = existing_user.is_some();
let mut user_model: UsersActiveModel = if let Some(existing) = existing_user {
println!("🔄 Updating user: {fullname} ({email})");
existing.into_active_model()
} else {
println!("✅ Inserting user: {fullname} ({email})");
let mut active: UsersActiveModel = Default::default();
active.id = Set(uid);
active.created_at = Set(Utc::now());
active
};
user_model.email = Set(email.to_string());
user_model.password_hash = Set(hashed);
user_model.username = Set(email.to_string());
user_model.first_name = Set(first_name);
user_model.last_name = Set(last_name);
user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string()));
user_model.is_verified = Set(true);
user_model.is_active = Set(true);
user_model.role_id = Set(role_uuid);
user_model.updated_at = Set(Utc::now());
if is_update {
user_model.update(db).await?;
} else {
user_model.insert(db).await?;
}
}
let uid = Uuid::parse_str(id)?;
let names: Vec<&str> = fullname.split_whitespace().collect();
let first_name = names.first().map(|s| s.to_string());
let last_name = if names.len() > 1 {
Some(names[1..].join(" "))
} else {
None
};
let password = "password";
let hashed = hash_password(password).unwrap();
let existing_user = UserEntity::find_by_id(uid).one(db).await?;
let is_update = existing_user.is_some();
let mut user_model: UsersActiveModel = if let Some(existing) = existing_user {
println!("🔄 Updating user: {fullname} ({email})");
existing.into_active_model()
} else {
println!("✅ Inserting user: {fullname} ({email})");
let mut active: UsersActiveModel = Default::default();
active.id = Set(uid);
active.created_at = Set(Utc::now());
active
};
user_model.email = Set(email.to_string());
user_model.password_hash = Set(hashed);
user_model.username = Set(email.to_string());
user_model.first_name = Set(first_name);
user_model.last_name = Set(last_name);
user_model.avatar_url = Set(Some("https://example.com/avatar.jpg".to_string()));
user_model.is_verified = Set(true);
user_model.is_active = Set(true);
user_model.role_id = Set(role_uuid);
user_model.updated_at = Set(Utc::now());
if is_update {
user_model.update(db).await?;
} else {
user_model.insert(db).await?;
}
}
println!("✅ All Users seeded");
Ok(())
}
}
+395 -368
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;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Starting PostgreSQL Connection Test");
println!("=====================================");
// Load configuration from environment
let config = PostgresConfig::from_env()?;
println!("✅ Configuration loaded successfully");
println!(" Database URL: {}", config.database_url.replace("postgres://", "postgres://****:****@"));
println!(" Pool size: {}", config.pool_size);
println!(" Connect timeout: {}s", config.connect_timeout);
println!(" Retry attempts: {}", config.retry_attempts);
// Test connection
println!("\n🔌 Testing PostgreSQL connection...");
match test_connection(config).await {
Ok(()) => {
println!("✅ All PostgreSQL tests passed successfully!");
Ok(())
}
Err(e) => {
println!("❌ PostgreSQL test failed: {}", e);
Err(e.into())
}
}
}
async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> {
// Create connection
println!(" Creating PostgreSQL connection...");
let postgres_conn = PostgresConnection::new(config).await?;
let connection = Arc::new(postgres_conn);
println!(" ✅ Connection established successfully");
// Test basic connectivity
println!(" Testing basic connectivity...");
test_basic_connectivity(&connection).await?;
println!("Basic connectivity test passed");
// Test table existence
println!(" Testing table existence...");
test_table_existence(&connection).await?;
println!(" ✅ Table existence test passed");
// Test CRUD operations
println!(" Testing CRUD operations...");
test_crud_operations(&connection).await?;
println!(" CRUD operations test passed");
// Test transaction support
println!(" Testing transaction support...");
test_transactions(&connection).await?;
println!(" ✅ Transaction support test passed");
// Test error handling
println!(" Testing error handling...");
test_error_handling(&connection).await?;
println!(" ✅ Error handling test passed");
Ok(())
}
async fn test_basic_connectivity(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
// Execute a simple query
let statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT 1 as test_value, current_timestamp as current_time".to_string()
);
let result = connection.query_one(statement).await?
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("No results returned".to_string())))?;
// Verify we got expected results
let test_value: Option<i32> = result.try_get("", "test_value").ok();
let current_time: Option<String> = result.try_get("", "current_time").ok();
if test_value != Some(1) {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
format!("Expected test_value=1, got {:?}", test_value)
)));
}
if current_time.is_none() {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"Expected current_time to be set".to_string()
)));
}
println!(" 📝 Query result: test_value={:?}, current_time={:?}", test_value, current_time);
Ok(())
}
async fn test_table_existence(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
// Test if our tables exist
use sea_orm::EntityTrait;
println!(" 📋 Checking users table...");
let user_count = UsersEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" 📊 Users table accessible, current count: {}", user_count);
println!(" 📋 Checking roles table...");
let role_count = RolesEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" 📊 Roles table accessible, current count: {}", role_count);
Ok(())
}
async fn test_crud_operations(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
use sea_orm::{ActiveModelTrait, Set};
// Create test user
println!(" Creating test user...");
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("test_user_{}@example.com", test_user_id)),
password_hash: Set("test_password_hash".to_string()),
username: Set(format!("testuser_{}", test_user_id)),
first_name: Set(Some("Test".to_string())),
last_name: Set(Some("User".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let created_user = user_model.insert(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Created user with ID: {}", created_user.id);
// Read user
println!(" 🔍 Reading test user...");
let found_user = UsersEntity::find_by_id(test_user_id)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.ok_or_else(|| PostgresError::ConnectionError(sea_orm::DbErr::Custom("User not found after creation".to_string())))?;
println!(" ✅ Found user: {} ({})", found_user.username, found_user.email);
// Update user
println!(" ✏️ Updating test user...");
let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel = found_user.into();
update_model.first_name = Set(Some("Updated".to_string()));
update_model.updated_at = Set(Utc::now());
let updated_user = update_model.update(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Updated user first name to: {:?}", updated_user.first_name);
// Delete user
println!(" 🗑️ Deleting test user...");
UsersEntity::delete_by_id(updated_user.id)
.exec(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Test user deleted successfully");
Ok(())
}
async fn test_transactions(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
println!(" 💰 Testing transaction support...");
// Test transaction with rollback
let transaction_result = connection.conn.transaction(|txn| {
Box::pin(async move {
// Create a test user within transaction
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("transaction_test_{}@example.com", test_user_id)),
password_hash: Set("transaction_password_hash".to_string()),
username: Set(format!("transaction_user_{}", test_user_id)),
first_name: Set(Some("Transaction".to_string())),
last_name: Set(Some("Test".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let _created_user = user_model.insert(txn)
.await?;
// Simulate an error to trigger rollback (return a sea_orm::DbErr so the TransactionError matches)
Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string()))
})
}).await;
// Transaction should fail and rollback
match transaction_result {
Err(e) => {
let e_text = format!("{:?}", e);
if e_text.contains("Simulated transaction failure") {
println!(" ✅ Transaction failed as expected, rollback successful");
} else {
return Err(PostgresError::OperationFailed(format!("Unexpected transaction result: {}", e_text)));
}
}
Ok(_) => {
return Err(PostgresError::OperationFailed("Unexpected transaction result: transaction unexpectedly succeeded".to_string()));
}
}
// Verify user was not created (due to rollback)
let user_exists = UsersEntity::find_by_id(Uuid::nil()) // Use nil UUID as we don't know the actual ID
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.is_some();
if user_exists {
println!(" ⚠️ User found despite rollback - this might indicate an issue");
} else {
println!(" ✅ Transaction rollback verified - user not found");
}
Ok(())
}
async fn test_error_handling(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
println!(" ⚠️ Testing error handling...");
// Test invalid UUID
println!(" 🔍 Testing invalid UUID handling...");
let invalid_uuid = Uuid::nil(); // This should exist or be handled gracefully
match UsersEntity::find_by_id(invalid_uuid)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
{
Some(_) => println!(" ✅ Found user with nil UUID (expected in some cases)"),
None => println!(" ✅ No user found with nil UUID (expected)"),
}
// Test invalid query
println!(" 🔍 Testing invalid query handling...");
let invalid_statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT * FROM non_existent_table".to_string()
);
match connection.execute(invalid_statement).await {
Err(_) => println!(" ✅ Invalid query properly handled with error"),
Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"),
}
Ok(())
}
/// Additional utility functions for comprehensive testing
pub mod test_utils {
use super::*;
/// Create a test PostgreSQL configuration
pub fn create_test_config() -> PostgresConfig {
PostgresConfig {
database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test".to_string(),
pool_size: 5,
connect_timeout: 10,
idle_timeout: 30,
max_lifetime: Some(600),
retry_attempts: 2,
retry_delay: 1,
}
}
/// Create a test user model
pub fn create_test_user_model() -> UserModel {
UserModel {
id: Uuid::new_v4(),
email: format!("test_{}@example.com", Uuid::new_v4()),
password_hash: "test_password_hash".to_string(),
username: format!("testuser_{}", Uuid::new_v4()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
avatar_url: None,
is_verified: false,
is_active: true,
metadata: None,
role_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
/// Create a test role model
pub fn create_test_role_model() -> RoleModel {
RoleModel {
id: Uuid::new_v4(),
name: format!("test_role_{}", Uuid::new_v4()),
description: "Test role description".to_string(),
permissions: Some(serde_json::json!(["test.permission"])),
is_system_role: false,
is_default: false,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_test_config() {
let config = test_utils::create_test_config();
assert_eq!(config.pool_size, 5);
assert_eq!(config.connect_timeout, 10);
assert!(config.database_url.contains("imphnen_test"));
}
#[test]
fn test_create_test_user_model() {
let user = test_utils::create_test_user_model();
assert!(!user.email.is_empty());
assert!(!user.username.is_empty());
assert!(user.is_active);
// is_admin field removed; instead, check role-based permission or is_active
}
#[test]
fn test_create_test_role_model() {
let role = test_utils::create_test_role_model();
assert!(!role.name.is_empty());
assert!(role.permissions.is_some());
assert!(!role.is_system_role);
}
}
use chrono::Utc;
use imphnen_entities::seaorm::auth::roles::{
Entity as RolesEntity, Model as RoleModel,
};
use imphnen_entities::seaorm::auth::users::{
Entity as UsersEntity, Model as UserModel,
};
use imphnen_libs::postgres::{PostgresConfig, PostgresConnection, PostgresError};
use sea_orm::{
ActiveModelTrait, DbErr, EntityTrait, PaginatorTrait, Set, TransactionTrait,
};
use std::sync::Arc;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Starting PostgreSQL Connection Test");
println!("=====================================");
let config = PostgresConfig::from_env()?;
println!("✅ Configuration loaded successfully");
println!(
" Database URL: {}",
config
.database_url
.replace("postgres://", "postgres://****:****@")
);
println!(" Pool size: {}", config.pool_size);
println!(" Connect timeout: {}s", config.connect_timeout);
println!(" Retry attempts: {}", config.retry_attempts);
println!("\n🔌 Testing PostgreSQL connection...");
match test_connection(config).await {
Ok(()) => {
println!("✅ All PostgreSQL tests passed successfully!");
Ok(())
}
Err(e) => {
println!("❌ PostgreSQL test failed: {}", e);
Err(e.into())
}
}
}
async fn test_connection(config: PostgresConfig) -> Result<(), PostgresError> {
println!(" Creating PostgreSQL connection...");
let postgres_conn = PostgresConnection::new(config).await?;
let connection = Arc::new(postgres_conn);
println!("Connection established successfully");
println!(" Testing basic connectivity...");
test_basic_connectivity(&connection).await?;
println!(" ✅ Basic connectivity test passed");
println!(" Testing table existence...");
test_table_existence(&connection).await?;
println!(" ✅ Table existence test passed");
println!(" Testing CRUD operations...");
test_crud_operations(&connection).await?;
println!(" ✅ CRUD operations test passed");
println!(" Testing transaction support...");
test_transactions(&connection).await?;
println!(" ✅ Transaction support test passed");
println!(" Testing error handling...");
test_error_handling(&connection).await?;
println!(" ✅ Error handling test passed");
Ok(())
}
async fn test_basic_connectivity(
connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
let statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT 1 as test_value, current_timestamp as current_time".to_string(),
);
let result = connection.query_one(statement).await?.ok_or_else(|| {
PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"No results returned".to_string(),
))
})?;
let test_value: Option<i32> = result.try_get("", "test_value").ok();
let current_time: Option<String> = result.try_get("", "current_time").ok();
if test_value != Some(1) {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
format!("Expected test_value=1, got {:?}", test_value),
)));
}
if current_time.is_none() {
return Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"Expected current_time to be set".to_string(),
)));
}
println!(
" 📝 Query result: test_value={:?}, current_time={:?}",
test_value, current_time
);
Ok(())
}
async fn test_table_existence(
connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
use sea_orm::EntityTrait;
println!(" 📋 Checking users table...");
let user_count = UsersEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(
" 📊 Users table accessible, current count: {}",
user_count
);
println!(" 📋 Checking roles table...");
let role_count = RolesEntity::find()
.count(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(
" 📊 Roles table accessible, current count: {}",
role_count
);
Ok(())
}
async fn test_crud_operations(
connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
use sea_orm::{ActiveModelTrait, Set};
println!(" Creating test user...");
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("test_user_{}@example.com", test_user_id)),
password_hash: Set("test_password_hash".to_string()),
username: Set(format!("testuser_{}", test_user_id)),
first_name: Set(Some("Test".to_string())),
last_name: Set(Some("User".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let created_user = user_model
.insert(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Created user with ID: {}", created_user.id);
println!(" 🔍 Reading test user...");
let found_user = UsersEntity::find_by_id(test_user_id)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.ok_or_else(|| {
PostgresError::ConnectionError(sea_orm::DbErr::Custom(
"User not found after creation".to_string(),
))
})?;
println!(
" ✅ Found user: {} ({})",
found_user.username, found_user.email
);
println!(" ✏️ Updating test user...");
let mut update_model: imphnen_entities::seaorm::auth::users::ActiveModel =
found_user.into();
update_model.first_name = Set(Some("Updated".to_string()));
update_model.updated_at = Set(Utc::now());
let updated_user = update_model
.update(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(
" ✅ Updated user first name to: {:?}",
updated_user.first_name
);
println!(" 🗑️ Deleting test user...");
UsersEntity::delete_by_id(updated_user.id)
.exec(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?;
println!(" ✅ Test user deleted successfully");
Ok(())
}
async fn test_transactions(
connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
println!(" 💰 Testing transaction support...");
let transaction_result = connection
.conn
.transaction(|txn| {
Box::pin(async move {
let test_user_id = Uuid::new_v4();
let now = Utc::now();
let user_model = imphnen_entities::seaorm::auth::users::ActiveModel {
id: Set(test_user_id),
email: Set(format!("transaction_test_{}@example.com", test_user_id)),
password_hash: Set("transaction_password_hash".to_string()),
username: Set(format!("transaction_user_{}", test_user_id)),
first_name: Set(Some("Transaction".to_string())),
last_name: Set(Some("Test".to_string())),
avatar_url: Set(None),
is_verified: Set(false),
is_active: Set(true),
metadata: Set(None),
role_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let _created_user = user_model.insert(txn).await?;
Err::<(), DbErr>(DbErr::Custom("Simulated transaction failure".to_string()))
})
})
.await;
match transaction_result {
Err(e) => {
let e_text = format!("{:?}", e);
if e_text.contains("Simulated transaction failure") {
println!(" ✅ Transaction failed as expected, rollback successful");
} else {
return Err(PostgresError::OperationFailed(format!(
"Unexpected transaction result: {}",
e_text
)));
}
}
Ok(_) => {
return Err(PostgresError::OperationFailed(
"Unexpected transaction result: transaction unexpectedly succeeded"
.to_string(),
));
}
}
let user_exists = UsersEntity::find_by_id(Uuid::nil())
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
.is_some();
if user_exists {
println!(" ⚠️ User found despite rollback - this might indicate an issue");
} else {
println!(" ✅ Transaction rollback verified - user not found");
}
Ok(())
}
async fn test_error_handling(
connection: &Arc<PostgresConnection>,
) -> Result<(), PostgresError> {
println!(" ⚠️ Testing error handling...");
println!(" 🔍 Testing invalid UUID handling...");
let invalid_uuid = Uuid::nil();
match UsersEntity::find_by_id(invalid_uuid)
.one(&connection.conn)
.await
.map_err(PostgresError::ConnectionError)?
{
Some(_) => {
println!(" ✅ Found user with nil UUID (expected in some cases)")
}
None => println!(" ✅ No user found with nil UUID (expected)"),
}
println!(" 🔍 Testing invalid query handling...");
let invalid_statement = sea_orm::Statement::from_string(
connection.get_database_backend(),
"SELECT * FROM non_existent_table".to_string(),
);
match connection.execute(invalid_statement).await {
Err(_) => println!(" ✅ Invalid query properly handled with error"),
Ok(_) => println!(" ⚠️ Invalid query unexpectedly succeeded"),
}
Ok(())
}
pub mod test_utils {
use super::*;
pub fn create_test_config() -> PostgresConfig {
PostgresConfig {
database_url: "postgres://postgres:postgres@localhost:5432/imphnen_test"
.to_string(),
pool_size: 5,
connect_timeout: 10,
idle_timeout: 30,
max_lifetime: Some(600),
retry_attempts: 2,
retry_delay: 1,
}
}
pub fn create_test_user_model() -> UserModel {
UserModel {
id: Uuid::new_v4(),
email: format!("test_{}@example.com", Uuid::new_v4()),
password_hash: "test_password_hash".to_string(),
username: format!("testuser_{}", Uuid::new_v4()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
avatar_url: None,
is_verified: false,
is_active: true,
metadata: None,
role_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
pub fn create_test_role_model() -> RoleModel {
RoleModel {
id: Uuid::new_v4(),
name: format!("test_role_{}", Uuid::new_v4()),
description: "Test role description".to_string(),
permissions: Some(serde_json::json!(["test.permission"])),
is_system_role: false,
is_default: false,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_test_config() {
let config = test_utils::create_test_config();
assert_eq!(config.pool_size, 5);
assert_eq!(config.connect_timeout, 10);
assert!(config.database_url.contains("imphnen_test"));
}
#[test]
fn test_create_test_user_model() {
let user = test_utils::create_test_user_model();
assert!(!user.email.is_empty());
assert!(!user.username.is_empty());
assert!(user.is_active);
}
#[test]
fn test_create_test_role_model() {
let role = test_utils::create_test_role_model();
assert!(!role.name.is_empty());
assert!(role.permissions.is_some());
assert!(!role.is_system_role);
}
}
+10 -13
View File
@@ -1,13 +1,10 @@
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let _ = axum_init(|postgres_conn| async {
// PostgreSQL is now the primary database - SurrealDB has been completely removed
gateway_service(postgres_conn).await
})
.await;
}
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let _ =
axum_init(|postgres_conn| async { gateway_service(postgres_conn).await }).await;
}
+41 -31
View File
@@ -1,31 +1,41 @@
[package]
name = "imphnen-cms"
version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
axum-test.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
log.workspace = true
tracing.workspace = true
sea-orm.workspace = true
uuid.workspace = true
[package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://"
[package]
name = "imphnen-cms"
version = "0.3.0"
edition = "2024"
[dependencies]
async-trait.workspace = true
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
zod-rs.workspace = true
zod-rs-util.workspace = true
axum-test.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
log.workspace = true
tracing.workspace = true
sea-orm.workspace = true
uuid.workspace = true
paginator-rs.workspace = true
paginator-utils.workspace = true
paginator-sea-orm.workspace = true
paginator-axum.workspace = true
sqlx.workspace = true
image.workspace = true
qrcode.workspace = true
[package.metadata.validator.regex]
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};
+7 -9
View File
@@ -1,9 +1,7 @@
pub mod v1;
pub use v1::landing;
pub use v1::landing::events;
pub use v1::landing::testimonials;
pub use v1::landing::events::events_public_routes;
pub use v1::landing::events::events_protected_routes;
pub use v1::landing::testimonials::testimonials_public_routes;
pub use v1::landing::testimonials::testimonials_protected_routes;
pub mod events;
pub mod testimonials;
pub mod qr;
pub use events::{events_protected_routes, events_public_routes};
pub use testimonials::{testimonials_protected_routes, testimonials_public_routes};
pub use qr::qr_router;
@@ -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,160 @@
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Campaigns",
security(("bearer_auth" = []))
)]
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 (multipart/form-data with 'file' field)"),
(status = 401, description = "Unauthorized")
),
tag = "QR - Campaigns",
security(("bearer_auth" = []))
)]
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(Extension(pool))
.layer(from_fn(qr_auth_middleware))
}
@@ -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,139 @@
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"),
(status = 401, description = "Unauthorized")
),
tag = "QR - Users",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized")
),
tag = "QR - Users",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("bearer_auth" = []))
)]
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"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only")
),
tag = "QR - Users",
security(("bearer_auth" = []))
)]
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(Extension(pool))
.layer(from_fn(qr_auth_middleware))
}
@@ -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 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,
}
}
}
@@ -0,0 +1,202 @@
use super::dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto, TestimonialsListItemDto,
TestimonialsUpdateRequestDto,
};
use crate::testimonials::domain::{TestimonialEntity, TestimonialService};
use axum::{
Extension,
extract::Path,
http::HeaderMap,
http::StatusCode,
response::{IntoResponse, Response},
};
use imphnen_entities::ResponseSuccessDto;
use imphnen_iam::require_auth;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_utils::AppError;
use imphnen_utils::{
ApiCreated, ApiMessage, ApiPaginated, ApiSuccess, extract_email,
};
use paginator_axum::PaginationQuery;
use paginator_utils::PaginatorResponse;
use std::sync::Arc;
use uuid::Uuid;
#[utoipa::path(
get,
path = "/v1/landing/cms/testimonials",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
),
responses(
(status = 200, description = "[PUBLIC] Get testimonial list")
),
tag = "Testimonials"
)]
pub async fn get_testimonial_list(
Extension(service): Extension<Arc<dyn TestimonialService>>,
PaginationQuery(params): PaginationQuery,
) -> Response {
match service.list(params).await {
Ok(result) => {
let mapped = PaginatorResponse {
data: result
.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(TestimonialsListItemDto::from)
.collect::<Vec<_>>(),
meta: result.meta,
};
ApiPaginated(mapped).into_response()
}
Err(e) => {
ApiMessage::new(StatusCode::BAD_REQUEST, e.to_string()).into_response()
}
}
}
#[utoipa::path(
get,
path = "/v1/landing/cms/testimonials/detail/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "[PUBLIC] Get testimonial by ID", body = ResponseSuccessDto<TestimonialsDetailItemDto>)
),
tag = "Testimonials"
)]
pub async fn get_testimonial_by_id(
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
) -> Response {
let uuid = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(e) => {
return ApiMessage::new(StatusCode::BAD_REQUEST, format!("Invalid UUID: {e}"))
.into_response();
}
};
match service.get(uuid).await {
Ok(t) if !t.is_deleted => {
ApiSuccess(TestimonialsDetailItemDto::from(t)).into_response()
}
Ok(_) => {
ApiMessage::new(StatusCode::NOT_FOUND, "Testimonial not found").into_response()
}
Err(e) => ApiMessage::new(StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
#[utoipa::path(
post,
security(("Bearer" = [])),
path = "/v1/landing/cms/testimonials/create",
request_body = TestimonialsCreateRequestDto,
responses(
(status = 201, description = "[USER] Create new testimonial", body = ResponseSuccessDto<TestimonialsDetailItemDto>)
),
tag = "Testimonials"
)]
pub async fn post_create_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
ValidatedJson(payload): ValidatedJson<TestimonialsCreateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers.clone(), state, {
let email = extract_email(&headers).ok_or_else(|| {
AppError::AuthenticationError("Token tidak valid".to_string())
})?;
let user_info = state
.user_lookup_service
.get_user_by_email(&email, &state)
.await
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user = user_info.basic_info;
let user_id = Uuid::parse_str(&user.id)
.map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {e}")))?;
let entity = TestimonialEntity {
id: Uuid::new_v4(),
user_id,
user_fullname: user.fullname.clone(),
role: payload.role,
content: payload.content,
is_deleted: false,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let created = service.create(entity).await?;
Ok(ApiCreated(TestimonialsDetailItemDto::from(created)))
})
}
#[utoipa::path(
patch,
security(("Bearer" = [])),
path = "/v1/landing/cms/testimonials/update/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
request_body = TestimonialsUpdateRequestDto,
responses(
(status = 200, description = "[USER] Update testimonial")
),
tag = "Testimonials"
)]
pub async fn patch_update_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<TestimonialsUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers, state, {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
let existing = service.get(uuid).await?;
let entity = TestimonialEntity {
id: existing.id,
user_id: existing.user_id,
user_fullname: existing.user_fullname,
role: payload.role,
content: payload.content,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: chrono::Utc::now().to_rfc3339(),
};
service.update(entity).await?;
Ok(ApiMessage::ok("Testimonial updated"))
})
}
#[utoipa::path(
delete,
security(("Bearer" = [])),
path = "/v1/landing/cms/testimonials/delete/{id}",
params(
("id" = String, Path, description = "Testimonial ID")
),
responses(
(status = 200, description = "[USER] Soft delete testimonial")
),
tag = "Testimonials"
)]
pub async fn delete_testimonial(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn TestimonialService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
require_auth!(headers, state, {
let uuid = Uuid::parse_str(&id)
.map_err(|e| AppError::BadRequestError(format!("Invalid UUID: {e}")))?;
service.delete(uuid).await?;
Ok(ApiMessage::ok("Testimonial deleted"))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{testimonials_protected_routes, testimonials_public_routes};
@@ -0,0 +1,47 @@
use super::handlers::{
delete_testimonial, get_testimonial_by_id, get_testimonial_list,
patch_update_testimonial, post_create_testimonial,
};
use crate::testimonials::application::TestimonialServiceImpl;
use crate::testimonials::domain::TestimonialService;
use crate::testimonials::infrastructure::persistence::PostgresTestimonialRepository;
use axum::{
Extension, Router,
routing::{delete, get, patch, post},
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;
fn build_service(db: DatabaseConnection) -> Arc<dyn TestimonialService> {
let repo = Arc::new(PostgresTestimonialRepository::new(db));
Arc::new(TestimonialServiceImpl::new(repo))
}
pub fn testimonials_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/testimonials", get(get_testimonial_list))
.route(
"/testimonials/detail/{id}",
get(get_testimonial_by_id),
)
.layer(Extension(service))
}
pub fn testimonials_protected_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route(
"/testimonials/create",
post(post_create_testimonial),
)
.route(
"/testimonials/update/{id}",
patch(patch_update_testimonial),
)
.route(
"/testimonials/delete/{id}",
delete(delete_testimonial),
)
.layer(Extension(service))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_testimonial_repository;
pub use postgres_testimonial_repository::PostgresTestimonialRepository;
@@ -0,0 +1,191 @@
use crate::testimonials::domain::{
repository::TestimonialRepository, testimonial::TestimonialEntity,
};
use async_trait::async_trait;
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
use imphnen_entities::seaorm::common::testimonials::{
ActiveModel as TestimonialsActiveModel, Column as TestimonialsColumn,
Entity as TestimonialsEntity,
};
use imphnen_utils::AppError;
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, PaginatorTrait, QueryOrder};
use std::sync::Arc;
use uuid::Uuid;
pub struct PostgresTestimonialRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresTestimonialRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl TestimonialRepository for PostgresTestimonialRepository {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<TestimonialEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = TestimonialsEntity::find()
.filter(TestimonialsColumn::IsDeleted.eq(false))
.find_also_related(UsersEntity);
query = match params.sort_by.as_deref() {
Some("updated_at") => match params.sort_direction {
Some(SortDirection::Asc) => {
query.order_by_asc(TestimonialsColumn::UpdatedAt)
}
_ => query.order_by_desc(TestimonialsColumn::UpdatedAt),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => {
query.order_by_asc(TestimonialsColumn::CreatedAt)
}
_ => query.order_by_desc(TestimonialsColumn::CreatedAt),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator
.num_items()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let rows = paginator
.fetch_page((page - 1) as u64)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data: Vec<TestimonialEntity> = rows
.into_iter()
.filter_map(|(t, u)| {
u.map(|user| TestimonialEntity {
id: t.id,
user_id: t.user_id,
user_fullname: format!(
"{} {}",
user.first_name.as_deref().unwrap_or(""),
user.last_name.as_deref().unwrap_or("")
)
.trim()
.to_string(),
role: t.role,
content: t.content,
is_deleted: t.is_deleted,
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
})
})
.collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(&self, id: Uuid) -> Result<TestimonialEntity, AppError> {
let (testimonial, user) = TestimonialsEntity::find_by_id(id)
.filter(TestimonialsColumn::IsDeleted.eq(false))
.find_also_related(UsersEntity)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?;
let user = user.ok_or_else(|| {
AppError::NotFoundError("User not found for testimonial".to_string())
})?;
Ok(TestimonialEntity {
id: testimonial.id,
user_id: testimonial.user_id,
user_fullname: format!(
"{} {}",
user.first_name.as_deref().unwrap_or(""),
user.last_name.as_deref().unwrap_or("")
)
.trim()
.to_string(),
role: testimonial.role,
content: testimonial.content,
is_deleted: testimonial.is_deleted,
created_at: testimonial.created_at.to_rfc3339(),
updated_at: testimonial.updated_at.to_rfc3339(),
})
}
async fn create(
&self,
entity: TestimonialEntity,
) -> Result<TestimonialEntity, AppError> {
let active_model = TestimonialsActiveModel {
id: ActiveValue::Set(entity.id),
user_id: ActiveValue::Set(entity.user_id),
role: ActiveValue::Set(entity.role.clone()),
content: ActiveValue::Set(entity.content.clone()),
is_deleted: ActiveValue::Set(false),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
let inserted = active_model
.insert(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(TestimonialEntity {
id: inserted.id,
user_id: inserted.user_id,
user_fullname: entity.user_fullname,
role: inserted.role,
content: inserted.content,
is_deleted: inserted.is_deleted,
created_at: inserted.created_at.to_rfc3339(),
updated_at: inserted.updated_at.to_rfc3339(),
})
}
async fn update(&self, entity: TestimonialEntity) -> Result<(), AppError> {
let mut active_model: TestimonialsActiveModel =
TestimonialsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?
.into();
active_model.role = ActiveValue::Set(entity.role);
active_model.content = ActiveValue::Set(entity.content);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
let mut active_model: TestimonialsActiveModel =
TestimonialsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Testimonial not found".to_string()))?
.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::{
testimonials_protected_routes, testimonials_public_routes,
};
View File
View File
@@ -1,139 +0,0 @@
use super::{
events_dto::{
EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto,
EventsUpdateRequestDto,
},
events_service::EventsService,
};
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use axum::{Extension, http::HeaderMap, http::StatusCode};
use imphnen_libs::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto, ValidatedJson,
};
use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::common_response;
use uuid::Uuid;
#[utoipa::path(
get,
path = "/v1/cms/landing/events",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
("filter" = Option<String>, Query, description = "Filter value"),
("filter_by" = Option<String>, Query, description = "Field to filter by"),
),
responses(
(status = 200, description = "[PUBLIC] Get event list", body = ResponseListSuccessDto<Vec<EventsListItemDto>>)
),
tag = "Events"
)]
pub async fn get_event_list(
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
EventsService::get_event_list(&state, meta).await
}
#[utoipa::path(
get,
path = "/v1/cms/landing/events/detail/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[PUBLIC] Get event by ID", body = ResponseSuccessDto<EventsDetailItemDto>)
),
tag = "Events"
)]
pub async fn get_event_by_id(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let parsed_id = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &format!("Invalid UUID format: {}", e)),
};
EventsService::get_event_by_id(&state, parsed_id).await
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/create",
request_body = EventsCreateRequestDto,
responses(
(status = 201, description = "[ADMIN] Create new event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn post_create_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
ValidatedJson(payload): ValidatedJson<EventsCreateRequestDto>,
) -> impl IntoResponse {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
EventsService::create_event(&state, payload).await
})
}
#[utoipa::path(
patch,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/update/{id}",
params(
("id" = String, Path, description = "Event ID")
),
request_body = EventsUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Update event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn patch_update_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
ValidatedJson(payload): ValidatedJson<EventsUpdateRequestDto>,
) -> impl IntoResponse {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
EventsService::update_event(&state, id, payload).await
})
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/cms/landing/events/delete/{id}",
params(
("id" = String, Path, description = "Event ID")
),
responses(
(status = 200, description = "[ADMIN] Soft delete event", body = MessageResponseDto)
),
tag = "Events"
)]
pub async fn delete_event(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
require_permissions!(headers, state, [PermissionsEnum::Administrator], {
let parsed_id = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &format!("Invalid UUID format: {}", e)),
};
EventsService::delete_event(&state, parsed_id).await
})
}
@@ -1,152 +0,0 @@
use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use utoipa::ToSchema;
use validator::{Validate, ValidationError};
// Custom URL validator that ensures valid HTTP/HTTPS URLs
static VALID_URL_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^https?://[^\s$.?#].[^\s]*$").unwrap());
pub fn validate_url(url: &str) -> Result<(), ValidationError> {
if VALID_URL_REGEX.is_match(url) {
Ok(())
} else {
Err(ValidationError::new("invalid_url"))
}
}
// Custom validator for future dates
pub fn validate_future_date(end_date: &DateTime<Utc>) -> Result<(), ValidationError> {
let now = Utc::now();
if end_date > &now {
Ok(())
} else {
Err(ValidationError::new("future_date"))
}
}
// Custom validator for event date ranges (for combined validation)
pub fn validate_date_range(start_date: &DateTime<Utc>, end_date: &DateTime<Utc>) -> Result<(), ValidationError> {
if start_date <= end_date {
Ok(())
} else {
Err(ValidationError::new("date_range"))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsCreateRequestDto {
#[validate(length(min = 1, max = 100, message = "Name must be between 1 and 100 characters"))]
pub name: String,
#[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))]
pub description: String,
#[validate(custom(
function = "validate_url",
message = "Detail link must be a valid HTTP/HTTPS URL"
))]
pub detail_link: String,
#[validate(range(min = 0.0, max = 1_000_000.0, message = "Price must be between 0 and 1,000,000"))]
pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
#[validate(custom(
function = "validate_future_date",
message = "End date must be in the future"
))]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
#[validate(length(max = 200, message = "Location name cannot exceed 200 characters"))]
pub location: Option<String>,
pub is_online: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsUpdateRequestDto {
#[validate(length(min = 1, message = "Name is required"))]
pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
#[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64,
pub is_online: bool,
pub description: String,
#[validate(url(message = "Detail link must be a valid URL"))]
pub detail_link: String,
pub location: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsListItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub location: Option<String>,
pub is_deleted: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventsDetailItemDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsQueryDto {
pub id: String,
pub name: String,
pub description: String,
pub detail_link: String,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub start_date: String,
pub end_date: String,
pub created_at: String,
pub updated_at: String,
pub location: Option<String>,
}
impl EventsQueryDto {
pub fn from(self) -> EventsListItemDto {
EventsListItemDto {
id: self.id,
name: self.name,
description: self.description,
detail_link: self.detail_link,
price: self.price,
location: self.location,
is_online: self.is_online,
start_date: self.start_date,
end_date: self.end_date,
created_at: self.created_at,
is_deleted: self.is_deleted,
}
}
}
@@ -1,220 +0,0 @@
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, QueryOrder};
use std::time::Instant;
use tracing::instrument;
use uuid::Uuid; // Added Uuid import
use imphnen_entities::seaorm::common::events::{Entity as EventsEntity, Column as EventsColumn, Model as EventsModel};
use imphnen_libs::{AppState, MetaRequestDto, ResponseListSuccessDto, AppStatePostgresExt};
use imphnen_utils::AppError;
use imphnen_utils::Result;
use crate::events::events_dto::EventsQueryDto;
use crate::events::events_schema::EventsSchema;
pub struct EventsRepository<'a> {
state: &'a AppState,
}
impl<'a> EventsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_event_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> {
let now = Instant::now();
let page = meta.page.unwrap_or(1);
let page_size = 10u64;
let _offset = (page - 1) * page_size;
let mut query = EventsEntity::find()
.filter(EventsColumn::IsDeleted.eq(false)); // Add sorting
if let Some(sort_by) = &meta.sort_by {
match sort_by.as_str() {
"created_at" => {
if meta.order.as_deref() == Some("desc") {
query = query.order_by_desc(EventsColumn::CreatedAt);
} else {
query = query.order_by_asc(EventsColumn::CreatedAt);
}
}
"name" => {
if meta.order.as_deref() == Some("desc") {
query = query.order_by_desc(EventsColumn::Name);
} else {
query = query.order_by_asc(EventsColumn::Name);
}
}
_ => {
query = query.order_by_desc(EventsColumn::CreatedAt);
}
}
} else {
query = query.order_by_desc(EventsColumn::CreatedAt);
}
let paginator = query.paginate(self.state.postgres_db(), page_size);
let events: Vec<EventsModel> = paginator.fetch_page(page - 1).await?;
let res: Vec<EventsQueryDto> = events.into_iter().map(|model| EventsQueryDto {
id: model.id.to_string(),
name: model.name,
description: model.description,
detail_link: model.detail_link,
price: model.price,
is_online: model.is_online,
is_deleted: model.is_deleted,
start_date: model.start_date.to_rfc3339(),
end_date: model.end_date.to_rfc3339(),
created_at: model.created_at.to_rfc3339(),
updated_at: model.updated_at.to_rfc3339(),
location: model.location,
}).collect();
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto {
data: res,
meta: None,
};
Ok(data)
}
#[instrument(skip(self, id), err)]
pub async fn query_event_by_id(&self, id: Uuid) -> Result<EventsQueryDto> {
let now = Instant::now();
let event = EventsEntity::find_by_id(id)
.filter(EventsColumn::IsDeleted.eq(false))
.one(self.state.postgres_db())
.await?
.ok_or_else(|| anyhow::anyhow!("Event not found"))?;
let result = EventsQueryDto {
id: event.id.to_string(),
name: event.name,
description: event.description,
detail_link: event.detail_link,
price: event.price,
is_online: event.is_online,
is_deleted: event.is_deleted,
start_date: event.start_date.to_rfc3339(),
end_date: event.end_date.to_rfc3339(),
created_at: event.created_at.to_rfc3339(),
updated_at: event.updated_at.to_rfc3339(),
location: event.location,
};
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_by_id' took: {elapsed:.2?}");
}
Ok(result)
}
#[instrument(skip(self, data), err)]
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let active_model = imphnen_entities::seaorm::common::events::ActiveModel {
id: ActiveValue::Set(Uuid::parse_str(&data.id)?),
name: ActiveValue::Set(data.name),
description: ActiveValue::Set(data.description),
detail_link: ActiveValue::Set(data.detail_link),
price: ActiveValue::Set(data.price),
is_online: ActiveValue::Set(data.is_online),
is_deleted: ActiveValue::Set(data.is_deleted),
location: ActiveValue::Set(data.location),
start_date: ActiveValue::Set(chrono::DateTime::parse_from_rfc3339(&data.start_date)?.with_timezone(&chrono::Utc)),
end_date: ActiveValue::Set(chrono::DateTime::parse_from_rfc3339(&data.end_date)?.with_timezone(&chrono::Utc)),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
};
let _result = EventsEntity::insert(active_model).exec(self.state.postgres_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_event' took: {elapsed:.2?}");
}
Ok("Success create event".into())
}
#[instrument(skip(self, data), err)]
pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let existing = self.query_event_by_id(Uuid::parse_str(&data.id)?).await?;
if existing.is_deleted {
return Err(AppError::BadRequestError("Event already deleted".to_string()));
}
let mut active_model: imphnen_entities::seaorm::common::events::ActiveModel = EventsEntity::find_by_id(Uuid::parse_str(&data.id)?)
.one(self.state.postgres_db())
.await?
.ok_or_else(|| anyhow::anyhow!("Event not found"))?
.into();
active_model.name = ActiveValue::Set(data.name);
active_model.description = ActiveValue::Set(data.description);
active_model.detail_link = ActiveValue::Set(data.detail_link);
active_model.price = ActiveValue::Set(data.price);
active_model.is_online = ActiveValue::Set(data.is_online);
active_model.location = ActiveValue::Set(data.location);
active_model.start_date = ActiveValue::Set(chrono::DateTime::parse_from_rfc3339(&data.start_date)?.with_timezone(&chrono::Utc));
active_model.end_date = ActiveValue::Set(chrono::DateTime::parse_from_rfc3339(&data.end_date)?.with_timezone(&chrono::Utc));
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.state.postgres_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_event' took: {elapsed:.2?}");
}
Ok("Success update event".into())
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_event(&self, id: Uuid) -> Result<String> {
let now = Instant::now();
let event = self.query_event_by_id(id).await?;
if event.is_deleted {
return Err(AppError::NotFoundError("Event not found".to_string()));
}
let mut active_model: imphnen_entities::seaorm::common::events::ActiveModel = EventsEntity::find_by_id(id)
.one(self.state.postgres_db())
.await?
.ok_or_else(|| anyhow::anyhow!("Event not found"))?
.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model.update(self.state.postgres_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_event' took: {elapsed:.2?}");
}
Ok("Success delete event".into())
}
}
@@ -1,94 +0,0 @@
use imphnen_utils::get_iso_date;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::events_dto::{
EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsSchema {
pub id: String,
pub price: f64,
pub is_online: bool,
pub is_deleted: bool,
pub name: String,
pub end_date: String,
pub start_date: String,
pub created_at: String,
pub updated_at: String,
pub description: String,
pub detail_link: String,
pub location: Option<String>,
}
impl Default for EventsSchema {
fn default() -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: String::new(),
description: String::new(),
detail_link: String::new(),
price: 0.0,
location: None,
is_online: false,
is_deleted: false,
start_date: String::new(),
end_date: String::new(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl EventsSchema {
pub fn from(dto: EventsQueryDto) -> Self {
Self {
id: dto.id,
name: dto.name,
description: dto.description,
detail_link: dto.detail_link,
price: dto.price,
location: dto.location,
is_online: dto.is_online,
is_deleted: false,
start_date: dto.start_date,
end_date: dto.end_date,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn create(payload: EventsCreateRequestDto) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: payload.name,
description: payload.description,
detail_link: payload.detail_link,
price: payload.price,
location: payload.location,
is_online: payload.is_online,
is_deleted: false,
end_date: payload.end_date.to_string(),
start_date: payload.start_date.to_string(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn update(payload: EventsUpdateRequestDto, id: String) -> Self {
Self {
id,
name: payload.name,
price: payload.price,
location: payload.location,
is_online: payload.is_online,
description: payload.description,
detail_link: payload.detail_link,
end_date: payload.end_date.to_string(),
start_date: payload.start_date.to_string(),
updated_at: get_iso_date(),
..Default::default()
}
}
}
@@ -1,102 +0,0 @@
use super::{
events_dto::{
EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto,
EventsUpdateRequestDto,
},
events_repository::EventsRepository,
events_schema::EventsSchema,
};
use axum::{http::StatusCode, response::Response};
use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
use uuid::Uuid;
pub struct EventsService;
impl EventsService {
pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_list(meta).await {
Ok(data) => {
let items: Vec<EventsListItemDto> = data
.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(EventsQueryDto::from)
.collect();
let response = ResponseListSuccessDto {
data: items,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_event_by_id(state: &AppState, id: Uuid) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_by_id(id).await {
Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto {
data: EventsDetailItemDto {
id: event.id,
name: event.name,
description: event.description,
detail_link: event.detail_link,
price: event.price,
is_online: event.is_online,
start_date: event.start_date,
end_date: event.end_date,
created_at: event.created_at,
updated_at: event.updated_at,
location: event.location,
},
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_event(
state: &AppState,
payload: EventsCreateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::create(payload);
match repo.query_create_event(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, msg.as_str()),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_event(
state: &AppState,
id: String,
payload: EventsUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::update(payload, id);
match repo.query_update_event(schema).await {
Ok(msg) => common_response(StatusCode::OK, msg.as_str()),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_event(state: &AppState, id: Uuid) -> Response {
let repo = EventsRepository::new(state);
match repo.query_delete_event(id).await {
Ok(msg) => common_response(StatusCode::OK, msg.as_str()),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
-38
View File
@@ -1,38 +0,0 @@
use axum::{
Router,
routing::{delete, get, patch, post},
};
pub mod events_controller;
pub mod events_dto;
pub mod events_repository;
pub mod events_schema;
pub mod events_service;
// Export only the necessary public items
pub use events_dto::{
EventsCreateRequestDto,
EventsUpdateRequestDto,
EventsListItemDto,
EventsDetailItemDto,
};
pub use events_controller::{
get_event_list,
get_event_by_id,
post_create_event,
patch_update_event,
delete_event,
};
pub fn events_public_routes() -> Router {
Router::new()
.route("/cms/landing/events", get(get_event_list))
.route("/cms/landing/events/detail/{id}", get(get_event_by_id))
}
pub fn events_protected_routes() -> Router {
Router::new()
.route("/cms/landing/events/create", post(post_create_event))
.route("/cms/landing/events/update/{id}", patch(patch_update_event))
.route("/cms/landing/events/delete/{id}", delete(delete_event))
}

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