Compare commits
7 Commits
8a5605812c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be69a54fd1 | ||
|
|
90aa4f8185 | ||
|
|
5d5f9ec765 | ||
|
|
7affe88d50 | ||
|
|
e02139ff44 | ||
|
|
45f529902d | ||
|
|
ef9f860da2 |
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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,42 +1,53 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "comment_id",
|
||||
"name": "user_id?",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "comment_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "post_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 4,
|
||||
"name": "author",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 5,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 6,
|
||||
"name": "published_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
@@ -44,5 +55,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b5951c4e52f86101b33606abf65cdb411578eea4e0635a2b0ea14600e6d5031"
|
||||
"hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE posts\n SET title = $1, content = $2 WHERE post_id = $3\n ",
|
||||
"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": "790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3"
|
||||
"hash": "aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,41 +1,54 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "comment_id",
|
||||
"name": "user_id?",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "comment_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "post_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 4,
|
||||
"name": "author",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 5,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 6,
|
||||
"name": "published_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
@@ -43,5 +56,5 @@
|
||||
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"
|
||||
}
|
||||
2783
assets/css/main.css
2783
assets/css/main.css
File diff suppressed because one or more lines are too long
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",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
||||
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.3",
|
||||
|
||||
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;
|
||||
|
||||
pub struct CommentEntry {
|
||||
pub user_id: Option<Uuid>,
|
||||
pub username: Option<String>,
|
||||
pub comment_id: Uuid,
|
||||
pub post_id: Uuid,
|
||||
pub author: Option<String>,
|
||||
@@ -11,6 +13,6 @@ pub struct CommentEntry {
|
||||
|
||||
impl CommentEntry {
|
||||
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,15 +3,18 @@ use uuid::Uuid;
|
||||
|
||||
pub struct PostEntry {
|
||||
pub post_id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub author: String,
|
||||
pub full_name: Option<String>,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub published_at: DateTime<Utc>,
|
||||
pub last_modified: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PostEntry {
|
||||
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) -> anyhow::Result<String> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use validator::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)]
|
||||
mod tests {
|
||||
use super::SubscriberEmail;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
use tracing::{Span, field::display};
|
||||
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 email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||
worker_loop(connection_pool, email_client).await
|
||||
@@ -31,14 +31,6 @@ pub enum ExecutionOutcome {
|
||||
EmptyQueue,
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
newsletter_issue_id=tracing::field::Empty,
|
||||
subscriber_email=tracing::field::Empty
|
||||
),
|
||||
err
|
||||
)]
|
||||
pub async fn try_execute_task(
|
||||
connection_pool: &PgPool,
|
||||
email_client: &EmailClient,
|
||||
@@ -53,25 +45,14 @@ pub async fn try_execute_task(
|
||||
.record("subscriber_email", display(&task.subscriber_email));
|
||||
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
||||
Ok(email) => {
|
||||
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(&mut transaction).await?;
|
||||
}
|
||||
if let Err(e) = email_client
|
||||
.send_email(
|
||||
&email,
|
||||
&issue.title,
|
||||
&issue.html_content,
|
||||
&issue.text_content,
|
||||
execute_task(
|
||||
connection_pool,
|
||||
&mut transaction,
|
||||
&task,
|
||||
email,
|
||||
email_client,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Failed to deliver issue to confirmed subscriber. Skipping."
|
||||
);
|
||||
}
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
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(
|
||||
mut transaction: Transaction<'static, Postgres>,
|
||||
issue_id: Uuid,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod authentication;
|
||||
pub mod configuration;
|
||||
pub mod database_worker;
|
||||
pub mod domain;
|
||||
pub mod email_client;
|
||||
pub mod idempotency;
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -1,6 +1,6 @@
|
||||
use zero2prod::{
|
||||
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped,
|
||||
startup::Application, telemetry::init_subscriber,
|
||||
configuration::get_configuration, database_worker, issue_delivery_worker, startup::Application,
|
||||
telemetry::init_subscriber,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -11,11 +11,16 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
let application = Application::build(configuration.clone()).await?;
|
||||
|
||||
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! {
|
||||
_ = application_task => {},
|
||||
_ = worker_task => {},
|
||||
_ = database_worker_task => {},
|
||||
_ = delivery_worker_task => {},
|
||||
};
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct CommentForm {
|
||||
pub author: Option<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))]
|
||||
@@ -51,7 +52,13 @@ pub async fn post_comment(
|
||||
}
|
||||
};
|
||||
|
||||
insert_comment(&mut transaction, post_id, form.author, form.content)
|
||||
insert_comment(
|
||||
&mut transaction,
|
||||
post_id,
|
||||
form.author,
|
||||
form.user_id,
|
||||
form.content,
|
||||
)
|
||||
.await
|
||||
.context("Could not insert comment into database.")?;
|
||||
|
||||
@@ -75,20 +82,25 @@ async fn insert_comment(
|
||||
transaction: &mut Transaction<'static, Postgres>,
|
||||
post_id: Uuid,
|
||||
author: Option<String>,
|
||||
user_id: Option<Uuid>,
|
||||
content: String,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let author = author
|
||||
let author = if user_id.is_some() {
|
||||
None
|
||||
} else {
|
||||
author
|
||||
.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();
|
||||
tracing::Span::current().record("comment_id", comment_id.to_string());
|
||||
let query = sqlx::query!(
|
||||
"
|
||||
INSERT INTO comments (comment_id, post_id, author, content)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO comments (user_id, comment_id, post_id, author, content)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
",
|
||||
user_id,
|
||||
comment_id,
|
||||
post_id,
|
||||
author,
|
||||
@@ -177,22 +189,35 @@ pub async fn get_comments_page_for_post(
|
||||
page: i64,
|
||||
) -> Result<Vec<CommentEntry>, sqlx::Error> {
|
||||
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
||||
let comments = sqlx::query_as!(
|
||||
let mut comments = sqlx::query_as!(
|
||||
CommentEntry,
|
||||
"
|
||||
SELECT comment_id, post_id, author, content, published_at
|
||||
FROM comments
|
||||
WHERE post_id = $1
|
||||
ORDER BY published_at DESC
|
||||
r#"
|
||||
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 c
|
||||
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
|
||||
WHERE c.post_id = $1
|
||||
ORDER BY c.published_at DESC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
",
|
||||
"#,
|
||||
post_id,
|
||||
COMMENTS_PER_PAGE,
|
||||
offset
|
||||
)
|
||||
.fetch_all(connection_pool)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -214,13 +239,14 @@ pub async fn get_comments_page(
|
||||
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
||||
let comments = sqlx::query_as!(
|
||||
CommentEntry,
|
||||
"
|
||||
SELECT comment_id, post_id, author, content, published_at
|
||||
FROM comments
|
||||
r#"
|
||||
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 c
|
||||
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
|
||||
ORDER BY published_at DESC
|
||||
LIMIT $1
|
||||
OFFSET $2
|
||||
",
|
||||
"#,
|
||||
COMMENTS_PER_PAGE,
|
||||
offset
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@ use axum::{
|
||||
};
|
||||
use axum::{http::StatusCode, response::Redirect};
|
||||
use secrecy::SecretString;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LoginFormData {
|
||||
@@ -50,6 +52,9 @@ pub async fn post_login(
|
||||
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
||||
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
|
||||
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
|
||||
@@ -69,3 +74,11 @@ pub async fn post_login(
|
||||
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use axum::{
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
@@ -77,7 +78,8 @@ async fn get_posts(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
ORDER BY p.published_at DESC
|
||||
@@ -99,7 +101,8 @@ pub async fn get_posts_page(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
ORDER BY p.published_at DESC
|
||||
@@ -151,10 +154,11 @@ pub async fn update_post(
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE posts
|
||||
SET title = $1, content = $2 WHERE post_id = $3
|
||||
SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4
|
||||
",
|
||||
form.title,
|
||||
form.content,
|
||||
Utc::now(),
|
||||
post_id
|
||||
)
|
||||
.execute(&connection_pool)
|
||||
@@ -197,7 +201,7 @@ pub async fn see_post(
|
||||
|
||||
if let Some(post) = get_post_data(&connection_pool, post_id)
|
||||
.await
|
||||
.context(format!("Failed to fetch post #{}", post_id))
|
||||
.context(format!("Failed to fetch post #{}.", post_id))
|
||||
.map_err(AppError::unexpected_page)?
|
||||
{
|
||||
let post_html = post
|
||||
@@ -206,16 +210,20 @@ pub async fn see_post(
|
||||
let current_page = 1;
|
||||
let comments_count = get_comments_count_for_post(&connection_pool, post_id)
|
||||
.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 comments = get_comments_page_for_post(&connection_pool, post_id, 1)
|
||||
.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")?;
|
||||
.context("Could not check for session username.")?;
|
||||
let template = HtmlTemplate(PostTemplate {
|
||||
post,
|
||||
post_html,
|
||||
@@ -224,6 +232,7 @@ pub async fn see_post(
|
||||
current_page,
|
||||
max_page,
|
||||
comments_count,
|
||||
session_user_id,
|
||||
session_username,
|
||||
});
|
||||
Ok(template.into_response())
|
||||
@@ -252,7 +261,8 @@ async fn get_post_data(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
WHERE p.post_id = $1
|
||||
|
||||
@@ -91,7 +91,11 @@ pub async fn update_user(
|
||||
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
|
||||
@@ -255,13 +259,32 @@ pub async fn user_profile(
|
||||
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||
.await
|
||||
.context("Could not fetch user posts.")?;
|
||||
let session_username = session
|
||||
.get_username()
|
||||
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_username,
|
||||
session_user_id,
|
||||
last_seen,
|
||||
posts,
|
||||
});
|
||||
Ok(template.into_response())
|
||||
@@ -299,7 +322,8 @@ async fn fetch_user_posts(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
INNER JOIN users u ON p.author_id = u.user_id
|
||||
WHERE p.author_id = $1
|
||||
|
||||
@@ -21,7 +21,7 @@ where
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.pretty()
|
||||
.with_writer(sink)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct HtmlTemplate<T>(pub T);
|
||||
@@ -25,8 +26,9 @@ where
|
||||
#[template(path = "user/profile.html")]
|
||||
pub struct UserTemplate {
|
||||
pub user: UserEntry,
|
||||
pub session_username: Option<String>,
|
||||
pub session_user_id: Option<Uuid>,
|
||||
pub posts: Vec<PostEntry>,
|
||||
pub last_seen: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -127,6 +129,7 @@ pub struct PostTemplate {
|
||||
pub current_page: i64,
|
||||
pub max_page: i64,
|
||||
pub comments_count: i64,
|
||||
pub session_user_id: Option<Uuid>,
|
||||
pub session_username: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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 %}
|
||||
{{ name }}
|
||||
{% else %}
|
||||
Anonymous
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
|
||||
<div class="flex items-center text-xs text-gray-500 mb-1">
|
||||
@@ -17,8 +24,7 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<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"/>
|
||||
<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" />
|
||||
</svg>
|
||||
<time datetime="{{ comment.published_at }}">
|
||||
{{ comment.formatted_date() }}
|
||||
@@ -26,9 +32,8 @@
|
||||
</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>
|
||||
<a href="/posts/{{ comment.post_id }}"
|
||||
class="text-blue-600 hover:underline truncate">#{{ comment.post_id }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
|
||||
@@ -40,8 +45,7 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<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"/>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<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" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
@@ -23,11 +24,16 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<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>
|
||||
<a href="/users/{{ post.author }}"
|
||||
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
|
||||
class="hover:text-blue-600 hover:underline">
|
||||
{% if let Some(full_name) = post.full_name %}
|
||||
{{ full_name }}
|
||||
{% else %}
|
||||
{{ post.author }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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="flex items-start space-x-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<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"
|
||||
@@ -11,20 +11,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="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-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 %}
|
||||
{{ name }}
|
||||
{% else %}
|
||||
Anonymous
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-400">•</span>
|
||||
<time class="text-sm text-gray-500" datetime="{{ comment.published_at }}">
|
||||
{% endif %}
|
||||
<time class="block text-sm text-gray-500 mt-0.5"
|
||||
datetime="{{ comment.published_at }}">
|
||||
{{ comment.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-line">{{ comment.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-line mt-2">{{ comment.content }}</p>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,26 @@
|
||||
hx-swap="innerHTML"
|
||||
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>
|
||||
{% if session_username.is_none() %}
|
||||
<input type="text"
|
||||
name="author"
|
||||
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">
|
||||
{% 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>
|
||||
<textarea name="content"
|
||||
@@ -35,8 +50,7 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<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"/>
|
||||
<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" />
|
||||
</svg>
|
||||
<p>No comments yet. Be the first to comment!</p>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<header class="mb-4">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
<header class="mb-4 space-y-4">
|
||||
<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 items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
@@ -17,7 +17,13 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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 class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||
@@ -31,7 +37,7 @@
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
{% 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">
|
||||
@@ -46,8 +52,11 @@
|
||||
</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>
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
{% 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>
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<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"/>
|
||||
<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" />
|
||||
</svg>
|
||||
<p>No posts yet</p>
|
||||
</div>
|
||||
@@ -15,18 +14,19 @@
|
||||
<div class="divide-y divide-gray-200">
|
||||
{% for post in posts %}
|
||||
<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-1 min-w-0">
|
||||
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{
|
||||
post.title }}</h3>
|
||||
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">
|
||||
{{
|
||||
post.title }}
|
||||
</h3>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<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" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
|
||||
@@ -2,54 +2,62 @@
|
||||
{% block title %}{{ user.username }}{% endblock %}
|
||||
{% block content %}
|
||||
<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-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 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username)
|
||||
}}</h1>
|
||||
<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">
|
||||
<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() %}
|
||||
<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"
|
||||
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" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if session_username.as_deref() == Some(user.username) %}
|
||||
{% if session_user_id.as_ref() == Some(user.user_id) %}
|
||||
<a href="/users/edit"
|
||||
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3">
|
||||
<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"/>
|
||||
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 mb-3">@{{ user.username }}</p>
|
||||
<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">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<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" />
|
||||
</svg>
|
||||
{{ user.formatted_date() }}
|
||||
</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>
|
||||
{% if user.bio.is_some() %}
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
||||
<div class="border-t border-gray-100 pt-6">
|
||||
<p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "activity.html" %}
|
||||
<!-- Activity -->
|
||||
<div class="mt-8">{% include "activity.html" %}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user