graphql
This commit is contained in:
parent
d88a9b920a
commit
d194c0941a
4
.env
Normal file
4
.env
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL=postgresql://mmap:yjhcfzXWrzslzl1331@8.217.64.157:5433/mmap
|
||||||
|
JWT_SECRET="JvGpWgGWLHAhvhxN7BuOVtUWfMXm6xAqjClaTwOcAnI="
|
||||||
|
RUST_LOG=debug
|
||||||
|
PORT=3050
|
||||||
64
.sqlx/query-0325b8932e0a71f18e0018185b5559eb6ec748c67417efaf62520a690ca2b385.json
generated
Normal file
64
.sqlx/query-0325b8932e0a71f18e0018185b5559eb6ec748c67417efaf62520a690ca2b385.json
generated
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "code",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "used_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "is_used",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "expires_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "used_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "0325b8932e0a71f18e0018185b5559eb6ec748c67417efaf62520a690ca2b385"
|
||||||
|
}
|
||||||
22
.sqlx/query-18a1c337af1a4308cac915ab8161c8e5a19b1e798d91445bdeeed8235210fcc4.json
generated
Normal file
22
.sqlx/query-18a1c337af1a4308cac915ab8161c8e5a19b1e798d91445bdeeed8235210fcc4.json
generated
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT value FROM system_config WHERE key = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "value",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "18a1c337af1a4308cac915ab8161c8e5a19b1e798d91445bdeeed8235210fcc4"
|
||||||
|
}
|
||||||
15
.sqlx/query-238508bcec88a825c8bd87965aed4ccc3ce313835d9ed49d111b2722e207bd62.json
generated
Normal file
15
.sqlx/query-238508bcec88a825c8bd87965aed4ccc3ce313835d9ed49d111b2722e207bd62.json
generated
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO system_config (key, value) \n VALUES ($1, $2)\n ON CONFLICT (key) \n DO UPDATE SET value = $2, updated_at = NOW()\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "238508bcec88a825c8bd87965aed4ccc3ce313835d9ed49d111b2722e207bd62"
|
||||||
|
}
|
||||||
64
.sqlx/query-2ab074bca985746d32cb5d932c0012019486847c80eb12d5fccda6841a22613d.json
generated
Normal file
64
.sqlx/query-2ab074bca985746d32cb5d932c0012019486847c80eb12d5fccda6841a22613d.json
generated
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n FROM users WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2ab074bca985746d32cb5d932c0012019486847c80eb12d5fccda6841a22613d"
|
||||||
|
}
|
||||||
64
.sqlx/query-353dee803eee3a4f1e68a8e5f391879128dfc59b963bd519e8dfb2fae24c6c3b.json
generated
Normal file
64
.sqlx/query-353dee803eee3a4f1e68a8e5f391879128dfc59b963bd519e8dfb2fae24c6c3b.json
generated
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE code = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "code",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "used_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "is_used",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "expires_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "used_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "353dee803eee3a4f1e68a8e5f391879128dfc59b963bd519e8dfb2fae24c6c3b"
|
||||||
|
}
|
||||||
23
.sqlx/query-3923b5d03196cc7753417e1748eb1c85a8cd2f5f912ff98ced99506647d770a4.json
generated
Normal file
23
.sqlx/query-3923b5d03196cc7753417e1748eb1c85a8cd2f5f912ff98ced99506647d770a4.json
generated
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE invite_codes \n SET is_used = true, used_by = $1, used_at = NOW()\n WHERE code = $2 AND is_used = false\n RETURNING id\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3923b5d03196cc7753417e1748eb1c85a8cd2f5f912ff98ced99506647d770a4"
|
||||||
|
}
|
||||||
64
.sqlx/query-3bdc7146e0751bcb04a3ed6831c0d094a5d4b38937542f5f1529748c8c6b3aa6.json
generated
Normal file
64
.sqlx/query-3bdc7146e0751bcb04a3ed6831c0d094a5d4b38937542f5f1529748c8c6b3aa6.json
generated
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n FROM users WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3bdc7146e0751bcb04a3ed6831c0d094a5d4b38937542f5f1529748c8c6b3aa6"
|
||||||
|
}
|
||||||
20
.sqlx/query-41e05b2ce23645a612d385f0faa02dad038c2facb9cb1d566fe7572d659c0a03.json
generated
Normal file
20
.sqlx/query-41e05b2ce23645a612d385f0faa02dad038c2facb9cb1d566fe7572d659c0a03.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) FROM users WHERE is_activate = false",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "41e05b2ce23645a612d385f0faa02dad038c2facb9cb1d566fe7572d659c0a03"
|
||||||
|
}
|
||||||
20
.sqlx/query-56c6cd9cd434821ad87afdc1e0e9194d1965d2876676780275ecdb45ee9e16f5.json
generated
Normal file
20
.sqlx/query-56c6cd9cd434821ad87afdc1e0e9194d1965d2876676780275ecdb45ee9e16f5.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) FROM users WHERE role = 'user'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "56c6cd9cd434821ad87afdc1e0e9194d1965d2876676780275ecdb45ee9e16f5"
|
||||||
|
}
|
||||||
64
.sqlx/query-6e1bf3857d6eb4df7f14d0eca0823f39c50f1b18e61bc469d616598a20c5bb91.json
generated
Normal file
64
.sqlx/query-6e1bf3857d6eb4df7f14d0eca0823f39c50f1b18e61bc469d616598a20c5bb91.json
generated
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE created_by = $1\n ORDER BY created_at DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "code",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "used_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "is_used",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "expires_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "used_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6e1bf3857d6eb4df7f14d0eca0823f39c50f1b18e61bc469d616598a20c5bb91"
|
||||||
|
}
|
||||||
12
.sqlx/query-751a432b1a28e71faa4f87c9c470d6ca5212ffcde18ddb574c9c6e705cc02b01.json
generated
Normal file
12
.sqlx/query-751a432b1a28e71faa4f87c9c470d6ca5212ffcde18ddb574c9c6e705cc02b01.json
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE system_config \n SET value = 'true', updated_at = NOW()\n WHERE key = 'admin_initialized'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "751a432b1a28e71faa4f87c9c470d6ca5212ffcde18ddb574c9c6e705cc02b01"
|
||||||
|
}
|
||||||
65
.sqlx/query-84af2d99fe5036ae0f0041aa539427312e89b9faba1c8b6900164fbd0f7a703e.json
generated
Normal file
65
.sqlx/query-84af2d99fe5036ae0f0041aa539427312e89b9faba1c8b6900164fbd0f7a703e.json
generated
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users SET invite_code_id = $1 WHERE id = $2\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "84af2d99fe5036ae0f0041aa539427312e89b9faba1c8b6900164fbd0f7a703e"
|
||||||
|
}
|
||||||
73
.sqlx/query-8a1b2c956e1ff38b2a2a6179f5f7aaddd80dfef77ed05694ba17d7dedebac9dc.json
generated
Normal file
73
.sqlx/query-8a1b2c956e1ff38b2a2a6179f5f7aaddd80dfef77ed05694ba17d7dedebac9dc.json
generated
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO users (username, email, password_hash, role)\n VALUES ($1, $2, $3, $4)\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, is_activate, created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "is_activate",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8a1b2c956e1ff38b2a2a6179f5f7aaddd80dfef77ed05694ba17d7dedebac9dc"
|
||||||
|
}
|
||||||
20
.sqlx/query-9f0ea2fedffe65fa3211fc80bccfa7856d05ea48646879b8dd3579e45e4b6131.json
generated
Normal file
20
.sqlx/query-9f0ea2fedffe65fa3211fc80bccfa7856d05ea48646879b8dd3579e45e4b6131.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT value FROM system_config WHERE key = 'admin_initialized'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "value",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9f0ea2fedffe65fa3211fc80bccfa7856d05ea48646879b8dd3579e45e4b6131"
|
||||||
|
}
|
||||||
65
.sqlx/query-a27168afd0caa5f71fa1036e2508d0a7f83a74433a42d7a31c71a97c522aed79.json
generated
Normal file
65
.sqlx/query-a27168afd0caa5f71fa1036e2508d0a7f83a74433a42d7a31c71a97c522aed79.json
generated
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at FROM users LIMIT $1 OFFSET $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a27168afd0caa5f71fa1036e2508d0a7f83a74433a42d7a31c71a97c522aed79"
|
||||||
|
}
|
||||||
67
.sqlx/query-c2c999a6ab3b058deced5568868eff7e70cf981804cecd228b6477885e1386ae.json
generated
Normal file
67
.sqlx/query-c2c999a6ab3b058deced5568868eff7e70cf981804cecd228b6477885e1386ae.json
generated
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO users (username, email, password_hash, role)\n VALUES ($1, $2, $3, $4)\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "invite_code_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c2c999a6ab3b058deced5568868eff7e70cf981804cecd228b6477885e1386ae"
|
||||||
|
}
|
||||||
20
.sqlx/query-d470eafcaff0b536c38e0d29a1a4f35867068c68ebed4b326517631a8e978da5.json
generated
Normal file
20
.sqlx/query-d470eafcaff0b536c38e0d29a1a4f35867068c68ebed4b326517631a8e978da5.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) FROM users WHERE is_activate = true",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d470eafcaff0b536c38e0d29a1a4f35867068c68ebed4b326517631a8e978da5"
|
||||||
|
}
|
||||||
23
.sqlx/query-d4a34771ec58927f36305a1e5de9b1d6006b44eaf693f666cf63923d322c987f.json
generated
Normal file
23
.sqlx/query-d4a34771ec58927f36305a1e5de9b1d6006b44eaf693f666cf63923d322c987f.json
generated
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT create_invite_code($1, $2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "create_invite_code",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d4a34771ec58927f36305a1e5de9b1d6006b44eaf693f666cf63923d322c987f"
|
||||||
|
}
|
||||||
20
.sqlx/query-d718520b0f79dd1b0999cbb65bd334dca8d7209fa914355a38287af587ae8d4c.json
generated
Normal file
20
.sqlx/query-d718520b0f79dd1b0999cbb65bd334dca8d7209fa914355a38287af587ae8d4c.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) FROM users WHERE role = 'admin'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d718520b0f79dd1b0999cbb65bd334dca8d7209fa914355a38287af587ae8d4c"
|
||||||
|
}
|
||||||
20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
generated
Normal file
20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) FROM users",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d"
|
||||||
|
}
|
||||||
2682
Cargo.lock
generated
2682
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
32
Cargo.toml
@ -1,12 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mapp"
|
name = "mapp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.4"
|
async-graphql = { version = "7.0.17", features = ["chrono", "uuid"] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
async-graphql-axum = "7.0.17"
|
||||||
tokio = { version = "1.46.1", features = ["full"] }
|
axum = { version = "0.8.4", features = ["ws", "macros"] }
|
||||||
tower-http = { version = "0.6.0", features = ["cors"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tracing = "0.1.40"
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tracing-subscriber = "0.3.19"
|
sqlx = { version = "0.8", features = [
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"postgres",
|
||||||
|
"chrono",
|
||||||
|
"uuid",
|
||||||
|
"migrate",
|
||||||
|
] }
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
jsonwebtoken = "9.0"
|
||||||
|
argon2 = "0.5"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
tower = "0.4"
|
||||||
|
async-stream = "0.3"
|
||||||
|
axum-jwt-auth = "0.5.1"
|
||||||
|
|||||||
27
migrations/001_init.sql
Normal file
27
migrations/001_init.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'User',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on username and email for faster lookups
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- Create function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Create trigger to automatically update updated_at
|
||||||
|
CREATE TRIGGER update_users_updated_at BEFORE UPDATE
|
||||||
|
ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
59
migrations/002_invite_codes.sql
Normal file
59
migrations/002_invite_codes.sql
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
-- Create invite_codes table
|
||||||
|
CREATE TABLE invite_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX idx_invite_codes_code ON invite_codes(code);
|
||||||
|
CREATE INDEX idx_invite_codes_created_by ON invite_codes(created_by);
|
||||||
|
CREATE INDEX idx_invite_codes_used_by ON invite_codes(used_by);
|
||||||
|
CREATE INDEX idx_invite_codes_is_used ON invite_codes(is_used);
|
||||||
|
CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at);
|
||||||
|
|
||||||
|
-- Add invite_code_id column to users table
|
||||||
|
ALTER TABLE users ADD COLUMN invite_code_id UUID REFERENCES invite_codes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Create index on the new column
|
||||||
|
CREATE INDEX idx_users_invite_code_id ON users(invite_code_id);
|
||||||
|
|
||||||
|
-- Create function to generate random invite codes
|
||||||
|
CREATE OR REPLACE FUNCTION generate_invite_code()
|
||||||
|
RETURNS VARCHAR(20) AS $$
|
||||||
|
DECLARE
|
||||||
|
chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
result VARCHAR(20) := '';
|
||||||
|
i INTEGER;
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 1..8 LOOP
|
||||||
|
result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1);
|
||||||
|
END LOOP;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create function to create a new invite code
|
||||||
|
CREATE OR REPLACE FUNCTION create_invite_code(creator_id UUID, expires_in_days INTEGER DEFAULT 30)
|
||||||
|
RETURNS VARCHAR(20) AS $$
|
||||||
|
DECLARE
|
||||||
|
new_code VARCHAR(20);
|
||||||
|
code_exists BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
LOOP
|
||||||
|
new_code := generate_invite_code();
|
||||||
|
SELECT EXISTS(SELECT 1 FROM invite_codes WHERE code = new_code) INTO code_exists;
|
||||||
|
EXIT WHEN NOT code_exists;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
INSERT INTO invite_codes (code, created_by, expires_at)
|
||||||
|
VALUES (new_code, creator_id, NOW() + (expires_in_days || ' days')::INTERVAL);
|
||||||
|
|
||||||
|
RETURN new_code;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
20
migrations/003_system_config.sql
Normal file
20
migrations/003_system_config.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Create system_config table to track system-wide settings
|
||||||
|
CREATE TABLE system_config (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on key for faster lookups
|
||||||
|
CREATE INDEX idx_system_config_key ON system_config(key);
|
||||||
|
|
||||||
|
-- Create trigger to automatically update updated_at
|
||||||
|
CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE
|
||||||
|
ON system_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Insert initial system configuration
|
||||||
|
INSERT INTO system_config (key, value, description)
|
||||||
|
VALUES ('admin_initialized', 'false', 'Whether the first admin user has been initialized');
|
||||||
8
migrations/004_add_user_is_activate.sql
Normal file
8
migrations/004_add_user_is_activate.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Add is_activate column to users table
|
||||||
|
ALTER TABLE users ADD COLUMN is_activate BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- Create index on is_activate for faster filtering
|
||||||
|
CREATE INDEX idx_users_is_activate ON users(is_activate);
|
||||||
|
|
||||||
|
-- Update existing users to be activated by default
|
||||||
|
UPDATE users SET is_activate = true WHERE is_activate IS NULL;
|
||||||
99
src/app.rs
Normal file
99
src/app.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_graphql::{
|
||||||
|
http::{playground_source, GraphQLPlaygroundConfig},
|
||||||
|
Schema,
|
||||||
|
};
|
||||||
|
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRef, State},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::DecodingKey;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{AuthUser, AuthUserState, Claims as MyClaims},
|
||||||
|
config::Config,
|
||||||
|
graphql::{MutationRoot, QueryRoot, SubscriptionRoot},
|
||||||
|
services::{
|
||||||
|
invite_code_service::InviteCodeService, system_config_service::SystemConfigService,
|
||||||
|
user_service::UserService,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum_jwt_auth::{Claims, JwtDecoderState, LocalDecoder};
|
||||||
|
use jsonwebtoken::Validation;
|
||||||
|
|
||||||
|
pub type AppSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||||
|
|
||||||
|
#[derive(Clone, FromRef)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub schema: AppSchema,
|
||||||
|
pub decoder: JwtDecoderState<MyClaims>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_router(pool: PgPool, config: Config) -> Router {
|
||||||
|
let user_service = UserService::new(pool.clone(), config.jwt_secret.clone());
|
||||||
|
let invite_code_service = InviteCodeService::new(pool.clone());
|
||||||
|
let system_config_service = SystemConfigService::new(pool.clone());
|
||||||
|
|
||||||
|
let schema = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||||
|
.data(pool)
|
||||||
|
.data(user_service)
|
||||||
|
.data(invite_code_service)
|
||||||
|
.data(system_config_service)
|
||||||
|
.data(config.clone())
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let keys = vec![DecodingKey::from_secret(config.jwt_secret.as_bytes())];
|
||||||
|
let validation = Validation::default();
|
||||||
|
let decoder = LocalDecoder::builder()
|
||||||
|
.keys(keys)
|
||||||
|
.validation(validation)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
schema,
|
||||||
|
decoder: JwtDecoderState {
|
||||||
|
decoder: Arc::new(decoder),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(graphql_playground))
|
||||||
|
.route("/graphql", get(graphql_playground).post(graphql_handler))
|
||||||
|
// .route("/ws", get(graphql_subscription_handler))
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.with_state(app_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn graphql_handler(
|
||||||
|
AuthUserState(user): AuthUserState,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: GraphQLRequest,
|
||||||
|
) -> GraphQLResponse {
|
||||||
|
let mut request = req.into_inner();
|
||||||
|
if let Some(user) = user {
|
||||||
|
request = request.data(user);
|
||||||
|
}
|
||||||
|
state.schema.execute(request).await.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn graphql_playground() -> impl IntoResponse {
|
||||||
|
Html(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// async fn graphql_subscription_handler(
|
||||||
|
// State(state): State<AppState>,
|
||||||
|
// ws: axum::extract::WebSocketUpgrade,
|
||||||
|
// ) -> Response {
|
||||||
|
// ws.on_upgrade(move |socket| async move {
|
||||||
|
// GraphQLSubscription::new(socket, state.schema).serve().await
|
||||||
|
// })
|
||||||
|
// }
|
||||||
163
src/auth.rs
Normal file
163
src/auth.rs
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use async_graphql::{Context, Error, Result};
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{request::Parts, StatusCode},
|
||||||
|
RequestPartsExt,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::user::{Role, User},
|
||||||
|
services::user_service::UserService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub username: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn new(user: &User, jwt_secret: &str) -> Result<String> {
|
||||||
|
let expiration = chrono::Utc::now()
|
||||||
|
.checked_add_signed(chrono::Duration::hours(24))
|
||||||
|
.unwrap()
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user.id.to_string(),
|
||||||
|
username: user.username.clone(),
|
||||||
|
role: user.role,
|
||||||
|
exp: expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
)
|
||||||
|
.map_err(|e| Error::new(format!("Failed to create token: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(token: &str, jwt_secret: &str) -> Result<Claims> {
|
||||||
|
decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map(|data| data.claims)
|
||||||
|
.map_err(|e| Error::new(format!("Invalid token: {}", e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthUserState(pub Option<AuthUser>);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthUserState
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let auth_header = parts
|
||||||
|
.headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header| header.to_str().ok());
|
||||||
|
|
||||||
|
let auth_header = match auth_header {
|
||||||
|
Some(header) => header,
|
||||||
|
None => return Ok(AuthUserState(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !auth_header.starts_with("Bearer ") {
|
||||||
|
return Ok(AuthUserState(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = &auth_header[7..];
|
||||||
|
let jwt_secret = match std::env::var("JWT_SECRET") {
|
||||||
|
Ok(secret) => secret,
|
||||||
|
Err(_) => return Ok(AuthUserState(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let claims = match Claims::verify(token, &jwt_secret) {
|
||||||
|
Ok(claims) => claims,
|
||||||
|
Err(_) => return Ok(AuthUserState(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = match Uuid::parse_str(&claims.sub) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return Ok(AuthUserState(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AuthUserState(Some(AuthUser {
|
||||||
|
id,
|
||||||
|
username: claims.username,
|
||||||
|
role: claims.role,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_auth_user<'a>(ctx: &'a Context<'_>) -> Result<AuthUser> {
|
||||||
|
let claims = ctx
|
||||||
|
.data::<AuthUser>()
|
||||||
|
.map_err(|_| Error::new("Authentication required"))?;
|
||||||
|
|
||||||
|
let uid = &claims.id;
|
||||||
|
|
||||||
|
let user = ctx
|
||||||
|
.data::<UserService>()
|
||||||
|
.unwrap()
|
||||||
|
.get_user_by_id(*uid)
|
||||||
|
.await?
|
||||||
|
.map(|user| AuthUser {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
.ok_or(Error::new("User not found"))?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[derive(Debug, Clone)]
|
||||||
|
// pub struct AuthUser {
|
||||||
|
// pub id: Uuid,
|
||||||
|
// pub username: String,
|
||||||
|
// pub role: Role,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn get_auth_user<'a>(ctx: &'a Context<'_>) -> Result<AuthUser> {
|
||||||
|
// let claims = ctx
|
||||||
|
// .data::<Claims>()
|
||||||
|
// .map_err(|_| Error::new("Authentication required"))?;
|
||||||
|
|
||||||
|
// let uid = &claims.sub;
|
||||||
|
|
||||||
|
// let user = ctx
|
||||||
|
// .data::<UserService>()
|
||||||
|
// .unwrap()
|
||||||
|
// .get_user_by_id(Uuid::parse_str(uid).unwrap())
|
||||||
|
// .await?
|
||||||
|
// .map(|user| AuthUser {
|
||||||
|
// id: user.id,
|
||||||
|
// username: user.username,
|
||||||
|
// role: user.role,
|
||||||
|
// })
|
||||||
|
// .ok_or(Error::new("User not found"))?;
|
||||||
|
|
||||||
|
// Ok(user)
|
||||||
|
// }
|
||||||
23
src/config.rs
Normal file
23
src/config.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, env::VarError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
database_url: env::var("DATABASE_URL")?,
|
||||||
|
jwt_secret: env::var("JWT_SECRET")?,
|
||||||
|
port: env::var("PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(3000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/db.rs
Normal file
14
src/db.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.acquire_timeout(Duration::from_secs(30))
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
|
sqlx::migrate!("./migrations").run(pool).await
|
||||||
|
}
|
||||||
20
src/graphql/guards.rs
Normal file
20
src/graphql/guards.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
use crate::auth::get_auth_user;
|
||||||
|
use crate::models::user::Role;
|
||||||
|
use async_graphql::{Context, Error, Guard, Result};
|
||||||
|
|
||||||
|
pub struct RequireRole(pub Role);
|
||||||
|
|
||||||
|
impl Guard for RequireRole {
|
||||||
|
async fn check(&self, ctx: &Context<'_>) -> Result<()> {
|
||||||
|
let user = get_auth_user(ctx).await?;
|
||||||
|
|
||||||
|
match (self.0, user.role) {
|
||||||
|
(Role::User, Role::User) | (Role::User, Role::Admin) => Ok(()),
|
||||||
|
(Role::Admin, Role::Admin) => Ok(()),
|
||||||
|
_ => Err(Error::new(format!(
|
||||||
|
"Insufficient permissions. Required: {:?}, Current: {:?}",
|
||||||
|
self.0, user.role
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/graphql/mod.rs
Normal file
9
src/graphql/mod.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
pub mod guards;
|
||||||
|
pub mod mutation;
|
||||||
|
pub mod query;
|
||||||
|
pub mod subscription;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use mutation::MutationRoot;
|
||||||
|
pub use query::QueryRoot;
|
||||||
|
pub use subscription::SubscriptionRoot;
|
||||||
83
src/graphql/mutation.rs
Normal file
83
src/graphql/mutation.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use crate::auth::get_auth_user;
|
||||||
|
use crate::graphql::guards::RequireRole;
|
||||||
|
use crate::graphql::types::{
|
||||||
|
CreateInviteCodeInput, CreateUserInput, InitializeAdminInput, InitializeAdminResponse,
|
||||||
|
InviteCodeResponse, LoginInput, LoginResponse, RegisterInput,
|
||||||
|
};
|
||||||
|
use crate::models::user::Role;
|
||||||
|
use crate::models::user::User;
|
||||||
|
use crate::services::invite_code_service::InviteCodeService;
|
||||||
|
use crate::services::user_service::UserService;
|
||||||
|
use async_graphql::{Context, Object, Result};
|
||||||
|
|
||||||
|
pub struct MutationRoot;
|
||||||
|
|
||||||
|
#[Object]
|
||||||
|
impl MutationRoot {
|
||||||
|
async fn register(&self, ctx: &Context<'_>, input: RegisterInput) -> Result<User> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
user_service.register(input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::Admin)")]
|
||||||
|
async fn create_user(&self, ctx: &Context<'_>, input: CreateUserInput) -> Result<User> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
user_service.create_user(input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(&self, ctx: &Context<'_>, input: LoginInput) -> Result<LoginResponse> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
user_service.login(input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_invite_code(
|
||||||
|
&self,
|
||||||
|
ctx: &Context<'_>,
|
||||||
|
input: CreateInviteCodeInput,
|
||||||
|
) -> Result<InviteCodeResponse> {
|
||||||
|
let auth_user = get_auth_user(ctx).await?;
|
||||||
|
let invite_code_service = ctx.data::<InviteCodeService>()?;
|
||||||
|
|
||||||
|
let code = invite_code_service
|
||||||
|
.create_invite_code(auth_user.id, input)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Get the invite code details to return expires_at
|
||||||
|
let invite_codes = invite_code_service
|
||||||
|
.get_invite_codes_by_creator(auth_user.id)
|
||||||
|
.await?;
|
||||||
|
let invite_code = invite_codes
|
||||||
|
.into_iter()
|
||||||
|
.find(|ic| ic.code == code)
|
||||||
|
.ok_or_else(|| async_graphql::Error::new("Failed to retrieve created invite code"))?;
|
||||||
|
|
||||||
|
Ok(InviteCodeResponse {
|
||||||
|
code,
|
||||||
|
expires_at: invite_code.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize_admin(
|
||||||
|
&self,
|
||||||
|
ctx: &Context<'_>,
|
||||||
|
input: InitializeAdminInput,
|
||||||
|
) -> Result<InitializeAdminResponse> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
|
||||||
|
match user_service
|
||||||
|
.initialize_admin(input.username, input.email, input.password)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => Ok(InitializeAdminResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Admin user initialized successfully".to_string(),
|
||||||
|
user: Some(user),
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(InitializeAdminResponse {
|
||||||
|
success: false,
|
||||||
|
message: e.message,
|
||||||
|
user: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/graphql/query.rs
Normal file
71
src/graphql/query.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
use crate::auth::get_auth_user;
|
||||||
|
use crate::graphql::guards::RequireRole;
|
||||||
|
use crate::graphql::types::UserInfoRespnose;
|
||||||
|
use crate::models::invite_code::InviteCode;
|
||||||
|
use crate::models::user::{Role, User};
|
||||||
|
use crate::services::invite_code_service::InviteCodeService;
|
||||||
|
use crate::services::user_service::UserService;
|
||||||
|
use async_graphql::{Context, Object, Result};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub struct QueryRoot;
|
||||||
|
|
||||||
|
#[Object]
|
||||||
|
impl QueryRoot {
|
||||||
|
async fn health_check(&self) -> &str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::User)")]
|
||||||
|
async fn current_user(&self, ctx: &Context<'_>) -> Result<User> {
|
||||||
|
let auth_user = get_auth_user(ctx).await?;
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
|
||||||
|
user_service
|
||||||
|
.get_user_by_id(auth_user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| async_graphql::Error::new("User not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::Admin)")]
|
||||||
|
async fn secret_data(&self, _ctx: &Context<'_>) -> &str {
|
||||||
|
"This is super secret admin data!"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::Admin)")]
|
||||||
|
async fn users(
|
||||||
|
&self,
|
||||||
|
ctx: &Context<'_>,
|
||||||
|
offset: Option<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> Result<Vec<User>> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
info!("users im here");
|
||||||
|
let offset = offset.unwrap_or(0);
|
||||||
|
let limit = limit.unwrap_or(20);
|
||||||
|
user_service.get_all_users(offset, limit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::User)")]
|
||||||
|
async fn my_invite_codes(&self, ctx: &Context<'_>) -> Result<Vec<InviteCode>> {
|
||||||
|
let auth_user = get_auth_user(ctx).await?;
|
||||||
|
let invite_code_service = ctx.data::<InviteCodeService>()?;
|
||||||
|
invite_code_service
|
||||||
|
.get_invite_codes_by_creator(auth_user.id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::Admin)")]
|
||||||
|
async fn validate_invite_code(&self, ctx: &Context<'_>, code: String) -> Result<bool> {
|
||||||
|
let invite_code_service = ctx.data::<InviteCodeService>()?;
|
||||||
|
invite_code_service
|
||||||
|
.validate_invite_code(crate::models::invite_code::ValidateInviteCodeInput { code })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[graphql(guard = "RequireRole(Role::Admin)")]
|
||||||
|
async fn users_info(&self, ctx: &Context<'_>) -> Result<UserInfoRespnose> {
|
||||||
|
let user_service = ctx.data::<UserService>()?;
|
||||||
|
user_service.users_info().await
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/graphql/subscription.rs
Normal file
22
src/graphql/subscription.rs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
use async_graphql::{Subscription, Result};
|
||||||
|
use futures_util::{Stream, StreamExt};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::interval;
|
||||||
|
|
||||||
|
pub struct SubscriptionRoot;
|
||||||
|
|
||||||
|
#[Subscription]
|
||||||
|
impl SubscriptionRoot {
|
||||||
|
async fn ticks(&self) -> impl Stream<Item = i32> {
|
||||||
|
let mut interval = interval(Duration::from_secs(1));
|
||||||
|
let mut counter = 0;
|
||||||
|
|
||||||
|
async_stream::stream! {
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
counter += 1;
|
||||||
|
yield counter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/graphql/types.rs
Normal file
70
src/graphql/types.rs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
use crate::models::user::Role;
|
||||||
|
use async_graphql::{InputObject, SimpleObject};
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct RegisterInput {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub invite_code: String,
|
||||||
|
pub role: Option<Role>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct CreateUserInput {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub role: Option<Role>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct LoginInput {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(SimpleObject)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct CreateInviteCodeInput {
|
||||||
|
pub expires_in_days: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct ValidateInviteCodeInput {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(SimpleObject)]
|
||||||
|
pub struct InviteCodeResponse {
|
||||||
|
pub code: String,
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(InputObject)]
|
||||||
|
pub struct InitializeAdminInput {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(SimpleObject)]
|
||||||
|
pub struct InitializeAdminResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub user: Option<crate::models::user::User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(SimpleObject)]
|
||||||
|
pub struct UserInfoRespnose {
|
||||||
|
pub total_users: i64,
|
||||||
|
pub total_active_users: i64,
|
||||||
|
pub total_inactive_users: i64,
|
||||||
|
pub total_admin_users: i64,
|
||||||
|
pub total_user_users: i64,
|
||||||
|
}
|
||||||
164
src/main.rs
164
src/main.rs
@ -1,146 +1,36 @@
|
|||||||
use axum::{
|
mod app;
|
||||||
Router,
|
mod auth;
|
||||||
extract::{Path, Query},
|
mod config;
|
||||||
http::{StatusCode, header},
|
mod db;
|
||||||
response::{IntoResponse, Response},
|
mod graphql;
|
||||||
routing::get,
|
mod models;
|
||||||
};
|
mod services;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use app::create_router;
|
||||||
use tokio::fs;
|
use config::Config;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use db::{create_pool, run_migrations};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// 初始化日志
|
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// 配置更精确的CORS
|
let config = Config::from_env()?;
|
||||||
let cors = CorsLayer::new()
|
let pool = create_pool(&config.database_url).await?;
|
||||||
.allow_origin(Any) // 允许所有来源,也可以指定特定域名
|
|
||||||
.allow_methods([axum::http::Method::GET]) // 只允许GET请求
|
|
||||||
.allow_headers([axum::http::header::CONTENT_TYPE]); // 允许的头部
|
|
||||||
|
|
||||||
// 构建应用路由
|
run_migrations(&pool).await?;
|
||||||
let app = Router::new()
|
|
||||||
.route("/", get(root))
|
|
||||||
.route("/tiles/{z}/{x}/{y}", get(get_tile))
|
|
||||||
.route("/test", get(get_test))
|
|
||||||
.layer(cors); // 使用配置好的CORS
|
|
||||||
|
|
||||||
// 启动服务器,监听端口 3050
|
let router = create_router(pool, config.clone());
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3050").await.unwrap();
|
|
||||||
println!("瓦片服务器启动成功,监听地址: http://0.0.0.0:3050");
|
|
||||||
println!("API 示例: GET /tiles/6/42/20.png?time=202507220006");
|
|
||||||
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&format!("0.0.0.0:{}", config.port)).await?;
|
||||||
}
|
|
||||||
|
tracing::info!("GraphQL server running on http://0.0.0.0:{}", config.port);
|
||||||
// 根路径处理器
|
tracing::info!(
|
||||||
async fn root() -> &'static str {
|
"GraphiQL playground: http://0.0.0.0:{}/graphql",
|
||||||
"瓦片服务器运行中!\n\n使用方式:\nGET /tiles/{z}/{x}/{y}.png?time=YYYYMMDDHHMM\n\n示例:\nGET /tiles/6/42/20.png?time=202507220006"
|
config.port
|
||||||
}
|
);
|
||||||
|
tracing::info!("WebSocket subscriptions: ws://0.0.0.0:{}/ws", config.port);
|
||||||
// 瓦片处理器
|
|
||||||
async fn get_tile(
|
axum::serve(listener, router).await?;
|
||||||
Path((z, x, y)): Path<(String, String, String)>,
|
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Ok(())
|
||||||
) -> Result<Response, TileError> {
|
|
||||||
// 解析时间参数
|
|
||||||
let time = params.get("time").ok_or(TileError::MissingTimeParameter)?;
|
|
||||||
|
|
||||||
// 验证时间格式(YYYYMMDDHHMM,12位数字)
|
|
||||||
if time.len() != 12 || !time.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Err(TileError::InvalidTimeFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建文件路径
|
|
||||||
let file_path = format!("tiles/{}/{}/{}/{}.png", time, z, x, y);
|
|
||||||
|
|
||||||
// 异步读取文件
|
|
||||||
match fs::read(&file_path).await {
|
|
||||||
Ok(file_data) => {
|
|
||||||
// 创建响应,设置正确的 Content-Type 和缓存头
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, "image/png")
|
|
||||||
.header(header::CACHE_CONTROL, "public, max-age=3600") // 缓存1小时
|
|
||||||
.header(header::ETAG, format!("\"{}\"", generate_etag(&file_path)))
|
|
||||||
.body(file_data.into())
|
|
||||||
.map_err(|_| TileError::InternalServerError)?;
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!("瓦片文件不存在: {}", file_path);
|
|
||||||
Err(TileError::TileNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 瓦片处理器
|
|
||||||
async fn get_test() -> Result<Response, TileError> {
|
|
||||||
// 异步读取文件
|
|
||||||
match fs::read("/Users/xiang.li1/projects/radarprocess/china.png").await {
|
|
||||||
Ok(file_data) => {
|
|
||||||
// 创建响应,设置正确的 Content-Type 和缓存头
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, "image/png")
|
|
||||||
.header(header::CACHE_CONTROL, "public, max-age=3600") // 缓存1小时
|
|
||||||
.header(
|
|
||||||
header::ETAG,
|
|
||||||
format!(
|
|
||||||
"\"{}\"",
|
|
||||||
generate_etag("/Users/xiang.li1/projects/radarprocess/china.png")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.body(file_data.into())
|
|
||||||
.map_err(|_| TileError::InternalServerError)?;
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
Err(_) => Err(TileError::TileNotFound),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成简单的 ETag
|
|
||||||
fn generate_etag(path: &str) -> String {
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
path.hash(&mut hasher);
|
|
||||||
format!("{:x}", hasher.finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误类型定义
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum TileError {
|
|
||||||
MissingTimeParameter,
|
|
||||||
InvalidTimeFormat,
|
|
||||||
TileNotFound,
|
|
||||||
InternalServerError,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误响应实现
|
|
||||||
impl IntoResponse for TileError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, error_message) = match self {
|
|
||||||
TileError::MissingTimeParameter => (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"缺少时间参数。请使用 ?time=YYYYMMDDHHMM 格式",
|
|
||||||
),
|
|
||||||
TileError::InvalidTimeFormat => (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"时间格式无效。请使用 YYYYMMDDHHMM 格式(12位数字)",
|
|
||||||
),
|
|
||||||
TileError::TileNotFound => (StatusCode::NOT_FOUND, "瓦片文件不存在"),
|
|
||||||
TileError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, "服务器内部错误"),
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::error!("瓦片服务错误: {:?}", self);
|
|
||||||
|
|
||||||
(status, error_message).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/models/invite_code.rs
Normal file
27
src/models/invite_code.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use async_graphql::SimpleObject;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, SimpleObject)]
|
||||||
|
pub struct InviteCode {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub code: String,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub used_by: Option<Uuid>,
|
||||||
|
pub is_used: bool,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
pub used_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UseInviteCodeInput {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ValidateInviteCodeInput {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
5
src/models/mod.rs
Normal file
5
src/models/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub mod invite_code;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use invite_code::*;
|
||||||
|
pub use user::*;
|
||||||
47
src/models/user.rs
Normal file
47
src/models/user.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
use async_graphql::{Enum, SimpleObject};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Enum, Copy, PartialEq, Eq, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "VARCHAR", rename_all = "PascalCase")]
|
||||||
|
pub enum Role {
|
||||||
|
User,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Role {
|
||||||
|
fn default() -> Self {
|
||||||
|
Role::User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, SimpleObject)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
#[graphql(skip)]
|
||||||
|
pub password_hash: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub invite_code_id: Option<Uuid>,
|
||||||
|
pub is_activate: bool,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateUserInput {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub invite_code: String,
|
||||||
|
pub role: Option<Role>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LoginInput {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
130
src/services/invite_code_service.rs
Normal file
130
src/services/invite_code_service.rs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
use async_graphql::{Error, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
graphql::types::CreateInviteCodeInput,
|
||||||
|
models::invite_code::{InviteCode, UseInviteCodeInput, ValidateInviteCodeInput},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct InviteCodeService {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InviteCodeService {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invite_code(
|
||||||
|
&self,
|
||||||
|
creator_id: Uuid,
|
||||||
|
input: CreateInviteCodeInput,
|
||||||
|
) -> Result<String> {
|
||||||
|
let expires_in_days = input.expires_in_days.unwrap_or(30);
|
||||||
|
|
||||||
|
let code = sqlx::query_scalar!(
|
||||||
|
"SELECT create_invite_code($1, $2)",
|
||||||
|
creator_id,
|
||||||
|
expires_in_days
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to create invite code: {}", e)))?
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_invite_code(&self, input: ValidateInviteCodeInput) -> Result<bool> {
|
||||||
|
let invite_code = sqlx::query_as!(
|
||||||
|
InviteCode,
|
||||||
|
r#"
|
||||||
|
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
|
||||||
|
FROM invite_codes
|
||||||
|
WHERE code = $1
|
||||||
|
"#,
|
||||||
|
input.code
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?
|
||||||
|
.ok_or_else(|| Error::new("Invalid invite code"))?;
|
||||||
|
|
||||||
|
// Check if code is already used
|
||||||
|
if invite_code.is_used {
|
||||||
|
return Err(Error::new("Invite code has already been used"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if code has expired
|
||||||
|
if let Some(expires_at) = invite_code.expires_at {
|
||||||
|
if expires_at < Utc::now() {
|
||||||
|
return Err(Error::new("Invite code has expired"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn use_invite_code(&self, code: &str, user_id: Uuid) -> Result<Uuid> {
|
||||||
|
// First validate the code
|
||||||
|
self.validate_invite_code(ValidateInviteCodeInput {
|
||||||
|
code: code.to_string(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Use the code in a transaction
|
||||||
|
let invite_code_id = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
UPDATE invite_codes
|
||||||
|
SET is_used = true, used_by = $1, used_at = NOW()
|
||||||
|
WHERE code = $2 AND is_used = false
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
user_id,
|
||||||
|
code
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?
|
||||||
|
.ok_or_else(|| Error::new("Failed to use invite code"))?;
|
||||||
|
|
||||||
|
Ok(invite_code_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invite_codes_by_creator(&self, creator_id: Uuid) -> Result<Vec<InviteCode>> {
|
||||||
|
let invite_codes = sqlx::query_as!(
|
||||||
|
InviteCode,
|
||||||
|
r#"
|
||||||
|
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
|
||||||
|
FROM invite_codes
|
||||||
|
WHERE created_by = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
creator_id
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(invite_codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invite_code_by_id(&self, id: Uuid) -> Result<Option<InviteCode>> {
|
||||||
|
let invite_code = sqlx::query_as!(
|
||||||
|
InviteCode,
|
||||||
|
r#"
|
||||||
|
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
|
||||||
|
FROM invite_codes
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(invite_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/services/mod.rs
Normal file
3
src/services/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod invite_code_service;
|
||||||
|
pub mod system_config_service;
|
||||||
|
pub mod user_service;
|
||||||
80
src/services/system_config_service.rs
Normal file
80
src/services/system_config_service.rs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
use async_graphql::{Error, Result};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub struct SystemConfigService {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemConfigService {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_admin_initialized(&self) -> Result<bool> {
|
||||||
|
let value = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
SELECT value FROM system_config WHERE key = 'admin_initialized'
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?
|
||||||
|
.unwrap_or_else(|| "false".to_string());
|
||||||
|
|
||||||
|
Ok(value == "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_admin_initialized(&self) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE system_config
|
||||||
|
SET value = 'true', updated_at = NOW()
|
||||||
|
WHERE key = 'admin_initialized'
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
Error::new(format!(
|
||||||
|
"Failed to update admin initialization status: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Admin initialization status set to true");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_config_value(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
let value = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
SELECT value FROM system_config WHERE key = $1
|
||||||
|
"#,
|
||||||
|
key
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET value = $2, updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to set config value: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
338
src/services/user_service.rs
Normal file
338
src/services/user_service.rs
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
|
use async_graphql::{Error, Result};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::info;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::graphql::types::{
|
||||||
|
CreateUserInput, LoginInput, LoginResponse, RegisterInput, UserInfoRespnose,
|
||||||
|
};
|
||||||
|
use crate::models::user::{Role, User};
|
||||||
|
use crate::services::invite_code_service::InviteCodeService;
|
||||||
|
use crate::services::system_config_service::SystemConfigService;
|
||||||
|
|
||||||
|
pub struct UserService {
|
||||||
|
pool: PgPool,
|
||||||
|
jwt_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserService {
|
||||||
|
pub fn new(pool: PgPool, jwt_secret: String) -> Self {
|
||||||
|
Self { pool, jwt_secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(&self, input: RegisterInput) -> Result<User> {
|
||||||
|
let password_hash = self.hash_password(&input.password)?;
|
||||||
|
let role = input.role.unwrap_or(Role::User);
|
||||||
|
|
||||||
|
// Use transaction to ensure data consistency
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to start transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Validate invite code first
|
||||||
|
let invite_code_service = InviteCodeService::new(self.pool.clone());
|
||||||
|
invite_code_service
|
||||||
|
.validate_invite_code(crate::models::invite_code::ValidateInviteCodeInput {
|
||||||
|
code: input.invite_code.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create user first
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (username, email, password_hash, role, is_activate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
input.username,
|
||||||
|
input.email,
|
||||||
|
password_hash,
|
||||||
|
role as Role,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("duplicate key") {
|
||||||
|
Error::new("Username or email already exists")
|
||||||
|
} else {
|
||||||
|
Error::new(format!("Failed to create user: {}", e))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Use the invite code
|
||||||
|
let invite_code_id = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
UPDATE invite_codes
|
||||||
|
SET is_used = true, used_by = $1, used_at = NOW()
|
||||||
|
WHERE code = $2 AND is_used = false
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
user.id,
|
||||||
|
input.invite_code
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?
|
||||||
|
.ok_or_else(|| Error::new("Failed to use invite code"))?;
|
||||||
|
|
||||||
|
// Update user with invite code id
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
UPDATE users SET invite_code_id = $1 WHERE id = $2
|
||||||
|
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
invite_code_id,
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to update user with invite code: {}", e)))?;
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(&self, input: CreateUserInput) -> Result<User> {
|
||||||
|
let password_hash = self.hash_password(&input.password)?;
|
||||||
|
let role = input.role.unwrap_or(Role::User);
|
||||||
|
|
||||||
|
// Create user first
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (username, email, password_hash, role, is_activate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
input.username,
|
||||||
|
input.email,
|
||||||
|
password_hash,
|
||||||
|
role as Role,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("duplicate key") {
|
||||||
|
Error::new("Username or email already exists")
|
||||||
|
} else {
|
||||||
|
Error::new(format!("Failed to create user: {}", e))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, input: LoginInput) -> Result<LoginResponse> {
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
"#,
|
||||||
|
input.username
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?
|
||||||
|
.ok_or_else(|| Error::new("Invalid credentials"))?;
|
||||||
|
|
||||||
|
if !self.verify_password(&input.password, &user.password_hash)? {
|
||||||
|
return Err(Error::new("Invalid credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Claims::new(&user, &self.jwt_secret)?;
|
||||||
|
|
||||||
|
Ok(LoginResponse {
|
||||||
|
token,
|
||||||
|
user_id: user.id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>> {
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_users(&self, offset: i64, limit: i64) -> Result<Vec<User>> {
|
||||||
|
let users = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at FROM users LIMIT $1 OFFSET $2"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
info!("users: {:?}", users);
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize_admin(
|
||||||
|
&self,
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<User> {
|
||||||
|
// Check if admin is already initialized
|
||||||
|
let system_config_service = SystemConfigService::new(self.pool.clone());
|
||||||
|
if system_config_service.is_admin_initialized().await? {
|
||||||
|
return Err(Error::new("Admin has already been initialized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any users exist
|
||||||
|
let user_count = sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
if user_count.unwrap_or(0) > 0 {
|
||||||
|
return Err(Error::new("Users already exist in the system"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = self.hash_password(&password)?;
|
||||||
|
|
||||||
|
// Use transaction to ensure data consistency
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to start transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (username, email, password_hash, role, is_activate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
Role::Admin as Role,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("duplicate key") {
|
||||||
|
Error::new("Username or email already exists")
|
||||||
|
} else {
|
||||||
|
Error::new(format!("Failed to create admin user: {}", e))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Mark admin as initialized
|
||||||
|
system_config_service.set_admin_initialized().await?;
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
info!("Admin user initialized: {}", user.username);
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_password(&self, password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| Error::new(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string()
|
||||||
|
.pipe(Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> Result<bool> {
|
||||||
|
let parsed_hash = PasswordHash::new(hash)
|
||||||
|
.map_err(|e| Error::new(format!("Invalid password hash: {}", e)))?;
|
||||||
|
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
Ok(argon2
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_info(&self) -> Result<UserInfoRespnose> {
|
||||||
|
let total_users = sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
let total_active_users =
|
||||||
|
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE is_activate = true"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
let total_inactive_users =
|
||||||
|
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE is_activate = false"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
let total_admin_users =
|
||||||
|
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE role = 'admin'"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
let total_user_users =
|
||||||
|
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE role = 'user'"#)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(UserInfoRespnose {
|
||||||
|
total_users: total_users.unwrap_or(0),
|
||||||
|
total_active_users: total_active_users.unwrap_or(0),
|
||||||
|
total_inactive_users: total_inactive_users.unwrap_or(0),
|
||||||
|
total_admin_users: total_admin_users.unwrap_or(0),
|
||||||
|
total_user_users: total_user_users.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Pipe<T> {
|
||||||
|
fn pipe<F, R>(self, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(Self) -> R,
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Pipe<T> for T {
|
||||||
|
fn pipe<F, R>(self, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(Self) -> R,
|
||||||
|
{
|
||||||
|
f(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user