Compare commits
18 Commits
8f62c2513e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be69a54fd1 | ||
|
|
90aa4f8185 | ||
|
|
5d5f9ec765 | ||
|
|
7affe88d50 | ||
|
|
e02139ff44 | ||
|
|
45f529902d | ||
|
|
ef9f860da2 | ||
|
|
8a5605812c | ||
|
|
d27196d7e5 | ||
|
|
9cbcdc533e | ||
|
|
f18899b1a6 | ||
|
|
3bfac6d012 | ||
|
|
0b402c6259 | ||
|
|
8b5f55db6f | ||
|
|
b252216709 | ||
|
|
da590fb7c6 | ||
|
|
04c2d2b7f5 | ||
|
|
d96a29ee73 |
18
.sqlx/query-02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65.json
generated
Normal file
18
.sqlx/query-02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO comments (user_id, comment_id, post_id, author, content)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65"
|
||||||
|
}
|
||||||
64
.sqlx/query-059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4.json
generated
Normal file
64
.sqlx/query-059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4.json
generated
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4"
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "post_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "author",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "content",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "published_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8",
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "1778ace39189532c2c69850ad7366bb36c8b5fe7491064a53190a324485f0e53"
|
|
||||||
}
|
|
||||||
12
.sqlx/query-1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5.json
generated
Normal file
12
.sqlx/query-1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM idempotency\n WHERE created_at < NOW() - INTERVAL '1 hour'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5"
|
||||||
|
}
|
||||||
64
.sqlx/query-1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce.json
generated
Normal file
64
.sqlx/query-1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce.json
generated
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.author_id, u.username as author, u.full_name,\n p.post_id, p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "author_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce"
|
||||||
|
}
|
||||||
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO idempotency (idempotency_key, created_at)\n VALUES ($1, now())\n ON CONFLICT DO NOTHING\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65"
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b"
|
|
||||||
}
|
|
||||||
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal file
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, username, role as \"role: Role\", full_name, bio, member_since\n FROM users\n WHERE user_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "bio",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "member_since",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE idempotency_key = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Uuid",
|
|
||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -54,5 +53,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
|
"hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9"
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO comments (comment_id, post_id, author, content)\n VALUES ($1, $2, $3, $4)\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "767386497874bbf3988938d62112be9479d5dc7eb523246ac98816ff3e8d2754"
|
|
||||||
}
|
|
||||||
22
.sqlx/query-769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0.json
generated
Normal file
22
.sqlx/query-769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT login_time FROM user_logins\n WHERE user_id = $1\n ORDER BY login_time DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "login_time",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0"
|
||||||
|
}
|
||||||
12
.sqlx/query-7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104.json
generated
Normal file
12
.sqlx/query-7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM subscriptions\n WHERE status = 'pending_confirmation'\n AND subscribed_at < NOW() - INTERVAL '24 hours'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104"
|
||||||
|
}
|
||||||
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal file
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT author_id FROM posts WHERE post_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "author_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b"
|
||||||
|
}
|
||||||
@@ -1,42 +1,53 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT comment_id, post_id, author, content, published_at\n FROM comments\n WHERE post_id = $1\n ORDER BY published_at DESC\n LIMIT $2\n OFFSET $3\n ",
|
"query": "\n SELECT c.user_id as \"user_id?\", u.username as \"username?\", c.comment_id, c.post_id, c.author, c.content, c.published_at\n FROM comments c\n LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL\n ORDER BY published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "comment_id",
|
"name": "user_id?",
|
||||||
"type_info": "Uuid"
|
"type_info": "Uuid"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
|
"name": "username?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "comment_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
"name": "post_id",
|
"name": "post_id",
|
||||||
"type_info": "Uuid"
|
"type_info": "Uuid"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 4,
|
||||||
"name": "author",
|
"name": "author",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 5,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 6,
|
||||||
"name": "published_at",
|
"name": "published_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Uuid",
|
|
||||||
"Int8",
|
"Int8",
|
||||||
"Int8"
|
"Int8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@@ -44,5 +55,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0b5951c4e52f86101b33606abf65cdb411578eea4e0635a2b0ea14600e6d5031"
|
"hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
|
||||||
}
|
}
|
||||||
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal file
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users\n SET username = $1, full_name = $2, bio = $3\n WHERE user_id = $4\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd"
|
||||||
|
}
|
||||||
17
.sqlx/query-aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654.json
generated
Normal file
17
.sqlx/query-aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE posts\n SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Timestamptz",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654"
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n UPDATE idempotency\n SET\n response_status_code = $3,\n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
"query": "\n UPDATE idempotency\n SET\n response_status_code = $2,\n response_headers = $3,\n response_body = $4\n WHERE idempotency_key = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Uuid",
|
|
||||||
"Text",
|
"Text",
|
||||||
"Int2",
|
"Int2",
|
||||||
{
|
{
|
||||||
@@ -37,5 +36,5 @@
|
|||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
|
"hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153"
|
||||||
}
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "post_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "author",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "content",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "published_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f"
|
|
||||||
}
|
|
||||||
28
.sqlx/query-bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b.json
generated
Normal file
28
.sqlx/query-bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b.json
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT username, full_name FROM users WHERE user_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b"
|
||||||
|
}
|
||||||
65
.sqlx/query-dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710.json
generated
Normal file
65
.sqlx/query-dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710.json
generated
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710"
|
||||||
|
}
|
||||||
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal file
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT user_id FROM users WHERE username = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05"
|
||||||
|
}
|
||||||
@@ -1,41 +1,54 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT comment_id, post_id, author, content, published_at\n FROM comments\n ORDER BY published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
"query": "\n SELECT c.user_id as \"user_id?\", u.username as \"username?\", c.comment_id, c.post_id, c.author, c.content, c.published_at\n FROM comments c\n LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL\n WHERE c.post_id = $1\n ORDER BY c.published_at DESC\n LIMIT $2\n OFFSET $3\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "comment_id",
|
"name": "user_id?",
|
||||||
"type_info": "Uuid"
|
"type_info": "Uuid"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
|
"name": "username?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "comment_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
"name": "post_id",
|
"name": "post_id",
|
||||||
"type_info": "Uuid"
|
"type_info": "Uuid"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 4,
|
||||||
"name": "author",
|
"name": "author",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 5,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 6,
|
||||||
"name": "published_at",
|
"name": "published_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
"Int8",
|
"Int8",
|
||||||
"Int8"
|
"Int8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@@ -43,5 +56,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "dcfd46faa2f2e590bcea3ade11d48cc632d8e0027faa95142f0a698f6b06a378"
|
"hash": "fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259"
|
||||||
}
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT u.username as author, p.post_id, p.title, p.content, p.published_at\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "author",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "post_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "content",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "published_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75"
|
|
||||||
}
|
|
||||||
14
.sqlx/query-fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3.json
generated
Normal file
14
.sqlx/query-fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO user_logins (user_id) VALUES ($1)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3"
|
||||||
|
}
|
||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -4070,7 +4070,6 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"claims",
|
"claims",
|
||||||
"config",
|
"config",
|
||||||
@@ -4087,7 +4086,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4096,8 +4094,6 @@ dependencies = [
|
|||||||
"tower-sessions-redis-store",
|
"tower-sessions-redis-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"unicode-segmentation",
|
|
||||||
"urlencoding",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ anyhow = "1.0.99"
|
|||||||
argon2 = { version = "0.5.3", features = ["std"] }
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
askama = "0.14.0"
|
askama = "0.14.0"
|
||||||
axum = { version = "0.8.4", features = ["macros"] }
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
base64 = "0.22.1"
|
|
||||||
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
||||||
config = "0.15.14"
|
config = "0.15.14"
|
||||||
markdown = "1.0.0"
|
markdown = "1.0.0"
|
||||||
@@ -56,8 +55,6 @@ tower-sessions = "0.14.0"
|
|||||||
tower-sessions-redis-store = "0.16.0"
|
tower-sessions-redis-store = "0.16.0"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
unicode-segmentation = "1.12.0"
|
|
||||||
urlencoding = "2.1.3"
|
|
||||||
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
||||||
validator = { version = "0.20.0", features = ["derive"] }
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
|
|
||||||
@@ -70,5 +67,4 @@ quickcheck = "1.0.3"
|
|||||||
quickcheck_macros = "1.1.0"
|
quickcheck_macros = "1.1.0"
|
||||||
scraper = "0.24.0"
|
scraper = "0.24.0"
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
serde_urlencoded = "0.7.1"
|
|
||||||
wiremock = "0.6.4"
|
wiremock = "0.6.4"
|
||||||
|
|||||||
2349
assets/css/main.css
2349
assets/css/main.css
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP CONSTRAINT idempotency_user_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP CONSTRAINT idempotency_pkey;
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
ADD PRIMARY KEY (idempotency_key);
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP COLUMN user_id;
|
||||||
3
migrations/20251008112745_add_user_id_to_comments.sql
Normal file
3
migrations/20251008112745_add_user_id_to_comments.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE comments
|
||||||
|
ADD COLUMN user_id UUID
|
||||||
|
REFERENCES users (user_id) ON DELETE SET NULL;
|
||||||
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN last_modified TIMESTAMPTZ;
|
||||||
5
migrations/20251009180347_create_user_logins_table.sql
Normal file
5
migrations/20251009180347_create_user_logins_table.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE user_logins (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||||
|
login_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -1123,7 +1123,8 @@
|
|||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
||||||
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
|
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
use crate::telemetry::spawn_blocking_with_tracing;
|
use crate::telemetry::spawn_blocking_with_tracing;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use argon2::{
|
use argon2::{
|
||||||
@@ -8,6 +6,7 @@ use argon2::{
|
|||||||
};
|
};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::fmt::Display;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
|
|||||||
58
src/database_worker.rs
Normal file
58
src/database_worker.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use sqlx::{
|
||||||
|
PgPool,
|
||||||
|
postgres::{PgConnectOptions, PgPoolOptions},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub async fn run_until_stopped(configuration: PgConnectOptions) -> Result<(), anyhow::Error> {
|
||||||
|
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration);
|
||||||
|
worker_loop(connection_pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn worker_loop(connection_pool: PgPool) -> Result<(), anyhow::Error> {
|
||||||
|
loop {
|
||||||
|
if let Err(e) = clean_pending_subscriptions(&connection_pool).await {
|
||||||
|
tracing::error!("{:?}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = clean_idempotency_keys(&connection_pool).await {
|
||||||
|
tracing::error!("{:?}", e);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clean_pending_subscriptions(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"
|
||||||
|
DELETE FROM subscriptions
|
||||||
|
WHERE status = 'pending_confirmation'
|
||||||
|
AND subscribed_at < NOW() - INTERVAL '24 hours'
|
||||||
|
"
|
||||||
|
)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to clean up subscriptions table.")?;
|
||||||
|
match result.rows_affected() {
|
||||||
|
n if n > 0 => tracing::info!("Cleaned up {} expired subscriptions.", n),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clean_idempotency_keys(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"
|
||||||
|
DELETE FROM idempotency
|
||||||
|
WHERE created_at < NOW() - INTERVAL '1 hour'
|
||||||
|
"
|
||||||
|
)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to clean up idempontency table.")?;
|
||||||
|
match result.rows_affected() {
|
||||||
|
n if n > 0 => tracing::info!("Cleaned up {} old idempotency records.", n),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct CommentEntry {
|
pub struct CommentEntry {
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub username: Option<String>,
|
||||||
pub comment_id: Uuid,
|
pub comment_id: Uuid,
|
||||||
pub post_id: Uuid,
|
pub post_id: Uuid,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
@@ -11,6 +13,6 @@ pub struct CommentEntry {
|
|||||||
|
|
||||||
impl CommentEntry {
|
impl CommentEntry {
|
||||||
pub fn formatted_date(&self) -> String {
|
pub fn formatted_date(&self) -> String {
|
||||||
self.published_at.format("%B %d, %Y").to_string()
|
self.published_at.format("%B %d, %Y %H:%M").to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,23 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub struct PostEntry {
|
pub struct PostEntry {
|
||||||
pub post_id: Uuid,
|
pub post_id: Uuid,
|
||||||
|
pub author_id: Uuid,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub published_at: DateTime<Utc>,
|
pub published_at: DateTime<Utc>,
|
||||||
|
pub last_modified: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PostEntry {
|
impl PostEntry {
|
||||||
pub fn formatted_date(&self) -> String {
|
pub fn formatted_date(&self) -> String {
|
||||||
self.published_at.format("%B %d, %Y").to_string()
|
self.published_at.format("%B %d, %Y %H:%M").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_html(self) -> Result<Self, anyhow::Error> {
|
pub fn to_html(&self) -> anyhow::Result<String> {
|
||||||
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
|
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
|
||||||
Ok(mut content) => {
|
Ok(content) => Ok(content),
|
||||||
content = content.replace("<table>", r#"<div class="table-wrapper"><table>"#);
|
|
||||||
content = content.replace("</table>", r#"</table></div>"#);
|
|
||||||
Ok(Self { content, ..self })
|
|
||||||
}
|
|
||||||
Err(e) => anyhow::bail!(e),
|
Err(e) => anyhow::bail!(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
#[derive(Debug, Validate)]
|
#[derive(Debug, Validate)]
|
||||||
@@ -22,6 +24,12 @@ impl AsRef<str> for SubscriberEmail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for SubscriberEmail {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::SubscriberEmail;
|
use super::SubscriberEmail;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use axum::{
|
|||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::Type)]
|
#[derive(Debug, sqlx::Type)]
|
||||||
#[sqlx(type_name = "header_pair")]
|
#[sqlx(type_name = "header_pair")]
|
||||||
@@ -23,7 +22,6 @@ struct HeaderPairRecord {
|
|||||||
pub async fn get_saved_response(
|
pub async fn get_saved_response(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<Option<Response>, anyhow::Error> {
|
) -> Result<Option<Response>, anyhow::Error> {
|
||||||
let saved_response = sqlx::query!(
|
let saved_response = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
@@ -32,11 +30,8 @@ pub async fn get_saved_response(
|
|||||||
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
||||||
response_body as "response_body!"
|
response_body as "response_body!"
|
||||||
FROM idempotency
|
FROM idempotency
|
||||||
WHERE
|
WHERE idempotency_key = $1
|
||||||
user_id = $1
|
|
||||||
AND idempotency_key = $2
|
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref()
|
idempotency_key.as_ref()
|
||||||
)
|
)
|
||||||
.fetch_optional(connection_pool)
|
.fetch_optional(connection_pool)
|
||||||
@@ -61,7 +56,6 @@ pub async fn get_saved_response(
|
|||||||
pub async fn save_response(
|
pub async fn save_response(
|
||||||
mut transaction: Transaction<'static, Postgres>,
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
response: Response<Body>,
|
response: Response<Body>,
|
||||||
) -> Result<Response<Body>, anyhow::Error> {
|
) -> Result<Response<Body>, anyhow::Error> {
|
||||||
let status_code = response.status().as_u16() as i16;
|
let status_code = response.status().as_u16() as i16;
|
||||||
@@ -80,14 +74,11 @@ pub async fn save_response(
|
|||||||
r#"
|
r#"
|
||||||
UPDATE idempotency
|
UPDATE idempotency
|
||||||
SET
|
SET
|
||||||
response_status_code = $3,
|
response_status_code = $2,
|
||||||
response_headers = $4,
|
response_headers = $3,
|
||||||
response_body = $5
|
response_body = $4
|
||||||
WHERE
|
WHERE idempotency_key = $1
|
||||||
user_id = $1
|
|
||||||
AND idempotency_key = $2
|
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref(),
|
idempotency_key.as_ref(),
|
||||||
status_code,
|
status_code,
|
||||||
headers,
|
headers,
|
||||||
@@ -109,23 +100,21 @@ pub enum NextAction {
|
|||||||
pub async fn try_processing(
|
pub async fn try_processing(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<NextAction, anyhow::Error> {
|
) -> Result<NextAction, anyhow::Error> {
|
||||||
let mut transaction = connection_pool.begin().await?;
|
let mut transaction = connection_pool.begin().await?;
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO idempotency (user_id, idempotency_key, created_at)
|
INSERT INTO idempotency (idempotency_key, created_at)
|
||||||
VALUES ($1, $2, now())
|
VALUES ($1, now())
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref()
|
idempotency_key.as_ref()
|
||||||
);
|
);
|
||||||
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
||||||
if n_inserted_rows > 0 {
|
if n_inserted_rows > 0 {
|
||||||
Ok(NextAction::StartProcessing(transaction))
|
Ok(NextAction::StartProcessing(transaction))
|
||||||
} else {
|
} else {
|
||||||
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id)
|
let saved_response = get_saved_response(connection_pool, idempotency_key)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
||||||
Ok(NextAction::ReturnSavedResponse(saved_response))
|
Ok(NextAction::ReturnSavedResponse(saved_response))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
|||||||
use tracing::{Span, field::display};
|
use tracing::{Span, field::display};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
|
pub async fn run_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
|
||||||
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
||||||
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||||
worker_loop(connection_pool, email_client).await
|
worker_loop(connection_pool, email_client).await
|
||||||
@@ -31,14 +31,6 @@ pub enum ExecutionOutcome {
|
|||||||
EmptyQueue,
|
EmptyQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
|
||||||
skip_all,
|
|
||||||
fields(
|
|
||||||
newsletter_issue_id=tracing::field::Empty,
|
|
||||||
subscriber_email=tracing::field::Empty
|
|
||||||
),
|
|
||||||
err
|
|
||||||
)]
|
|
||||||
pub async fn try_execute_task(
|
pub async fn try_execute_task(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
email_client: &EmailClient,
|
email_client: &EmailClient,
|
||||||
@@ -53,25 +45,14 @@ pub async fn try_execute_task(
|
|||||||
.record("subscriber_email", display(&task.subscriber_email));
|
.record("subscriber_email", display(&task.subscriber_email));
|
||||||
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
||||||
Ok(email) => {
|
Ok(email) => {
|
||||||
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
execute_task(
|
||||||
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
connection_pool,
|
||||||
if task.kind == EmailType::NewPost.to_string() {
|
&mut transaction,
|
||||||
issue.inject_tracking_info(&mut transaction).await?;
|
&task,
|
||||||
}
|
email,
|
||||||
if let Err(e) = email_client
|
email_client,
|
||||||
.send_email(
|
|
||||||
&email,
|
|
||||||
&issue.title,
|
|
||||||
&issue.html_content,
|
|
||||||
&issue.text_content,
|
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
error = %e,
|
|
||||||
"Failed to deliver issue to confirmed subscriber. Skipping."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -178,6 +159,35 @@ async fn dequeue_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Executing task",
|
||||||
|
skip_all,
|
||||||
|
fields(email = %email),
|
||||||
|
)]
|
||||||
|
async fn execute_task(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
|
task: &Task,
|
||||||
|
email: SubscriberEmail,
|
||||||
|
email_client: &EmailClient,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
||||||
|
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
||||||
|
if task.kind == EmailType::NewPost.to_string() {
|
||||||
|
issue.inject_tracking_info(transaction).await?;
|
||||||
|
}
|
||||||
|
email_client
|
||||||
|
.send_email(
|
||||||
|
&email,
|
||||||
|
&issue.title,
|
||||||
|
&issue.html_content,
|
||||||
|
&issue.text_content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("Failed to deliver newsletter issue to subscriber..")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_task(
|
async fn delete_task(
|
||||||
mut transaction: Transaction<'static, Postgres>,
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
issue_id: Uuid,
|
issue_id: Uuid,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod authentication;
|
pub mod authentication;
|
||||||
pub mod configuration;
|
pub mod configuration;
|
||||||
|
pub mod database_worker;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod email_client;
|
pub mod email_client;
|
||||||
pub mod idempotency;
|
pub mod idempotency;
|
||||||
|
|||||||
13
src/main.rs
13
src/main.rs
@@ -1,6 +1,6 @@
|
|||||||
use zero2prod::{
|
use zero2prod::{
|
||||||
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped,
|
configuration::get_configuration, database_worker, issue_delivery_worker, startup::Application,
|
||||||
startup::Application, telemetry::init_subscriber,
|
telemetry::init_subscriber,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -11,11 +11,16 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||||||
let application = Application::build(configuration.clone()).await?;
|
let application = Application::build(configuration.clone()).await?;
|
||||||
|
|
||||||
let application_task = tokio::spawn(application.run_until_stopped());
|
let application_task = tokio::spawn(application.run_until_stopped());
|
||||||
let worker_task = tokio::spawn(run_worker_until_stopped(configuration));
|
let database_worker_task = tokio::spawn(database_worker::run_until_stopped(
|
||||||
|
configuration.database.with_db(),
|
||||||
|
));
|
||||||
|
let delivery_worker_task =
|
||||||
|
tokio::spawn(issue_delivery_worker::run_until_stopped(configuration));
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = application_task => {},
|
_ = application_task => {},
|
||||||
_ = worker_task => {},
|
_ = database_worker_task => {},
|
||||||
|
_ = delivery_worker_task => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub use subscriptions::*;
|
|||||||
pub use subscriptions_confirm::*;
|
pub use subscriptions_confirm::*;
|
||||||
pub use unsubscribe::*;
|
pub use unsubscribe::*;
|
||||||
pub use users::*;
|
pub use users::*;
|
||||||
|
use validator::ValidationErrors;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthError,
|
authentication::AuthError,
|
||||||
@@ -161,6 +162,7 @@ impl From<AuthError> for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn not_found() -> Response {
|
pub async fn not_found() -> Response {
|
||||||
|
tracing::error!("Not found.");
|
||||||
not_found_html()
|
not_found_html()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,3 +210,20 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn join_error_messages(e: ValidationErrors) -> String {
|
||||||
|
let error_messages: Vec<_> = e
|
||||||
|
.field_errors()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(field, errors)| {
|
||||||
|
errors.iter().map(move |error| {
|
||||||
|
error
|
||||||
|
.message
|
||||||
|
.as_ref()
|
||||||
|
.map(|msg| msg.to_string())
|
||||||
|
.unwrap_or(format!("Invalid field: {}", field))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
error_messages.join("\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ mod posts;
|
|||||||
mod subscribers;
|
mod subscribers;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::{AuthenticatedUser, Role},
|
authentication::AuthenticatedUser,
|
||||||
routes::{AppError, error_chain_fmt},
|
routes::{AppError, error_chain_fmt},
|
||||||
session_state::TypedSession,
|
session_state::TypedSession,
|
||||||
templates::{HtmlTemplate, MessageTemplate},
|
templates::{HtmlTemplate, MessageTemplate},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use axum::response::Redirect;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
@@ -49,11 +50,17 @@ pub async fn require_auth(
|
|||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let user_id = session
|
let user_id = match session
|
||||||
.get_user_id()
|
.get_user_id()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
||||||
.ok_or(AdminError::NotAuthenticated)?;
|
{
|
||||||
|
None => {
|
||||||
|
tracing::error!("Not authenticated. Redirecting to /login.");
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
}
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
};
|
||||||
let username = session
|
let username = session
|
||||||
.get_username()
|
.get_username()
|
||||||
.await
|
.await
|
||||||
@@ -81,11 +88,10 @@ pub async fn require_admin(
|
|||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if let Role::Admin = session
|
if session
|
||||||
.get_role()
|
.has_admin_permissions()
|
||||||
.await
|
.await
|
||||||
.context("Error retrieving user role in session.")?
|
.context("Error retrieving user role in session.")?
|
||||||
.ok_or(anyhow::anyhow!("Could not find user role in session."))?
|
|
||||||
{
|
{
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
|
||||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
routes::{AdminError, AppError},
|
routes::{AdminError, AppError},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
@@ -8,7 +7,7 @@ use crate::{
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Form,
|
Form,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -95,7 +94,6 @@ pub async fn publish_newsletter(
|
|||||||
base_url,
|
base_url,
|
||||||
..
|
..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
|
||||||
Form(form): Form<BodyData>,
|
Form(form): Form<BodyData>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||||
@@ -105,7 +103,7 @@ pub async fn publish_newsletter(
|
|||||||
.try_into()
|
.try_into()
|
||||||
.map_err(AdminError::Idempotency)?;
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
crate::idempotency::NextAction::StartProcessing(t) => t,
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -129,7 +127,7 @@ pub async fn publish_newsletter(
|
|||||||
let message = String::from("Your email has been queued for delivery.");
|
let message = String::from("Your email has been queued for delivery.");
|
||||||
let template = MessageTemplate::success(message);
|
let template = MessageTemplate::success(message);
|
||||||
let response = Html(template.render().unwrap()).into_response();
|
let response = Html(template.render().unwrap()).into_response();
|
||||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
let response = save_response(transaction, &idempotency_key, response)
|
||||||
.await
|
.await
|
||||||
.map_err(AdminError::UnexpectedError)?;
|
.map_err(AdminError::UnexpectedError)?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub async fn create_post(
|
|||||||
.try_into()
|
.try_into()
|
||||||
.map_err(AdminError::Idempotency)?;
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
crate::idempotency::NextAction::StartProcessing(t) => t,
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -75,7 +75,7 @@ pub async fn create_post(
|
|||||||
|
|
||||||
let template = MessageTemplate::success("Your new post has been published!".into());
|
let template = MessageTemplate::success("Your new post has been published!".into());
|
||||||
let response = Html(template.render().unwrap()).into_response();
|
let response = Html(template.render().unwrap()).into_response();
|
||||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
let response = save_response(transaction, &idempotency_key, response)
|
||||||
.await
|
.await
|
||||||
.map_err(AdminError::UnexpectedError)?;
|
.map_err(AdminError::UnexpectedError)?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::routes::get_max_page;
|
use crate::idempotency::{IdempotencyKey, save_response, try_processing};
|
||||||
|
use crate::routes::{AdminError, get_max_page};
|
||||||
use crate::templates::CommentsPageDashboardTemplate;
|
use crate::templates::CommentsPageDashboardTemplate;
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::CommentEntry,
|
domain::CommentEntry,
|
||||||
@@ -13,7 +14,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -25,6 +26,8 @@ pub struct CommentPathParam {
|
|||||||
pub struct CommentForm {
|
pub struct CommentForm {
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
|
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
|
||||||
@@ -36,14 +39,35 @@ pub async fn post_comment(
|
|||||||
Form(form): Form<CommentForm>,
|
Form(form): Form<CommentForm>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
validate_form(&form)?;
|
validate_form(&form)?;
|
||||||
let comment_id = insert_comment(&connection_pool, post_id, form)
|
|
||||||
|
let idempotency_key: IdempotencyKey = form
|
||||||
|
.idempotency_key
|
||||||
|
.try_into()
|
||||||
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
insert_comment(
|
||||||
|
&mut transaction,
|
||||||
|
post_id,
|
||||||
|
form.author,
|
||||||
|
form.user_id,
|
||||||
|
form.content,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not insert comment into database.")?;
|
.context("Could not insert comment into database.")?;
|
||||||
tracing::info!("new comment with id {} has been inserted", comment_id);
|
|
||||||
let template = HtmlTemplate(MessageTemplate::success(
|
let template = HtmlTemplate(MessageTemplate::success(
|
||||||
"Your comment has been posted.".into(),
|
"Your comment has been posted.".into(),
|
||||||
));
|
));
|
||||||
Ok(template.into_response())
|
let response = template.into_response();
|
||||||
|
let response = save_response(transaction, &idempotency_key, response).await?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
||||||
@@ -55,28 +79,34 @@ fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
|||||||
|
|
||||||
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
|
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
|
||||||
async fn insert_comment(
|
async fn insert_comment(
|
||||||
connection_pool: &PgPool,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
post_id: Uuid,
|
post_id: Uuid,
|
||||||
form: CommentForm,
|
author: Option<String>,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
content: String,
|
||||||
) -> Result<Uuid, sqlx::Error> {
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
let author = form
|
let author = if user_id.is_some() {
|
||||||
.author
|
None
|
||||||
|
} else {
|
||||||
|
author
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(|s| s.trim().to_string());
|
.map(|s| s.trim().to_string())
|
||||||
|
};
|
||||||
|
let content = content.trim();
|
||||||
let comment_id = Uuid::new_v4();
|
let comment_id = Uuid::new_v4();
|
||||||
tracing::Span::current().record("comment_id", comment_id.to_string());
|
tracing::Span::current().record("comment_id", comment_id.to_string());
|
||||||
sqlx::query!(
|
let query = sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO comments (comment_id, post_id, author, content)
|
INSERT INTO comments (user_id, comment_id, post_id, author, content)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
",
|
",
|
||||||
|
user_id,
|
||||||
comment_id,
|
comment_id,
|
||||||
post_id,
|
post_id,
|
||||||
author,
|
author,
|
||||||
form.content.trim()
|
content,
|
||||||
)
|
);
|
||||||
.execute(connection_pool)
|
transaction.execute(query).await?;
|
||||||
.await?;
|
|
||||||
Ok(comment_id)
|
Ok(comment_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,22 +189,35 @@ pub async fn get_comments_page_for_post(
|
|||||||
page: i64,
|
page: i64,
|
||||||
) -> Result<Vec<CommentEntry>, sqlx::Error> {
|
) -> Result<Vec<CommentEntry>, sqlx::Error> {
|
||||||
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
||||||
let comments = sqlx::query_as!(
|
let mut comments = sqlx::query_as!(
|
||||||
CommentEntry,
|
CommentEntry,
|
||||||
"
|
r#"
|
||||||
SELECT comment_id, post_id, author, content, published_at
|
SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
|
||||||
FROM comments
|
FROM comments c
|
||||||
WHERE post_id = $1
|
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
|
||||||
ORDER BY published_at DESC
|
WHERE c.post_id = $1
|
||||||
|
ORDER BY c.published_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
OFFSET $3
|
OFFSET $3
|
||||||
",
|
"#,
|
||||||
post_id,
|
post_id,
|
||||||
COMMENTS_PER_PAGE,
|
COMMENTS_PER_PAGE,
|
||||||
offset
|
offset
|
||||||
)
|
)
|
||||||
.fetch_all(connection_pool)
|
.fetch_all(connection_pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
for comment in comments.iter_mut() {
|
||||||
|
if let Some(user_id) = comment.user_id {
|
||||||
|
let record = sqlx::query!(
|
||||||
|
"SELECT username, full_name FROM users WHERE user_id = $1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(connection_pool)
|
||||||
|
.await?;
|
||||||
|
let author = record.full_name.unwrap_or(record.username);
|
||||||
|
comment.author = Some(author);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(comments)
|
Ok(comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,13 +239,14 @@ pub async fn get_comments_page(
|
|||||||
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
||||||
let comments = sqlx::query_as!(
|
let comments = sqlx::query_as!(
|
||||||
CommentEntry,
|
CommentEntry,
|
||||||
"
|
r#"
|
||||||
SELECT comment_id, post_id, author, content, published_at
|
SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
|
||||||
FROM comments
|
FROM comments c
|
||||||
|
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
|
||||||
ORDER BY published_at DESC
|
ORDER BY published_at DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
",
|
"#,
|
||||||
COMMENTS_PER_PAGE,
|
COMMENTS_PER_PAGE,
|
||||||
offset
|
offset
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use axum::{http::StatusCode, response::Redirect};
|
use axum::{http::StatusCode, response::Redirect};
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct LoginFormData {
|
pub struct LoginFormData {
|
||||||
@@ -29,7 +31,7 @@ pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
|
|||||||
.context("Failed to retrieve user id from data store.")?
|
.context("Failed to retrieve user id from data store.")?
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
Ok(Redirect::to("/admin/dashboard").into_response())
|
Ok(Redirect::to("dashboard").into_response())
|
||||||
} else {
|
} else {
|
||||||
Ok(Html(LoginTemplate.render().unwrap()).into_response())
|
Ok(Html(LoginTemplate.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,9 @@ pub async fn post_login(
|
|||||||
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
||||||
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
|
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
|
||||||
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
||||||
|
record_login(&connection_pool, &user_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to register new login event.")?;
|
||||||
|
|
||||||
session.renew().await.context("Failed to renew session.")?;
|
session.renew().await.context("Failed to renew session.")?;
|
||||||
session
|
session
|
||||||
@@ -66,6 +71,14 @@ pub async fn post_login(
|
|||||||
.context("Failed to insert role in session data store.")?;
|
.context("Failed to insert role in session data store.")?;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
|
||||||
Ok((StatusCode::OK, headers).into_response())
|
Ok((StatusCode::OK, headers).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Recording new login event", skip_all, fields(user_id = %user_id))]
|
||||||
|
async fn record_login(connection_pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("INSERT INTO user_logins (user_id) VALUES ($1)", user_id)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::routes::{COMMENTS_PER_PAGE, get_max_page};
|
use crate::authentication::AuthenticatedUser;
|
||||||
use crate::templates::PostsPageDashboardTemplate;
|
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages};
|
||||||
|
use crate::session_state::TypedSession;
|
||||||
|
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::PostEntry,
|
domain::PostEntry,
|
||||||
routes::{
|
routes::{
|
||||||
AppError, Path, Query, get_comments_count_for_post, get_comments_page_for_post,
|
AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html,
|
||||||
not_found_html,
|
|
||||||
},
|
},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
||||||
@@ -12,11 +13,14 @@ use crate::{
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Extension, Form,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
pub const POSTS_PER_PAGE: i64 = 3;
|
pub const POSTS_PER_PAGE: i64 = 3;
|
||||||
|
|
||||||
@@ -74,7 +78,8 @@ async fn get_posts(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
|
||||||
|
p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
ORDER BY p.published_at DESC
|
ORDER BY p.published_at DESC
|
||||||
@@ -96,7 +101,8 @@ pub async fn get_posts_page(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
|
||||||
|
p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
ORDER BY p.published_at DESC
|
ORDER BY p.published_at DESC
|
||||||
@@ -117,18 +123,76 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
|
|||||||
.map(|r| r.count.unwrap())
|
.map(|r| r.count.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Validate, serde::Deserialize)]
|
||||||
|
pub struct EditPostForm {
|
||||||
|
#[validate(length(min = 1, message = "Title must be at least one character."))]
|
||||||
|
pub title: String,
|
||||||
|
#[validate(length(min = 1, message = "Content must be at least one character."))]
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Editing post", skip_all, fields(post_id = %post_id))]
|
||||||
|
pub async fn update_post(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
|
Path(post_id): Path<Uuid>,
|
||||||
|
Form(form): Form<EditPostForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let record = sqlx::query!("SELECT author_id FROM posts WHERE post_id = $1", post_id)
|
||||||
|
.fetch_optional(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch post author.")?;
|
||||||
|
match record {
|
||||||
|
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
|
||||||
|
Some(record) if record.author_id == user_id => {
|
||||||
|
if let Err(e) = form.validate().map_err(join_error_messages) {
|
||||||
|
let template = HtmlTemplate(MessageTemplate::error(e));
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE posts
|
||||||
|
SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4
|
||||||
|
",
|
||||||
|
form.title,
|
||||||
|
form.content,
|
||||||
|
Utc::now(),
|
||||||
|
post_id
|
||||||
|
)
|
||||||
|
.execute(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not update post")?;
|
||||||
|
Ok(HtmlTemplate(MessageTemplate::success(
|
||||||
|
"Your changes have been saved.".into(),
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
_ => Ok(HtmlTemplate(MessageTemplate::error(
|
||||||
|
"You are not authorized. Only the author can edit his post.".into(),
|
||||||
|
))
|
||||||
|
.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct PostParams {
|
pub struct OriginQueryParam {
|
||||||
origin: Option<Uuid>,
|
origin: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Fetching post from database", skip(connection_pool, origin))]
|
#[tracing::instrument(
|
||||||
|
name = "Fetching post from database",
|
||||||
|
skip(connection_pool, origin, session)
|
||||||
|
)]
|
||||||
|
|
||||||
pub async fn see_post(
|
pub async fn see_post(
|
||||||
|
session: TypedSession,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Path(post_id): Path<Uuid>,
|
Path(post_id): Path<Uuid>,
|
||||||
Query(PostParams { origin }): Query<PostParams>,
|
Query(OriginQueryParam { origin }): Query<OriginQueryParam>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if let Some(origin) = origin {
|
if let Some(origin) = origin {
|
||||||
mark_email_as_opened(&connection_pool, origin).await?;
|
mark_email_as_opened(&connection_pool, origin).await?;
|
||||||
@@ -137,26 +201,39 @@ pub async fn see_post(
|
|||||||
|
|
||||||
if let Some(post) = get_post_data(&connection_pool, post_id)
|
if let Some(post) = get_post_data(&connection_pool, post_id)
|
||||||
.await
|
.await
|
||||||
.context(format!("Failed to fetch post #{}", post_id))
|
.context(format!("Failed to fetch post #{}.", post_id))
|
||||||
.map_err(AppError::unexpected_page)?
|
.map_err(AppError::unexpected_page)?
|
||||||
{
|
{
|
||||||
let post = post
|
let post_html = post
|
||||||
.to_html()
|
.to_html()
|
||||||
.context("Could not render markdown with extension.")?;
|
.context("Could not render markdown with extension.")?;
|
||||||
let current_page = 1;
|
let current_page = 1;
|
||||||
let comments_count = get_comments_count_for_post(&connection_pool, post_id)
|
let comments_count = get_comments_count_for_post(&connection_pool, post_id)
|
||||||
.await
|
.await
|
||||||
.context("Could not fetch comment count")?;
|
.context("Could not fetch comment count.")?;
|
||||||
let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE);
|
let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE);
|
||||||
let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
|
let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
|
||||||
.await
|
.await
|
||||||
.context("Failed to fetch latest comments")?;
|
.context("Failed to fetch latest comments.")?;
|
||||||
|
let idempotency_key = Uuid::new_v4().to_string();
|
||||||
|
let session_user_id = session
|
||||||
|
.get_user_id()
|
||||||
|
.await
|
||||||
|
.context("Could not check for session user id.")?;
|
||||||
|
let session_username = session
|
||||||
|
.get_username()
|
||||||
|
.await
|
||||||
|
.context("Could not check for session username.")?;
|
||||||
let template = HtmlTemplate(PostTemplate {
|
let template = HtmlTemplate(PostTemplate {
|
||||||
post,
|
post,
|
||||||
|
post_html,
|
||||||
comments,
|
comments,
|
||||||
|
idempotency_key,
|
||||||
current_page,
|
current_page,
|
||||||
max_page,
|
max_page,
|
||||||
comments_count,
|
comments_count,
|
||||||
|
session_user_id,
|
||||||
|
session_username,
|
||||||
});
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
} else {
|
} else {
|
||||||
@@ -184,7 +261,8 @@ async fn get_post_data(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
|
||||||
|
p.title, p.content, p.published_at, last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
WHERE p.post_id = $1
|
WHERE p.post_id = $1
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::routes::verify_password;
|
use crate::authentication::AuthenticatedUser;
|
||||||
use crate::templates::MessageTemplate;
|
use crate::routes::{join_error_messages, verify_password};
|
||||||
|
use crate::session_state::TypedSession;
|
||||||
|
use crate::templates::{MessageTemplate, UserEditTemplate};
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::Role,
|
authentication::Role,
|
||||||
domain::{PostEntry, UserEntry},
|
domain::{PostEntry, UserEntry},
|
||||||
@@ -9,17 +11,114 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Extension, Form,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
pub async fn user_edit_form(
|
||||||
pub struct ProfilePath {
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
UserEntry,
|
||||||
|
r#"
|
||||||
|
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
|
||||||
|
FROM users
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch user in database.")?;
|
||||||
|
let template = HtmlTemplate(UserEditTemplate { user });
|
||||||
|
Ok(template.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Validate, serde::Deserialize)]
|
||||||
|
pub struct EditProfileForm {
|
||||||
|
user_id: Uuid,
|
||||||
|
#[validate(length(min = 3, message = "Username must be at least 3 characters."))]
|
||||||
username: String,
|
username: String,
|
||||||
|
full_name: String,
|
||||||
|
bio: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Updating user profile", skip_all, fields(user_id = %form.user_id))]
|
||||||
|
pub async fn update_user(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
session: TypedSession,
|
||||||
|
Extension(AuthenticatedUser {
|
||||||
|
user_id: session_user_id,
|
||||||
|
username: session_username,
|
||||||
|
..
|
||||||
|
}): Extension<AuthenticatedUser>,
|
||||||
|
Form(form): Form<EditProfileForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
if let Err(e) = form.validate().map_err(join_error_messages) {
|
||||||
|
let template = HtmlTemplate(MessageTemplate::error(e));
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
if form.user_id != session_user_id {
|
||||||
|
let template = HtmlTemplate(MessageTemplate::error(
|
||||||
|
"You are not authorized. Refresh the page and try again.".into(),
|
||||||
|
));
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
let updated_username = form.username.trim();
|
||||||
|
if updated_username != session_username
|
||||||
|
&& sqlx::query!(
|
||||||
|
"SELECT user_id FROM users WHERE username = $1",
|
||||||
|
updated_username
|
||||||
|
)
|
||||||
|
.fetch_optional(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch users table.")?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let template = HtmlTemplate(MessageTemplate::error(
|
||||||
|
"This username is already taken.".into(),
|
||||||
|
));
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
let updated_full_name = form.full_name.trim();
|
||||||
|
let bio = {
|
||||||
|
let bio = form.bio.trim();
|
||||||
|
if bio.is_empty() { None } else { Some(bio) }
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE users
|
||||||
|
SET username = $1, full_name = $2, bio = $3
|
||||||
|
WHERE user_id = $4
|
||||||
|
",
|
||||||
|
updated_username,
|
||||||
|
updated_full_name,
|
||||||
|
bio,
|
||||||
|
form.user_id
|
||||||
|
)
|
||||||
|
.execute(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to apply changes.")
|
||||||
|
.map_err(AppError::FormError)?;
|
||||||
|
session
|
||||||
|
.insert_username(updated_username.to_owned())
|
||||||
|
.await
|
||||||
|
.context("Could not update session username.")?;
|
||||||
|
let template = HtmlTemplate(MessageTemplate::success(
|
||||||
|
"Your profile has been updated.".into(),
|
||||||
|
));
|
||||||
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
|
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
|
||||||
@@ -62,7 +161,10 @@ impl TryFrom<CreateUserForm> for NewUser {
|
|||||||
anyhow::bail!("Password mismatch.");
|
anyhow::bail!("Password mismatch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let role = value.admin.map(|_| Role::Admin).unwrap_or(Role::Writer);
|
let role = match value.admin {
|
||||||
|
Some(true) => Role::Admin,
|
||||||
|
_ => Role::Writer,
|
||||||
|
};
|
||||||
let password_hash = crate::authentication::compute_pasword_hash(value.password)
|
let password_hash = crate::authentication::compute_pasword_hash(value.password)
|
||||||
.context("Failed to hash password.")?;
|
.context("Failed to hash password.")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -141,12 +243,13 @@ pub async fn delete_user(
|
|||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
|
#[tracing::instrument(name = "Fetching user data", skip(connection_pool, session))]
|
||||||
pub async fn user_profile(
|
pub async fn user_profile(
|
||||||
|
session: TypedSession,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Path(ProfilePath { username }): Path<ProfilePath>,
|
Path(username): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
match fetch_user_data(&connection_pool, &username)
|
match fetch_user_data(&connection_pool, &username)
|
||||||
.await
|
.await
|
||||||
@@ -156,7 +259,34 @@ pub async fn user_profile(
|
|||||||
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||||
.await
|
.await
|
||||||
.context("Could not fetch user posts.")?;
|
.context("Could not fetch user posts.")?;
|
||||||
let template = HtmlTemplate(UserTemplate { user, posts });
|
let session_user_id = session
|
||||||
|
.get_user_id()
|
||||||
|
.await
|
||||||
|
.context("Could not fetch session username.")?;
|
||||||
|
let profile_user_id =
|
||||||
|
sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_one(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch profile user id.")?
|
||||||
|
.user_id;
|
||||||
|
let last_seen = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT login_time FROM user_logins
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY login_time DESC
|
||||||
|
",
|
||||||
|
profile_user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch last user login")?
|
||||||
|
.map(|r| r.login_time);
|
||||||
|
let template = HtmlTemplate(UserTemplate {
|
||||||
|
user,
|
||||||
|
session_user_id,
|
||||||
|
last_seen,
|
||||||
|
posts,
|
||||||
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -192,7 +322,8 @@ async fn fetch_user_posts(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at
|
SELECT p.author_id, u.username as author, u.full_name,
|
||||||
|
p.post_id, p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
INNER JOIN users u ON p.author_id = u.user_id
|
INNER JOIN users u ON p.author_id = u.user_id
|
||||||
WHERE p.author_id = $1
|
WHERE p.author_id = $1
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ impl TypedSession {
|
|||||||
self.0.get(Self::ROLE_KEY).await
|
self.0.get(Self::ROLE_KEY).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn has_admin_permissions(&self) -> Result<bool> {
|
||||||
|
let role = self.0.get(Self::ROLE_KEY).await?;
|
||||||
|
if let Some(Role::Admin) = role {
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn clear(&self) {
|
pub async fn clear(&self) {
|
||||||
self.0.clear().await;
|
self.0.clear().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use axum::{
|
|||||||
http::Request,
|
http::Request,
|
||||||
middleware,
|
middleware,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use reqwest::{StatusCode, header};
|
use reqwest::{StatusCode, header};
|
||||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||||
@@ -99,8 +99,10 @@ pub fn app(
|
|||||||
.route("/password", post(change_password))
|
.route("/password", post(change_password))
|
||||||
.route("/newsletters", post(publish_newsletter))
|
.route("/newsletters", post(publish_newsletter))
|
||||||
.route("/posts", post(create_post))
|
.route("/posts", post(create_post))
|
||||||
|
.route("/posts/{post_id}", put(update_post))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.merge(admin_routes)
|
.route("/users/edit", get(user_edit_form).put(update_user))
|
||||||
|
.nest("/admin", admin_routes)
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
@@ -119,7 +121,7 @@ pub fn app(
|
|||||||
)
|
)
|
||||||
.route("/users/{username}", get(user_profile))
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.nest("/admin", auth_routes)
|
.merge(auth_routes)
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ where
|
|||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.compact()
|
.pretty()
|
||||||
.with_writer(sink)
|
.with_writer(sink)
|
||||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct HtmlTemplate<T>(pub T);
|
pub struct HtmlTemplate<T>(pub T);
|
||||||
@@ -25,7 +26,15 @@ where
|
|||||||
#[template(path = "user/profile.html")]
|
#[template(path = "user/profile.html")]
|
||||||
pub struct UserTemplate {
|
pub struct UserTemplate {
|
||||||
pub user: UserEntry,
|
pub user: UserEntry,
|
||||||
|
pub session_user_id: Option<Uuid>,
|
||||||
pub posts: Vec<PostEntry>,
|
pub posts: Vec<PostEntry>,
|
||||||
|
pub last_seen: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "user/edit.html")]
|
||||||
|
pub struct UserEditTemplate {
|
||||||
|
pub user: UserEntry,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -114,10 +123,14 @@ pub struct PostListTemplate {
|
|||||||
#[template(path = "posts/page.html")]
|
#[template(path = "posts/page.html")]
|
||||||
pub struct PostTemplate {
|
pub struct PostTemplate {
|
||||||
pub post: PostEntry,
|
pub post: PostEntry,
|
||||||
|
pub post_html: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
pub comments: Vec<CommentEntry>,
|
pub comments: Vec<CommentEntry>,
|
||||||
pub current_page: i64,
|
pub current_page: i64,
|
||||||
pub max_page: i64,
|
pub max_page: i64,
|
||||||
pub comments_count: i64,
|
pub comments_count: i64,
|
||||||
|
pub session_user_id: Option<Uuid>,
|
||||||
|
pub session_username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -146,6 +159,8 @@ pub enum ErrorTemplate {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[template(path = "error/500.html")]
|
#[template(path = "error/500.html")]
|
||||||
InternalServer,
|
InternalServer,
|
||||||
|
#[template(path = "error/403.html")]
|
||||||
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|||||||
@@ -13,6 +13,21 @@
|
|||||||
<script src="/assets/js/htmx.min.js"></script>
|
<script src="/assets/js/htmx.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
|
<div class="bg-yellow-50 border-b-2 border-yellow-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
|
<div class="flex items-center justify-center text-center">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600 mr-2 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
This is a personal project, <span class="font-semibold">not</span> a real website. Database is cleared periodically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center h-16">
|
<div class="flex justify-between items-center h-16">
|
||||||
@@ -39,7 +54,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/admin/dashboard"
|
<a href="/dashboard"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -59,7 +74,7 @@
|
|||||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||||
Posts
|
Posts
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/dashboard"
|
<a href="/dashboard"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||||
Dashboard
|
Dashboard
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
|
|
||||||
<div class="p-6 border-b border-gray-200">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-green-600 mr-2"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
Change your password
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<form hx-post="/admin/password"
|
|
||||||
hx-target="#password-messages"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="current_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="current_password"
|
|
||||||
name="current_password"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="new_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="new_password"
|
|
||||||
name="new_password"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="new_password_check"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="new_password_check"
|
|
||||||
name="new_password_check"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<button type="submit"
|
|
||||||
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
|
||||||
<svg class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Update password
|
|
||||||
</button>
|
|
||||||
<div id="password-messages" class="mt-4"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -2,33 +2,39 @@
|
|||||||
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
|
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
<div class="mb-1">
|
||||||
<span class="text-sm font-medium text-gray-900">
|
{% if let Some(user_id) = comment.user_id %}
|
||||||
|
<a href="/users/{{ comment.username.as_ref().unwrap() }}"
|
||||||
|
class="font-semibold text-blue-800 hover:text-blue-600 hover:underline">
|
||||||
|
{{ comment.username.as_ref().unwrap() }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-medium text-gray-600">
|
||||||
{% if let Some(name) = comment.author %}
|
{% if let Some(name) = comment.author %}
|
||||||
{{ name }}
|
{{ name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Anonymous
|
Anonymous
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-400">•</span>
|
{% endif %}
|
||||||
<span class="text-xs text-gray-500">on</span>
|
|
||||||
<a href="/posts/{{ comment.post_id }}" class="text-sm text-blue-600 hover:underline truncate">
|
|
||||||
#{{ comment.post_id.to_string()[..8] }}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
|
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-xs text-gray-500 mb-1">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<time datetime="{{ comment.published_at }}">
|
<time datetime="{{ comment.published_at }}">
|
||||||
{{ comment.formatted_date() }}
|
{{ comment.formatted_date() }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center text-xs text-gray-500">
|
||||||
|
<span class="mr-1">on</span>
|
||||||
|
<a href="/posts/{{ comment.post_id }}"
|
||||||
|
class="text-blue-600 hover:underline truncate">#{{ comment.post_id }}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
|
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
|
||||||
hx-target="#comment-{{ comment.comment_id }}"
|
hx-target="#comment-{{ comment.comment_id }}"
|
||||||
@@ -39,8 +45,7 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Comments management ({{ comments_count }})</span>
|
<span>Comments ({{ comments_count }})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and moderate all comments.</p>
|
<p class="text-sm text-gray-600 mt-1">View and moderate all comments.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list" class="space-y-6">
|
<div id="comments-list">
|
||||||
{% block comments %}
|
{% block comments %}
|
||||||
{% if comments.is_empty() %}
|
{% if comments.is_empty() %}
|
||||||
<div class="p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data to display</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No comments to display</h3>
|
||||||
<p class="text-gray-600">The request did not return any data.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for comment in comments %}
|
{% for comment in comments %}
|
||||||
{% include "dashboard/comments/card.html" %}
|
{% include "dashboard/comments/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button hx-get="/admin/logout"
|
<button hx-get="/logout"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -24,23 +24,36 @@
|
|||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if user.is_admin() %}
|
|
||||||
<div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 bg-blue-50 rounded-lg border border-blue-200">
|
|
||||||
<h2 class="text-lg font-semibold text-blue-900 mb-6">Administration</h2>
|
|
||||||
{% include "stats.html" %}
|
{% include "stats.html" %}
|
||||||
{% include "subscribers/list.html" %}
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
{% include "publish.html" %}
|
||||||
|
{% include "send_email.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.is_admin() %}
|
||||||
|
<div class="relative my-12">
|
||||||
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t-2 border-blue-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center">
|
||||||
|
<span class="bg-gray-50 px-4 text-sm font-semibold text-blue-900 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
Administration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{% include "subscribers/list.html" %}
|
||||||
{% include "users/list.html" %}
|
{% include "users/list.html" %}
|
||||||
{% include "users/form.html" %}
|
{% include "users/form.html" %}
|
||||||
</div>
|
|
||||||
{% include "posts/list.html" %}
|
{% include "posts/list.html" %}
|
||||||
{% include "comments/list.html" %}
|
{% include "comments/list.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{% include "publish.html" %}
|
|
||||||
{% include "send_email.html" %}
|
|
||||||
{% include "change_password.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
</h3>
|
</h3>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
{{ post.author }}
|
{{ post.author }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button hx-delete="/admin/posts/{{ post.post_id }}"
|
<button hx-delete="/admin/posts/{{ post.post_id }}"
|
||||||
hx-target="#post-{{ post.post_id }}"
|
hx-target="#post-{{ post.post_id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Posts management ({{ posts_count }})</span>
|
<span>Posts ({{ posts_count }})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage all published posts.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage all published posts.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="posts-list" class="space-y-6">
|
<div id="posts-list">
|
||||||
{% block posts %}
|
{% block posts %}
|
||||||
{% if posts.is_empty() %}
|
{% if posts.is_empty() %}
|
||||||
<div class="p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data to display</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts to display</h3>
|
||||||
<p class="text-gray-600">The request did not return any data.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for post in posts %}
|
{% for post in posts %}
|
||||||
{% include "dashboard/posts/card.html" %}
|
{% include "dashboard/posts/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Write a new post
|
Write a new post
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form hx-post="/admin/posts"
|
<form hx-post="/posts"
|
||||||
hx-target="#post-messages"
|
hx-target="#post-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Send an email
|
Send an email
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form hx-post="/admin/newsletters"
|
<form hx-post="/newsletters"
|
||||||
hx-target="#newsletter-messages"
|
hx-target="#newsletter-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
@@ -50,7 +51,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||||
</svg>
|
</svg>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
class="block py-4 hover:bg-gray-50 px-6 transition-colors group {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
|
class="block py-4 hover:bg-gray-50 px-6 transition-colors group {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
|
<h3 class="text-sm truncate font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
|
||||||
{{ subscriber.email }}</h3>
|
{{ subscriber.email }}</h3>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -13,16 +13,16 @@
|
|||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
</svg>
|
</svg>
|
||||||
Subscribers management ({{ count }})
|
Subscribers ({{ count }})
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="subscribers-list" class="space-y-6">
|
<div id="subscribers-list">
|
||||||
{% block subs %}
|
{% block subs %}
|
||||||
{% if subscribers.is_empty() %}
|
{% if subscribers.is_empty() %}
|
||||||
<div class="g p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-gray-500"
|
<svg class="w-8 h-8 text-gray-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data available</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No subscribers to display</h3>
|
||||||
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for subscriber in subscribers %}
|
{% for subscriber in subscribers %}
|
||||||
{% include "dashboard/subscribers/card.html" %}
|
{% include "dashboard/subscribers/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Users management
|
Users
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage users.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage users.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No users found</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No users to display</h3>
|
||||||
<p class="text-gray-600">No users in the system.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200">
|
||||||
|
|||||||
39
templates/error/403.html
Normal file
39
templates/error/403.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}403{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold text-gray-700 mb-4">403</h1>
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Forbidden</h2>
|
||||||
|
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||||
|
You don't have permission to access this page.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<a href="/"
|
||||||
|
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard"
|
||||||
|
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@@ -34,11 +35,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h1 {
|
.prose-compact h1 {
|
||||||
@apply pb-2 mb-3 border-b-2 border-gray-100;
|
@apply pb-2 mb-3 border-b-2 border-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h2 {
|
.prose-compact h2 {
|
||||||
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold;
|
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-200 font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h3 {
|
.prose-compact h3 {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<a href="/posts/{{ post.post_id }}">
|
<a href="/posts/{{ post.post_id }}">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">
|
||||||
|
{{
|
||||||
|
post.title }}
|
||||||
|
</h2>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1"
|
<svg class="w-4 h-4 mr-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -17,8 +19,7 @@
|
|||||||
{{ post.formatted_date() }}
|
{{ post.formatted_date() }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<span class="mx-2">•</span>
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1"
|
<svg class="w-4 h-4 mr-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -26,8 +27,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/users/{{ post.author }}"
|
<a href="/users/{{ post.author }}"
|
||||||
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
|
class="hover:text-blue-600 hover:underline">
|
||||||
</div>
|
{% if let Some(full_name) = post.full_name %}
|
||||||
|
{{ full_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ post.author }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||||||
<div class="flex items-start space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-gray-500"
|
<svg class="w-5 h-5 text-gray-500"
|
||||||
@@ -11,20 +11,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center space-x-2 mb-1">
|
{% if let Some(user_id) = comment.user_id %}
|
||||||
<span class="font-medium text-gray-900">
|
<a href="/users/{{ comment.username.as_ref().unwrap() }}"
|
||||||
|
class="font-semibold text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
|
{% if let Some(name) = comment.author %}
|
||||||
|
{{ name }}
|
||||||
|
{% else %}
|
||||||
|
{{ comment.username.as_ref().unwrap() }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-medium text-gray-600">
|
||||||
{% if let Some(name) = comment.author %}
|
{% if let Some(name) = comment.author %}
|
||||||
{{ name }}
|
{{ name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Anonymous
|
Anonymous
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-400">•</span>
|
{% endif %}
|
||||||
<time class="text-sm text-gray-500" datetime="{{ comment.published_at }}">
|
<time class="block text-sm text-gray-500 mt-0.5"
|
||||||
|
datetime="{{ comment.published_at }}">
|
||||||
{{ comment.formatted_date() }}
|
{{ comment.formatted_date() }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-700 whitespace-pre-line">{{ comment.content }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-gray-700 whitespace-pre-line mt-2">{{ comment.content }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,27 @@
|
|||||||
hx-target="#form-messages"
|
hx-target="#form-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
|
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
|
||||||
|
{% if session_user_id.is_some() %}
|
||||||
|
<input type="hidden"
|
||||||
|
name="user_id"
|
||||||
|
value="{{ session_user_id.as_ref().unwrap() }}" />
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
|
{% if session_username.is_none() %}
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="author"
|
name="author"
|
||||||
placeholder="Your name (optional)"
|
placeholder="Your name (optional)"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
{% else %}
|
||||||
|
<input type="text"
|
||||||
|
name="author"
|
||||||
|
value="{{ session_username.as_ref().unwrap() }}"
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
class="w-full px-4 py-2 border border-gray-200 rounded-md bg-gray-50 text-gray-600 cursor-not-allowed">
|
||||||
|
<p class="text-sm text-gray-500 mt-1">You are authenticated.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<textarea name="content"
|
<textarea name="content"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<article>
|
<article>
|
||||||
<header class="pb-4 mb-2 border-b-2 border-gray-300 border-dashed">
|
<header class="mb-4 space-y-4">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
<h1 class="text-4xl font-bold text-gray-900 leading-tight">{{ post.title }}</h1>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -17,7 +17,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<a href="/users/{{ post.author }}"
|
<a href="/users/{{ post.author }}"
|
||||||
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
|
class="hover:text-blue-600 hover:underline font-medium">
|
||||||
|
{% if let Some(full_name) = post.full_name %}
|
||||||
|
{{ full_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ post.author }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||||
@@ -31,11 +37,73 @@
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if session_user_id.as_ref() == Some(post.author_id) %}
|
||||||
|
<div class="mt-4 sm:mt-0">
|
||||||
|
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if let Some(modified) = post.last_modified %}
|
||||||
|
<span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }} at {{ modified.format("%H:%M") }}</span>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
<div class="prose-compact">{{ post.content | safe }}</div>
|
{% if session_user_id.as_ref() == Some(post.author_id) %}
|
||||||
|
<div id="edit-form"
|
||||||
|
class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
|
||||||
|
<form hx-put="/posts/{{ post.post_id }}"
|
||||||
|
hx-target="#edit-messages"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||||
|
<input type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ post.title }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
|
||||||
|
<textarea id="content"
|
||||||
|
name="content"
|
||||||
|
rows="12"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="document.getElementById('edit-form').classList.add('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="edit-messages" class="mt-6"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="mt-12">{% include "posts/comments/list.html" %}</div>
|
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
||||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 pb-4 pt-8 px-8">Activity</h2>
|
<h2 class="text-xl font-semibold text-gray-900 px-8 py-6 border-b border-gray-200">Activity</h2>
|
||||||
{% if posts.is_empty() %}
|
{% if posts.is_empty() %}
|
||||||
<div class="text-center text-gray-500 p-8">
|
<div class="text-center text-gray-500 p-8">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<p>No posts yet</p>
|
<p>No posts yet</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200 pb-8">
|
<div class="divide-y divide-gray-200">
|
||||||
{% for post in posts %}
|
{% for post in posts %}
|
||||||
<a href="/posts/{{ post.post_id }}"
|
<a href="/posts/{{ post.post_id }}"
|
||||||
class="block py-4 hover:bg-gray-50 px-8 transition-colors group">
|
class="block hover:bg-gray-50 px-8 py-5 transition-colors group">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{
|
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">
|
||||||
post.title }}</h3>
|
{{
|
||||||
|
post.title }}
|
||||||
|
</h3>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<time datetime="{{ post.published_at }}">
|
<time datetime="{{ post.published_at }}">
|
||||||
{{ post.formatted_date() }}
|
{{ post.formatted_date() }}
|
||||||
|
|||||||
14
templates/user/edit.html
Normal file
14
templates/user/edit.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit profile{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto p-4 sm:p-6">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Edit your profile</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Manage your profile and account settings.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{% include "edit/update_profile.html" %}
|
||||||
|
{% include "edit/change_password.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
46
templates/user/edit/change_password.html
Normal file
46
templates/user/edit/change_password.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Password</h2>
|
||||||
|
<form hx-post="/password"
|
||||||
|
hx-target="#password-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="current_password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password_check"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password_check"
|
||||||
|
name="new_password_check"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
<div id="password-messages" class="mt-4"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
42
templates/user/edit/update_profile.html
Normal file
42
templates/user/edit/update_profile.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile settings</h2>
|
||||||
|
<form hx-put="/users/edit"
|
||||||
|
hx-target="#edit-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-6">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||||
|
<input type="text"
|
||||||
|
name="username"
|
||||||
|
id="username"
|
||||||
|
value="{{ user.username }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
||||||
|
<input type="text"
|
||||||
|
id="full_name"
|
||||||
|
name="full_name"
|
||||||
|
value="{{ user.full_name.as_deref().unwrap_or("") }}"
|
||||||
|
placeholder="John Doe"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Your real name (optional)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
|
||||||
|
<textarea id="bio"
|
||||||
|
name="bio"
|
||||||
|
rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
<div id="edit-messages"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -2,25 +2,39 @@
|
|||||||
{% block title %}{{ user.username }}{% endblock %}
|
{% block title %}{{ user.username }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6">
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 space-y-6">
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold shadow-md">
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-center sm:text-left">
|
<div class="flex-1 text-center sm:text-left space-y-2">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username) }}</h1>
|
<h1 class="text-3xl font-semibold text-gray-900 tracking-tight">
|
||||||
|
{{ user.full_name.as_deref().unwrap_or(user.username) }}
|
||||||
|
</h1>
|
||||||
{% if user.is_admin() %}
|
{% if user.is_admin() %}
|
||||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24">
|
viewBox="0 0 24 24">
|
||||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
|
||||||
</svg>
|
</svg>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p>
|
{% if session_user_id.as_ref() == Some(user.user_id) %}
|
||||||
|
<a href="/users/edit"
|
||||||
|
class="inline-flex items-center text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-gray-500 text-lg">@{{ user.username }}</p>
|
||||||
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -30,14 +44,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ user.formatted_date() }}
|
{{ user.formatted_date() }}
|
||||||
</div>
|
</div>
|
||||||
|
{% if let Some(last_seen) = last_seen %}
|
||||||
|
<p class="text-sm italic text-gray-400">
|
||||||
|
Last seen on {{ last_seen.format("%h %d, %Y") }} at {{ last_seen.format("%H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if user.bio.is_some() %}
|
{% if user.bio.is_some() %}
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
<div class="border-t border-gray-100 pt-6">
|
||||||
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
<p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "activity.html" %}
|
<!-- Activity -->
|
||||||
|
<div class="mt-8">{% include "activity.html" %}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async fn logout_clears_session_state(connection_pool: PgPool) {
|
|||||||
"password": &app.test_user.password,
|
"password": &app.test_user.password,
|
||||||
});
|
});
|
||||||
let response = app.post_login(&login_body).await;
|
let response = app.post_login(&login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
|
|
||||||
let html_page = app.get_admin_dashboard_html().await;
|
let html_page = app.get_admin_dashboard_html().await;
|
||||||
assert!(html_page.contains("Connected as"));
|
assert!(html_page.contains("Connected as"));
|
||||||
@@ -40,7 +40,7 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
|||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
let response = app.get_admin_dashboard_html().await;
|
let response = app.get_admin_dashboard_html().await;
|
||||||
assert!(response.contains("No data available"));
|
assert!(response.contains("No subscribers to display"));
|
||||||
|
|
||||||
app.create_confirmed_subscriber().await;
|
app.create_confirmed_subscriber().await;
|
||||||
let subscriber = sqlx::query!("SELECT id, email FROM subscriptions")
|
let subscriber = sqlx::query!("SELECT id, email FROM subscriptions")
|
||||||
@@ -53,10 +53,90 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
|||||||
|
|
||||||
app.delete_subscriber(subscriber.id).await;
|
app.delete_subscriber(subscriber.id).await;
|
||||||
let response = app.get_admin_dashboard_html().await;
|
let response = app.get_admin_dashboard_html().await;
|
||||||
assert!(response.contains("No data available"));
|
assert!(response.contains("No subscribers to display"));
|
||||||
assert!(!response.contains(&subscriber.email));
|
assert!(!response.contains(&subscriber.email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn posts_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No posts to display"));
|
||||||
|
|
||||||
|
let response = app.post_create_post(&fake_post_body()).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your new post has been published")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (post_id, post_title) = {
|
||||||
|
let record = sqlx::query!("SELECT post_id, title FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.post_id, record.title)
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(&post_title));
|
||||||
|
|
||||||
|
app.delete_post(post_id).await;
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No posts to display"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comments_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No comments to display"));
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
|
||||||
|
let (post_id, post_title) = {
|
||||||
|
let record = sqlx::query!("SELECT post_id, title FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.post_id, record.title)
|
||||||
|
};
|
||||||
|
|
||||||
|
let author = "author";
|
||||||
|
let content = "comment";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": author,
|
||||||
|
"content": content,
|
||||||
|
"idempotency_key": "key"
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains(author));
|
||||||
|
assert!(response.contains(content));
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(&post_title));
|
||||||
|
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
|
||||||
|
app.delete_comment(comment_id).await;
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No comments to display"));
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn dashboard_shows_correct_stats(connection_pool: PgPool) {
|
async fn dashboard_shows_correct_stats(connection_pool: PgPool) {
|
||||||
let app = TestApp::spawn(connection_pool).await;
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
|||||||
@@ -85,5 +85,5 @@ async fn changing_password_works(connection_pool: PgPool) {
|
|||||||
"password": new_password,
|
"password": new_password,
|
||||||
});
|
});
|
||||||
let response = app.post_login(login_body).await;
|
let response = app.post_login(login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
163
tests/api/comments.rs
Normal file
163
tests/api/comments.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::helpers::{TestApp, fake_post_body};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_author = "Author";
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": comment_author,
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let post = app.get_post_html(&post_id).await;
|
||||||
|
assert!(post.contains(comment_author));
|
||||||
|
assert!(post.contains(comment_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let post = app.get_post_html(&post_id).await;
|
||||||
|
assert!(post.contains("Anonymous"));
|
||||||
|
assert!(post.contains(comment_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_with_invalid_body_is_rejected(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let test_cases = [
|
||||||
|
(
|
||||||
|
serde_json::json!({ "idempotency_key": "key" }),
|
||||||
|
"a missing content",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
serde_json::json!({ "idempotency_key": "key", "content": "" }),
|
||||||
|
"an empty content",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (invalid_body, message) in test_cases {
|
||||||
|
let response = app.post_comment(&post_id, &invalid_body).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
dbg!(&html);
|
||||||
|
assert!(
|
||||||
|
!html.contains("Your comment has been posted"),
|
||||||
|
"The API did not reject the request when the body had {}",
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_is_deleted_when_post_is_deleted(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_author = "Author";
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": comment_author,
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
app.delete_post(post_id).await;
|
||||||
|
let record = sqlx::query!("SELECT * FROM comments WHERE comment_id = $1", comment_id)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_posting_is_idempotent(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "content",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
let response = app.post_comment(&post_id, &comment_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your comment has been posted")
|
||||||
|
);
|
||||||
|
let response = app.post_comment(&post_id, &comment_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your comment has been posted")
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = sqlx::query_scalar!("SELECT count(*) FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
@@ -130,6 +130,26 @@ impl TestApp {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
) -> reqwest::Response {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"password_check": password,
|
||||||
|
"admin": admin,
|
||||||
|
});
|
||||||
|
self.api_client
|
||||||
|
.post(format!("{}/admin/users", self.address))
|
||||||
|
.form(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
||||||
let email: String = SafeEmail().fake();
|
let email: String = SafeEmail().fake();
|
||||||
let body = format!("email={email}");
|
let body = format!("email={email}");
|
||||||
@@ -166,7 +186,7 @@ impl TestApp {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_subscriber(&self, subscriber_id: Uuid) {
|
pub async fn delete_subscriber(&self, subscriber_id: Uuid) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.delete(format!(
|
.delete(format!(
|
||||||
"{}/admin/subscribers/{}",
|
"{}/admin/subscribers/{}",
|
||||||
@@ -174,7 +194,15 @@ impl TestApp {
|
|||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Could not delete subscriber");
|
.expect("Could not delete subscriber")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user(&self, user_id: Uuid) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.delete(format!("{}/admin/users/{}", self.address, user_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Could not delete user")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn dispatch_all_pending_emails(&self) {
|
pub async fn dispatch_all_pending_emails(&self) {
|
||||||
@@ -251,7 +279,7 @@ impl TestApp {
|
|||||||
|
|
||||||
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/dashboard", &self.address))
|
.get(format!("{}/dashboard", &self.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
@@ -261,6 +289,18 @@ impl TestApp {
|
|||||||
self.get_admin_dashboard().await.text().await.unwrap()
|
self.get_admin_dashboard().await.text().await.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn edit_post<Body>(&self, body: &Body, post_id: &Uuid) -> reqwest::Response
|
||||||
|
where
|
||||||
|
Body: serde::Serialize,
|
||||||
|
{
|
||||||
|
self.api_client
|
||||||
|
.put(format!("{}/posts/{}", self.address, post_id))
|
||||||
|
.form(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_posts(&self) -> reqwest::Response {
|
pub async fn get_posts(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/posts", &self.address))
|
.get(format!("{}/posts", &self.address))
|
||||||
@@ -273,7 +313,7 @@ impl TestApp {
|
|||||||
self.get_posts().await.text().await.unwrap()
|
self.get_posts().await.text().await.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response {
|
pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/posts/{}", &self.address, post_id))
|
.get(format!("{}/posts/{}", &self.address, post_id))
|
||||||
.send()
|
.send()
|
||||||
@@ -281,7 +321,7 @@ impl TestApp {
|
|||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_post_html(&self, post_id: Uuid) -> String {
|
pub async fn get_post_html(&self, post_id: &Uuid) -> String {
|
||||||
self.get_post(post_id).await.text().await.unwrap()
|
self.get_post(post_id).await.text().await.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +340,7 @@ impl TestApp {
|
|||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/newsletters", self.address))
|
.post(format!("{}/newsletters", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -329,7 +369,7 @@ impl TestApp {
|
|||||||
|
|
||||||
pub async fn logout(&self) -> reqwest::Response {
|
pub async fn logout(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/logout", self.address))
|
.get(format!("{}/logout", self.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
@@ -340,31 +380,75 @@ impl TestApp {
|
|||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/password", self.address))
|
.post(format!("{}/password", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn edit_profile<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
|
where
|
||||||
|
Body: serde::Serialize,
|
||||||
|
{
|
||||||
|
self.api_client
|
||||||
|
.put(format!("{}/users/edit", self.address))
|
||||||
|
.form(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_profile(&self, username: &str) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.get(format!("{}/users/{}", self.address, username))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_profile_html(&self, username: &str) -> String {
|
||||||
|
self.get_profile(username).await.text().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
|
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
where
|
where
|
||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/posts", self.address))
|
.post(format!("{}/posts", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_post(&self, post_id: Uuid) {
|
pub async fn post_comment<Body>(&self, post_id: &Uuid, body: &Body) -> reqwest::Response
|
||||||
|
where
|
||||||
|
Body: serde::Serialize,
|
||||||
|
{
|
||||||
|
self.api_client
|
||||||
|
.post(format!("{}/posts/{post_id}/comments", self.address))
|
||||||
|
.form(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_post(&self, post_id: Uuid) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.delete(format!("{}/admin/posts/{}", self.address, post_id))
|
.delete(format!("{}/admin/posts/{}", self.address, post_id))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Could not delete post");
|
.expect("Could not delete post")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_comment(&self, comment_id: Uuid) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.delete(format!("{}/admin/comments/{}", self.address, comment_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Could not delete comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
|
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
@@ -378,6 +462,14 @@ impl TestApp {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_id(&self, username: &str) -> Uuid {
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_one(&self.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.user_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
||||||
@@ -406,8 +498,7 @@ pub fn fake_post_body() -> serde_json::Value {
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"title": "Post title",
|
"title": "Post title",
|
||||||
"content": "Post content",
|
"content": "Post content",
|
||||||
"idempotency_key": Uuid::new_v4().to_string(),
|
"idempotency_key": Uuid::new_v4().to_string()
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async fn login_redirects_to_admin_dashboard_after_login_success(connection_pool:
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response = app.post_login(&login_body).await;
|
let response = app.post_login(&login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
|
|
||||||
let html_page = app.get_admin_dashboard_html().await;
|
let html_page = app.get_admin_dashboard_html().await;
|
||||||
assert!(html_page.contains("Connected as"));
|
assert!(html_page.contains("Connected as"));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod admin_dashboard;
|
mod admin_dashboard;
|
||||||
mod change_password;
|
mod change_password;
|
||||||
|
mod comments;
|
||||||
mod health_check;
|
mod health_check;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod login;
|
mod login;
|
||||||
@@ -9,3 +10,4 @@ mod subscriptions;
|
|||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
mod unsubscribe;
|
mod unsubscribe;
|
||||||
mod unsubscribe_confirm;
|
mod unsubscribe_confirm;
|
||||||
|
mod users;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) {
|
|||||||
.fetch_one(&app.connection_pool)
|
.fetch_one(&app.connection_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let html = app.get_post_html(post.post_id).await;
|
let html = app.get_post_html(&post.post_id).await;
|
||||||
assert!(html.contains(&title));
|
assert!(html.contains(&title));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) {
|
|||||||
.fetch_one(&app.connection_pool)
|
.fetch_one(&app.connection_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let html = app.get_post_html(post.post_id).await;
|
let html = app.get_post_html(&post.post_id).await;
|
||||||
assert!(html.contains(&title));
|
assert!(html.contains(&title));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) {
|
|||||||
|
|
||||||
app.delete_post(post.post_id).await;
|
app.delete_post(post.post_id).await;
|
||||||
|
|
||||||
let html = app.get_post_html(post.post_id).await;
|
let html = app.get_post_html(&post.post_id).await;
|
||||||
assert!(html.contains("Not Found"));
|
assert!(html.contains("Not Found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo
|
|||||||
.opened
|
.opened
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn only_post_author_can_access_the_edit_form(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.post_id;
|
||||||
|
let html = app.get_post_html(&post_id).await;
|
||||||
|
assert!(html.contains("Edit"));
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let html = app.get_post_html(&post_id).await;
|
||||||
|
assert!(!html.contains("Edit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn only_post_author_can_edit_post(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.post_id;
|
||||||
|
|
||||||
|
let new_title = "Stunning new title";
|
||||||
|
let new_content = "Astonishing content";
|
||||||
|
let edit_body = serde_json::json!({
|
||||||
|
"title": new_title,
|
||||||
|
"content": new_content,
|
||||||
|
});
|
||||||
|
let response = app.edit_post(&edit_body, &post_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("Your changes have been saved"));
|
||||||
|
let text = app.get_post_html(&post_id).await;
|
||||||
|
assert!(text.contains(new_title));
|
||||||
|
assert!(text.contains(new_content));
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let response = app.edit_post(&edit_body, &post_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("You are not authorized."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.post_id;
|
||||||
|
|
||||||
|
let test_cases = [
|
||||||
|
(
|
||||||
|
serde_json::json!({
|
||||||
|
"title": "",
|
||||||
|
"content": "content"
|
||||||
|
}),
|
||||||
|
"Title must be at least one character",
|
||||||
|
"title was empty",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
serde_json::json!({
|
||||||
|
"title": "Title",
|
||||||
|
"content": ""
|
||||||
|
}),
|
||||||
|
"Content must be at least one character",
|
||||||
|
"content was empty",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (invalid_body, expected_error_message, explaination) in test_cases {
|
||||||
|
let response = app.edit_post(&invalid_body, &post_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
text.contains(expected_error_message),
|
||||||
|
"The API did not reject the changes when the {}",
|
||||||
|
explaination
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
527
tests/api/users.rs
Normal file
527
tests/api/users.rs
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use zero2prod::authentication::Role;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_can_create_user(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_can_create_admin_user(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
|
||||||
|
let record = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT role as "role: Role"
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
matches!(record.role, Role::Admin);
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains("Administration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_create_posts(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_send_emails(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_create_users(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let username = "other_user";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_delete_contents(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
let (subscriber_id, email) = {
|
||||||
|
let record = sqlx::query!("SELECT id, email FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.id, record.email)
|
||||||
|
};
|
||||||
|
let response = app.delete_subscriber(subscriber_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains(&email));
|
||||||
|
assert!(text.contains("has been deleted"));
|
||||||
|
|
||||||
|
app.create_user("other_user", password, true).await;
|
||||||
|
let user_id = {
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.user_id
|
||||||
|
};
|
||||||
|
let response = app.delete_user(user_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("The user has been deleted"));
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "comment",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
let response = app.delete_comment(comment_id).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("The comment has been deleted")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app.delete_post(post_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("The post has been deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_functions_are_hidden_for_non_admin_users(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let record = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT role as "role: Role"
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
matches!(record.role, Role::Writer);
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
let response = app.post_login(&login_body).await;
|
||||||
|
assert!(!response.text().await.unwrap().contains("Administration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn writers_can_publish_posts_and_send_emails(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(2)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn writers_cannot_perform_admin_functions(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
let subscriber_id = {
|
||||||
|
let record = sqlx::query!("SELECT id FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.id
|
||||||
|
};
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "comment",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let response = app.delete_subscriber(subscriber_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT id FROM subscriptions")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let response = app.delete_comment(comment_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let response = app.delete_post(post_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let user_id = {
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.user_id
|
||||||
|
};
|
||||||
|
let response = app.delete_user(user_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
|
||||||
|
let record = sqlx::query_scalar!("SELECT username FROM users WHERE user_id = $1", user_id)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let username = "friend";
|
||||||
|
let password = "123456789abc";
|
||||||
|
let response = app.create_user(username, password, false).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn user_can_change_his_display_name(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let user_id = app.get_user_id(username).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
let full_name = "Alphonse Paix";
|
||||||
|
let edit_body = serde_json::json!( {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"full_name": full_name,
|
||||||
|
"bio": "",
|
||||||
|
});
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(!html.contains(full_name));
|
||||||
|
let response = app.edit_profile(&edit_body).await;
|
||||||
|
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(html.contains(full_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn user_can_change_his_bio(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let user_id = app.get_user_id(username).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
let bio = "This is me";
|
||||||
|
let edit_body = serde_json::json!( {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"full_name": "",
|
||||||
|
"bio": bio,
|
||||||
|
});
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(!html.contains(bio));
|
||||||
|
let response = app.edit_profile(&edit_body).await;
|
||||||
|
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(html.contains(bio));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn user_can_change_his_username(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let user_id = app.get_user_id(username).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
let new_username = "alphonsepaix";
|
||||||
|
let edit_body = serde_json::json!( {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": new_username,
|
||||||
|
"full_name": "",
|
||||||
|
"bio": "",
|
||||||
|
});
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
let response = app.edit_profile(&edit_body).await;
|
||||||
|
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(html.contains("404"));
|
||||||
|
let html = app.get_profile_html(new_username).await;
|
||||||
|
assert!(html.contains(new_username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn user_cannot_change_other_profiles(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let other_user_id = app.get_user_id("admin").await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
let new_username = "alphonsepaix";
|
||||||
|
let edit_body = serde_json::json!( {
|
||||||
|
"user_id": other_user_id,
|
||||||
|
"username": new_username,
|
||||||
|
"full_name": "",
|
||||||
|
"bio": "",
|
||||||
|
});
|
||||||
|
let response = app.edit_profile(&edit_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("You are not authorized")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn user_cannot_take_an_existing_username(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let user_id = app.get_user_id(username).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
let edit_body = serde_json::json!( {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": "admin",
|
||||||
|
"full_name": "",
|
||||||
|
"bio": "",
|
||||||
|
});
|
||||||
|
let response = app.edit_profile(&edit_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("This username is already taken")
|
||||||
|
);
|
||||||
|
let html = app.get_profile_html(username).await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let user_id = app.get_user_id(username).await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let test_cases = [(
|
||||||
|
serde_json::json!({
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": "ab",
|
||||||
|
"full_name": "",
|
||||||
|
"bio": "",
|
||||||
|
}),
|
||||||
|
"Username must be at least 3 characters",
|
||||||
|
"the username was too short",
|
||||||
|
)];
|
||||||
|
for (invalid_body, expected_error_message, explaination) in test_cases {
|
||||||
|
let html = app.edit_profile(&invalid_body).await;
|
||||||
|
assert!(
|
||||||
|
html.text().await.unwrap().contains(expected_error_message),
|
||||||
|
"The API did not reject the changes when {}",
|
||||||
|
explaination
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user