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>
This commit is contained in:
maulanasdqn
2026-04-02 16:03:39 +07:00
co-authored by Claude Sonnet 4.6
parent 5715e75593
commit 4bba182ea3
51 changed files with 1942 additions and 0 deletions
+9
View File
@@ -33,6 +33,15 @@ RETRY_ATTEMPTS=3
RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback
# QR campaign service
QR_DATABASE_URL=postgres://imphnen_qr@127.0.0.1:5432/imphnen_qr?sslmode=disable
QR_JWT_SECRET=your-qr-jwt-secret-at-least-32-chars
QR_JWT_EXPIRY_MINUTES=15
QR_JWT_REFRESH_EXPIRY_DAYS=7
QR_GOOGLE_CLIENT_ID=your-google-client-id
QR_GOOGLE_CLIENT_SECRET=your-google-client-secret
QR_GOOGLE_REDIRECT_URL=http://localhost:8080/v1/qr/auth/google/callback
# Hackathon feature
HACKATHON_JWT_SECRET=your-hackathon-jwt-secret-at-least-32-chars
HACKATHON_JWT_EXPIRY_HOURS=168
Generated
+678
View File
@@ -46,6 +46,24 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -135,6 +153,17 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -153,6 +182,15 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -223,6 +261,49 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror 2.0.17",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d"
dependencies = [
"arrayvec",
]
[[package]]
name = "axum"
version = "0.8.7"
@@ -353,6 +434,19 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bcrypt"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
dependencies = [
"base64",
"blowfish",
"getrandom 0.2.16",
"subtle",
"zeroize",
]
[[package]]
name = "bigdecimal"
version = "0.4.9"
@@ -367,6 +461,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -376,6 +476,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitstream-io"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
dependencies = [
"core2",
]
[[package]]
name = "bitvec"
version = "1.0.1"
@@ -406,6 +515,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "borsh"
version = "1.5.7"
@@ -429,6 +548,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "built"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -457,12 +582,24 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -482,6 +619,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -521,6 +660,22 @@ dependencies = [
"stacker",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -568,6 +723,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -601,6 +765,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -616,6 +799,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -777,6 +966,26 @@ dependencies = [
"log",
]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -815,12 +1024,56 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
dependencies = [
"fax_derive",
]
[[package]]
name = "fax_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
@@ -1034,6 +1287,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "h2"
version = "0.4.12"
@@ -1053,6 +1316,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1437,6 +1711,46 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "imphnen-backend"
version = "0.2.0"
@@ -1608,12 +1922,14 @@ dependencies = [
"imphnen-iam",
"imphnen-libs",
"imphnen-middleware",
"imphnen-qr",
"imphnen-utils",
"lazy_static",
"rand 0.9.2",
"regex",
"serde",
"serde_json",
"sqlx",
"tokio",
"tower-http",
"utoipa",
@@ -1760,6 +2076,30 @@ dependencies = [
"uuid",
]
[[package]]
name = "imphnen-qr"
version = "0.2.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"bcrypt",
"chrono",
"image",
"imphnen-utils",
"jsonwebtoken",
"oauth2",
"qrcode",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
"utoipa",
"uuid",
]
[[package]]
name = "imphnen-utils"
version = "0.2.0"
@@ -1814,6 +2154,26 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1836,6 +2196,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -1866,6 +2235,16 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.82"
@@ -1900,6 +2279,12 @@ dependencies = [
"spin",
]
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "lettre"
version = "0.11.19"
@@ -1934,6 +2319,16 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "libm"
version = "0.2.15"
@@ -1997,6 +2392,15 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2018,6 +2422,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]]
name = "md-5"
version = "0.10.6"
@@ -2097,6 +2511,16 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -2131,6 +2555,12 @@ dependencies = [
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
@@ -2140,6 +2570,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2181,6 +2617,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2201,6 +2648,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2430,6 +2888,18 @@ dependencies = [
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem"
version = "3.0.6"
@@ -2503,6 +2973,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
@@ -2631,6 +3114,25 @@ dependencies = [
"yansi",
]
[[package]]
name = "profiling"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
"syn 2.0.111",
]
[[package]]
name = "psm"
version = "0.1.28"
@@ -2661,6 +3163,36 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2812,6 +3344,76 @@ dependencies = [
"rand 0.9.2",
]
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.2",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.17",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2912,6 +3514,12 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "ring"
version = "0.17.14"
@@ -3376,6 +3984,15 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -3852,6 +4469,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.44"
@@ -4275,6 +4906,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -4420,6 +5062,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "whoami"
version = "1.6.1"
@@ -4776,6 +5424,12 @@ dependencies = [
"tap",
]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yansi"
version = "1.0.1"
@@ -4950,3 +5604,27 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+5
View File
@@ -11,6 +11,7 @@ members = [
"imphnen-gacha", # Game mechanics, depends on core services
"imphnen-dimentorin",# Learning platform, depends on core services
"imphnen-hackathon", # Hackathon feature, standalone with Supabase auth
"imphnen-qr", # QR campaign overlay service
"imphnen-gateway", # API gateway, depends on all services
"imphnen-backend", # Main application, depends on all services
]
@@ -89,6 +90,10 @@ imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
imphnen-macros = { path = "./imphnen-macros" }
imphnen-hackathon = { path = "./imphnen-hackathon" }
imphnen-qr = { path = "./imphnen-qr" }
bcrypt = "0.15"
image = { version = "0.25", features = ["png", "jpeg"] }
qrcode = { version = "0.14", default-features = false, features = ["image"] }
[profile.release]
lto = "fat"
+2
View File
@@ -5,6 +5,8 @@ edition = "2024"
[dependencies]
imphnen-hackathon.workspace = true
imphnen-qr.workspace = true
sqlx.workspace = true
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
+10
View File
@@ -17,6 +17,7 @@ use imphnen_dimentorin::{
};
use imphnen_gacha::gacha_router;
use imphnen_hackathon::{hackathon_router, HackathonConfig};
use imphnen_qr::{qr_router, QrConfig};
use imphnen_iam::{
auth_public_routes,
permissions_protected_routes,
@@ -45,6 +46,14 @@ pub async fn gateway_service(
let db = state.postgres_connection.conn.clone();
let state_arc = Arc::new(state.clone());
let hackathon_config = Arc::new(HackathonConfig::from_env());
let qr_config = Arc::new(QrConfig::from_env());
let qr_pool = Arc::new(
sqlx::PgPool::connect(
&std::env::var("QR_DATABASE_URL").expect("QR_DATABASE_URL must be set"),
)
.await
.expect("Failed to connect to QR database"),
);
let public_routes = Router::new()
.merge(auth_public_routes(db.clone(), Arc::clone(&state_arc)).layer(from_fn(rate_limiting_middleware)))
@@ -68,6 +77,7 @@ pub async fn gateway_service(
.route("/", get(Redirect::to("/docs")))
.nest("/v1", public_routes.merge(protected_routes))
.nest("/v1/hackathon", hackathon_router(db.clone(), hackathon_config))
.nest("/v1/qr", qr_router(qr_pool, qr_config))
.merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router()))
.layer(cors_middleware())
.layer(from_fn(security_headers_middleware))
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "imphnen-qr"
version = "0.2.0"
edition = "2024"
[dependencies]
imphnen-utils.workspace = true
axum.workspace = true
axum-extra.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
jsonwebtoken.workspace = true
bcrypt.workspace = true
chrono.workspace = true
uuid.workspace = true
sqlx.workspace = true
reqwest.workspace = true
oauth2.workspace = true
tracing.workspace = true
utoipa.workspace = true
image.workspace = true
qrcode.workspace = true
@@ -0,0 +1,154 @@
use std::sync::Arc;
use uuid::Uuid;
use sqlx::PgPool;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::common::qr_jwt::QrJwtService;
use crate::config::QrConfig;
use super::super::domain::service::{QrAuthService, AuthTokens, QrUserData};
pub struct QrAuthServiceImpl {
pool: Arc<PgPool>,
jwt: Arc<QrJwtService>,
config: Arc<QrConfig>,
}
impl QrAuthServiceImpl {
pub fn new(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Self {
Self { pool, jwt, config }
}
async fn find_user_by_id(&self, id: Uuid) -> Result<QrUserData, AppError> {
sqlx::query_as::<_, QrUserData>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1"
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
async fn find_user_by_email(&self, email: &str) -> Result<Option<serde_json::Value>, AppError> {
sqlx::query_scalar::<_, serde_json::Value>(
"SELECT row_to_json(u) FROM (SELECT id, email, name, role, provider, password FROM users WHERE email = $1) u"
)
.bind(email)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
fn make_tokens(&self, user_id: Uuid, role: &str) -> Result<AuthTokens, AppError> {
Ok(AuthTokens {
access_token: self.jwt.generate_token(user_id, role)?,
refresh_token: self.jwt.generate_refresh_token(user_id, role)?,
})
}
}
#[async_trait]
impl QrAuthService for QrAuthServiceImpl {
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError> {
let existing = self.find_user_by_email(&email).await?;
if existing.is_some() {
return Err(AppError::ConflictError("Email already registered".to_string()));
}
let hashed = bcrypt::hash(&password, 10)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let user = sqlx::query_as::<_, QrUserData>(
"INSERT INTO users (email, password, name, role, provider) VALUES ($1, $2, $3, 'user', 'local') RETURNING id, email, name, role, provider, created_at, updated_at"
)
.bind(&email)
.bind(&hashed)
.bind(&name)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError> {
let row = self.find_user_by_email(&email).await?
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
let provider = row["provider"].as_str().unwrap_or("local");
if provider != "local" {
return Err(AppError::AuthenticationError("Account uses social login".to_string()));
}
let stored_hash = row["password"].as_str()
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
let valid = bcrypt::verify(&password, stored_hash)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !valid {
return Err(AppError::AuthenticationError("Invalid credentials".to_string()));
}
let user_id: Uuid = row["id"].as_str()
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| AppError::InternalServerError("Invalid user ID".to_string()))?;
let user = self.find_user_by_id(user_id).await?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError> {
let http = reqwest::Client::new();
let token_res: serde_json::Value = http
.post("https://oauth2.googleapis.com/token")
.form(&[
("code", code.as_str()),
("client_id", self.config.google_client_id.as_str()),
("client_secret", self.config.google_client_secret.as_str()),
("redirect_uri", self.config.google_redirect_url.as_str()),
("grant_type", "authorization_code"),
])
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if token_res.get("error").is_some() {
return Err(AppError::BadRequestError("Google OAuth error".to_string()));
}
let access_token = token_res["access_token"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing access token from Google".to_string()))?;
let google_user: serde_json::Value = http
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let email = google_user["email"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing email from Google".to_string()))?;
let name = google_user["name"].as_str().unwrap_or(email);
let provider_id = google_user["id"].as_str().unwrap_or("");
let user = sqlx::query_as::<_, QrUserData>(
"INSERT INTO users (email, name, role, provider, provider_id) VALUES ($1, $2, 'user', 'google', $3)
ON CONFLICT (email) DO UPDATE SET provider_id = EXCLUDED.provider_id, updated_at = NOW()
RETURNING id, email, name, role, provider, created_at, updated_at"
)
.bind(email)
.bind(name)
.bind(provider_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError> {
let claims = self.jwt.verify_token(&refresh_token)?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::AuthenticationError("Invalid token subject".to_string()))?;
let user = self.find_user_by_id(user_id).await?;
Ok(AuthTokens {
access_token: self.jwt.generate_token(user.id, &user.role)?,
refresh_token,
})
}
}
+1
View File
@@ -0,0 +1 @@
pub mod auth_service;
+1
View File
@@ -0,0 +1 @@
pub mod service;
+30
View File
@@ -0,0 +1,30 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
pub struct QrUserData {
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>>,
}
#[async_trait]
pub trait QrAuthService: Send + Sync {
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError>;
}
@@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::auth::domain::service::QrUserData;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub user: QrUserData,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TokensResponse {
pub access_token: String,
pub refresh_token: String,
}
@@ -0,0 +1,72 @@
use axum::{Extension, Json, response::IntoResponse};
use axum::extract::Query;
use std::sync::Arc;
use serde::Deserialize;
use imphnen_utils::response_format::ApiSuccess;
use imphnen_utils::errors::AppError;
use crate::auth::domain::service::QrAuthService;
use crate::config::QrConfig;
use super::dto::{RegisterRequest, LoginRequest, RefreshRequest, AuthResponse, TokensResponse};
pub async fn register_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<RegisterRequest>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.register(body.email, body.password, body.name).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn login_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<LoginRequest>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.login(body.email, body.password).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn google_redirect_handler(
Extension(config): Extension<Arc<QrConfig>>,
) -> Result<axum::response::Response, AppError> {
let url = format!(
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=email+profile",
config.google_client_id,
config.google_redirect_url,
);
Ok(axum::response::Redirect::temporary(&url).into_response())
}
#[derive(Debug, Deserialize)]
pub struct GoogleCallbackQuery {
pub code: String,
}
pub async fn google_callback_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Query(params): Query<GoogleCallbackQuery>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.google_callback(params.code).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn refresh_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<RefreshRequest>,
) -> Result<axum::response::Response, AppError> {
let tokens = service.refresh_token(body.refresh_token).await?;
Ok(ApiSuccess(TokensResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,29 @@
use axum::{routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::auth::application::auth_service::QrAuthServiceImpl;
use crate::auth::domain::service::QrAuthService;
use crate::common::qr_jwt::QrJwtService;
use crate::config::QrConfig;
use super::handlers::{
register_handler,
login_handler,
google_redirect_handler,
google_callback_handler,
refresh_handler,
};
pub fn qr_auth_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Router {
let service: Arc<dyn QrAuthService> = Arc::new(
QrAuthServiceImpl::new(pool, jwt, config.clone())
);
Router::new()
.route("/auth/register", post(register_handler))
.route("/auth/login", post(login_handler))
.route("/auth/google", get(google_redirect_handler))
.route("/auth/google/callback", get(google_callback_handler))
.route("/auth/refresh", post(refresh_handler))
.layer(Extension(service))
.layer(Extension(config))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
@@ -0,0 +1,88 @@
use async_trait::async_trait;
use image::{DynamicImage, GenericImageView, ImageFormat, imageops};
use imphnen_utils::errors::AppError;
use qrcode::QrCode;
use std::io::Cursor;
use std::sync::Arc;
use uuid::Uuid;
use crate::campaigns::domain::{
entity::{CampaignEntity, CreateCampaignInput},
repository::CampaignRepository,
service::QrCampaignService,
};
pub struct QrCampaignServiceImpl {
repo: Arc<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;
+24
View File
@@ -0,0 +1,24 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)]
pub struct CampaignEntity {
pub id: Uuid,
pub name: String,
pub url: String,
pub is_active: bool,
pub created_by: Uuid,
pub expires_at: DateTime<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>,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,14 @@
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,15 @@
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,85 @@
use axum::{
extract::{Multipart, Path},
response::{IntoResponse, Response},
Extension, Json,
};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
use crate::{
campaigns::{
domain::service::QrCampaignService,
infrastructure::http::dto::CreateCampaignRequest,
},
middleware::qr_auth::QrAuthUser,
};
pub async fn create_campaign_handler(
Extension(service): Extension<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())
}
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())
}
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())
}
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())
}
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,38 @@
use axum::{
middleware::from_fn,
routing::{delete, post, put},
Extension, Router,
};
use sqlx::PgPool;
use std::sync::Arc;
use crate::{
campaigns::{
application::campaign_service::QrCampaignServiceImpl,
domain::{repository::CampaignRepository, service::QrCampaignService},
infrastructure::{
http::handlers::{
activate_campaign_handler, create_campaign_handler, delete_campaign_handler,
list_campaigns_handler, process_image_handler,
},
persistence::postgres_campaign_repository::PostgresCampaignRepository,
},
},
common::qr_jwt::QrJwtService,
middleware::qr_auth::qr_auth_middleware,
};
pub fn qr_campaigns_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> 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(jwt.clone()))
.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,107 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::campaigns::domain::{
entity::{CampaignEntity, CreateCampaignInput},
repository::CampaignRepository,
};
pub struct PostgresCampaignRepository {
pool: Arc<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::<_, CampaignEntity>(
"INSERT INTO qr_campaigns (id, name, url, qr_code_data, is_active, created_by, expires_at) \
VALUES ($1, $2, $3, $4, true, $5, NOW() + INTERVAL '30 days') \
RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at",
)
.bind(id)
.bind(&input.name)
.bind(&input.url)
.bind(&input.qr_code_data)
.bind(input.created_by)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
tx.commit().await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(campaign)
}
async fn find_all(&self) -> Result<Vec<CampaignEntity>, AppError> {
sqlx::query_as::<_, CampaignEntity>(
"SELECT id, name, url, is_active, created_by, expires_at, created_at, updated_at \
FROM qr_campaigns ORDER BY created_at DESC",
)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn find_active_qr_data(&self) -> Result<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::<_, CampaignEntity>(
"UPDATE qr_campaigns SET is_active = true, updated_at = NOW() WHERE id = $1 \
RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at",
)
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
tx.commit().await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(campaign)
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM qr_campaigns WHERE id = $1")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::qr_campaigns_routes;
+1
View File
@@ -0,0 +1 @@
pub mod qr_jwt;
+59
View File
@@ -0,0 +1,59 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use imphnen_utils::errors::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QrClaims {
pub sub: String,
pub role: String,
pub exp: usize,
}
#[derive(Clone)]
pub struct QrJwtService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_minutes: i64,
refresh_expiry_days: i64,
}
impl QrJwtService {
pub fn new(secret: &str, expiry_minutes: i64, refresh_expiry_days: i64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_minutes,
refresh_expiry_days,
}
}
pub fn generate_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
let exp = (Utc::now() + Duration::minutes(self.expiry_minutes)).timestamp() as usize;
let claims = QrClaims {
sub: user_id.to_string(),
role: role.to_string(),
exp,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub fn generate_refresh_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
let exp = (Utc::now() + Duration::days(self.refresh_expiry_days)).timestamp() as usize;
let claims = QrClaims {
sub: user_id.to_string(),
role: role.to_string(),
exp,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub fn verify_token(&self, token: &str) -> Result<QrClaims, AppError> {
decode::<QrClaims>(token, &self.decoding_key, &Validation::default())
.map(|d| d.claims)
.map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string()))
}
}
+30
View File
@@ -0,0 +1,30 @@
use std::env;
#[derive(Debug, Clone)]
pub struct QrConfig {
pub jwt_secret: String,
pub jwt_expiry_minutes: i64,
pub refresh_expiry_days: i64,
pub google_client_id: String,
pub google_client_secret: String,
pub google_redirect_url: String,
}
impl QrConfig {
pub fn from_env() -> Self {
Self {
jwt_secret: env::var("QR_JWT_SECRET").expect("QR_JWT_SECRET must be set"),
jwt_expiry_minutes: env::var("QR_JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "15".to_string())
.parse()
.unwrap_or(15),
refresh_expiry_days: env::var("QR_JWT_REFRESH_EXPIRY_DAYS")
.unwrap_or_else(|_| "7".to_string())
.parse()
.unwrap_or(7),
google_client_id: env::var("QR_GOOGLE_CLIENT_ID").unwrap_or_default(),
google_client_secret: env::var("QR_GOOGLE_CLIENT_SECRET").unwrap_or_default(),
google_redirect_url: env::var("QR_GOOGLE_REDIRECT_URL").unwrap_or_default(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
pub mod config;
pub mod common;
pub mod middleware;
pub mod auth;
pub mod users;
pub mod campaigns;
pub use config::QrConfig;
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
use common::qr_jwt::QrJwtService;
pub fn qr_router(pool: Arc<PgPool>, config: Arc<QrConfig>) -> Router {
let jwt = Arc::new(QrJwtService::new(
&config.jwt_secret,
config.jwt_expiry_minutes,
config.refresh_expiry_days,
));
Router::new()
.merge(auth::infrastructure::http::routes::qr_auth_routes(pool.clone(), jwt.clone(), config.clone()))
.merge(users::infrastructure::http::routes::qr_users_routes(pool.clone(), jwt.clone()))
.merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool.clone(), jwt.clone()))
}
+1
View File
@@ -0,0 +1 @@
pub mod qr_auth;
+39
View File
@@ -0,0 +1,39 @@
use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}};
use axum::http::StatusCode;
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use crate::common::qr_jwt::QrJwtService;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QrAuthUser {
pub user_id: Uuid,
pub role: String,
}
pub async fn qr_auth_middleware(
axum::Extension(jwt_service): axum::Extension<Arc<QrJwtService>>,
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 claims = jwt_service.verify_token(token).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
})?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response()
})?;
request.extensions_mut().insert(QrAuthUser { user_id, role: claims.role });
Ok(next.run(request).await)
}
+1
View File
@@ -0,0 +1 @@
pub mod user_service;
@@ -0,0 +1,51 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
use crate::users::domain::{
entity::{UpdateUserInput, UserEntity},
repository::UserRepository,
service::QrUserService,
};
pub struct QrUserServiceImpl {
repo: Arc<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 {
if email.trim().is_empty() {
return Err(AppError::ValidationError("Email cannot be empty".to_string()));
}
}
self.repo.update(user_id, input).await
}
async fn list_all(&self) -> Result<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
}
}
+21
View File
@@ -0,0 +1,21 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)]
pub struct UserEntity {
pub id: Uuid,
pub email: String,
pub name: String,
pub role: String,
pub provider: String,
pub created_at: Option<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;
+14
View File
@@ -0,0 +1,14 @@
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>;
}
+14
View File
@@ -0,0 +1,14 @@
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,73 @@
use axum::{
extract::Path,
response::{IntoResponse, Response},
Extension, Json,
};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
use crate::{
middleware::qr_auth::QrAuthUser,
users::{
domain::{entity::UpdateUserInput, service::QrUserService},
infrastructure::http::dto::{UpdateProfileRequest, UpdateRoleRequest},
},
};
pub async fn get_me_handler(
Extension(service): Extension<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())
}
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())
}
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())
}
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())
}
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,38 @@
use axum::{
middleware::from_fn,
routing::{delete, get, put},
Extension, Router,
};
use sqlx::PgPool;
use std::sync::Arc;
use crate::{
common::qr_jwt::QrJwtService,
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>, jwt: Arc<QrJwtService>) -> 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(jwt.clone()))
.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,74 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::users::domain::{
entity::{UpdateUserInput, UserEntity},
repository::UserRepository,
};
pub struct PostgresUserRepository {
pool: Arc<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::<_, UserEntity>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn find_all(&self) -> Result<Vec<UserEntity>, AppError> {
sqlx::query_as::<_, UserEntity>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM users ORDER BY created_at DESC",
)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result<UserEntity, AppError> {
sqlx::query_as::<_, UserEntity>(
"UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email), updated_at = NOW() WHERE id = $3 RETURNING id, email, name, role, provider, created_at, updated_at",
)
.bind(input.name)
.bind(input.email)
.bind(id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn update_role(&self, id: Uuid, role: String) -> Result<UserEntity, AppError> {
sqlx::query_as::<_, UserEntity>(
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role, provider, created_at, updated_at",
)
.bind(role)
.bind(id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::qr_users_routes;