Compare commits
126 Commits
72fa283a6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be69a54fd1 | ||
|
|
90aa4f8185 | ||
|
|
5d5f9ec765 | ||
|
|
7affe88d50 | ||
|
|
e02139ff44 | ||
|
|
45f529902d | ||
|
|
ef9f860da2 | ||
|
|
8a5605812c | ||
|
|
d27196d7e5 | ||
|
|
9cbcdc533e | ||
|
|
f18899b1a6 | ||
|
|
3bfac6d012 | ||
|
|
0b402c6259 | ||
|
|
8b5f55db6f | ||
|
|
b252216709 | ||
|
|
da590fb7c6 | ||
|
|
04c2d2b7f5 | ||
|
|
d96a29ee73 | ||
|
|
8f62c2513e | ||
|
|
50a7af2b06 | ||
|
|
af9cbdcafb | ||
|
|
ce8c602ddb | ||
|
|
9296187181 | ||
|
|
42c1dd9fe3 | ||
|
|
96e5dd0f35 | ||
|
|
91e80b4881 | ||
|
|
9e5d185aaf | ||
|
|
2c7282475f | ||
|
|
402c560354 | ||
|
|
3e81c27ab3 | ||
|
|
b5b00152cd | ||
|
|
22c462fba3 | ||
|
|
de44564ba0 | ||
|
|
3b727269c5 | ||
|
|
271aa87b9e | ||
|
|
34463d92fc | ||
|
|
c58dfaf647 | ||
|
|
b629a8e2fb | ||
|
|
2de3f8dcf7 | ||
|
|
1117d49746 | ||
|
|
ac96b3c249 | ||
|
|
87c529ecb6 | ||
|
|
f43e143bf6 | ||
|
|
f9ae3f42a6 | ||
|
|
0f6b479af9 | ||
|
|
4cb1d2b6fd | ||
|
|
33281132c6 | ||
|
|
9ea539e5cc | ||
|
|
165fc1bd70 | ||
|
|
3153b99d94 | ||
|
|
b1e315921e | ||
|
|
5c5e3b0e4c | ||
|
|
03ca17fdb5 | ||
|
|
b00129bca4 | ||
|
|
bcb5ada8ef | ||
|
|
ab650fdd35 | ||
|
|
4e18476f5e | ||
|
|
e90235a515 | ||
|
|
6f9d33953c | ||
|
|
4b5fbc2eb3 | ||
|
|
05ac172907 | ||
|
|
98611f18e3 | ||
|
|
829f3e4e4f | ||
|
|
0725b87bf2 | ||
|
|
56b25515f9 | ||
|
|
9dae7ff75d | ||
|
|
53af71a9a1 | ||
|
|
9922a62691 | ||
|
|
eb55fdb29f | ||
|
|
e017a4ed3f | ||
|
|
0bd10b201d | ||
|
|
bef658b940 | ||
|
|
bf2ec15e71 | ||
|
|
38cb594882 | ||
|
|
f7ebf73fbc | ||
|
|
d85879a004 | ||
|
|
7971095227 | ||
|
|
2b9cf979e8 | ||
|
|
6ad207d0a4 | ||
|
|
44b2ce677a | ||
|
|
bb27ad024d | ||
|
|
7578097754 | ||
|
|
13cb477598 | ||
|
|
384d88eee8 | ||
|
|
ebae511a12 | ||
|
|
066c2b8252 | ||
|
|
848fd621b7 | ||
|
|
eec6e5f566 | ||
|
|
a3ef312a6a | ||
|
|
bdddf0fe4a | ||
|
|
7364e2a23c | ||
|
|
7689628ffb | ||
|
|
a3533bfde7 | ||
|
|
626726d206 | ||
|
|
ee72073ff5 | ||
|
|
d23d9a4e6e | ||
|
|
8e1d68d948 | ||
|
|
612a221907 | ||
|
|
01b08bdc0d | ||
|
|
8a977df948 | ||
|
|
bcce04756c | ||
|
|
7c8ac0361e | ||
|
|
bda940bb2d | ||
|
|
c727b5032c | ||
|
|
a7d22e6634 | ||
|
|
767fc571b6 | ||
|
|
3057fdc927 | ||
|
|
d47fba5cc9 | ||
|
|
6f6e6ab017 | ||
|
|
de1fc4a825 | ||
|
|
3ae50830f4 | ||
|
|
684519f689 | ||
|
|
fccb79c57f | ||
|
|
5a86d7a35f | ||
|
|
415d787260 | ||
|
|
310a202ca3 | ||
|
|
394799f4e0 | ||
|
|
637a9e39d4 | ||
|
|
6a25c43ce4 | ||
|
|
d0c146328a | ||
|
|
19ddc8958d | ||
|
|
80b8029844 | ||
|
|
6dd44522b0 | ||
|
|
f3e76acc00 | ||
|
|
f1290d0bc5 | ||
|
|
709bd28a8c |
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "clang"
|
||||||
|
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
/tests
|
||||||
|
Dockerfile
|
||||||
|
/scripts
|
||||||
|
/migrations
|
||||||
|
/node_modules
|
||||||
|
/assets/css/main.css
|
||||||
|
/.github
|
||||||
|
README.md
|
||||||
|
/tests
|
||||||
|
/configuration/local.yaml
|
||||||
134
.github/workflows/general.yml
vendored
Normal file
134
.github/workflows/general.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
SQLX_VERSION: 0.8.6
|
||||||
|
SQLX_FEATURES: "rustls,postgres"
|
||||||
|
DATABASE_URL: postgres://postgres:password@postgres:5432/newsletter
|
||||||
|
APP_DATABASE__HOST: postgres
|
||||||
|
APP_KV_STORE__HOST: redis
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: newsletter
|
||||||
|
ports:
|
||||||
|
- 15432:5432
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- 16379:6379
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install mold linker
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mold clang
|
||||||
|
- name: Install the Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
- name: Install sqlx-cli
|
||||||
|
run: cargo install sqlx-cli
|
||||||
|
--version=${{ env.SQLX_VERSION }}
|
||||||
|
--features ${{ env.SQLX_FEATURES }}
|
||||||
|
--no-default-features
|
||||||
|
--locked
|
||||||
|
- name: Migrate database
|
||||||
|
run: cargo sqlx migrate run
|
||||||
|
- name: Run tests
|
||||||
|
run: TEST_LOG=true cargo test
|
||||||
|
- name: Check that queries are fresh
|
||||||
|
run: cargo sqlx prepare --check --workspace
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
name: Rustfmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install the Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
cache: false
|
||||||
|
- name: Enforce formatting
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: true
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install mold linker
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mold clang
|
||||||
|
- name: Install the Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
cache: false
|
||||||
|
- name: Linting
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: Code coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: newsletter
|
||||||
|
ports:
|
||||||
|
- 15432:5432
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- 16379:6379
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install mold linker
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mold clang
|
||||||
|
- name: Install the Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: llvm-tools-preview
|
||||||
|
cache: false
|
||||||
|
- name: Install sqlx-cli
|
||||||
|
run: cargo install sqlx-cli
|
||||||
|
--version=${{ env.SQLX_VERSION }}
|
||||||
|
--features ${{ env.SQLX_FEATURES }}
|
||||||
|
--no-default-features
|
||||||
|
--locked
|
||||||
|
- name: Migrate database
|
||||||
|
run: cargo sqlx migrate run
|
||||||
|
- name: Install cargo-llvm-cov
|
||||||
|
uses: taiki-e/install-action@cargo-llvm-cov
|
||||||
|
- name: Generate code coverage
|
||||||
|
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||||
|
- name: Generate report
|
||||||
|
run: cargo llvm-cov report --html --output-dir coverage
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
|
/node_modules
|
||||||
|
.env
|
||||||
|
/.idea
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
20
.sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json
generated
Normal file
20
.sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM notifications_delivered WHERE opened = TRUE",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca"
|
||||||
|
}
|
||||||
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, password_hash, role as \"role: Role\"\n FROM users\n WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
38
.sqlx/query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json
generated
Normal file
38
.sqlx/query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json
generated
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token, kind\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "newsletter_issue_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "subscriber_email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "unsubscribe_token",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f"
|
||||||
|
}
|
||||||
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal file
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO posts (post_id, author_id, title, content, published_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Timestamptz"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc"
|
||||||
|
}
|
||||||
20
.sqlx/query-3d7376ca79ffd159830fc6d43042d5fe761b6d330924bde7c5fc0f17f533def9.json
generated
Normal file
20
.sqlx/query-3d7376ca79ffd159830fc6d43042d5fe761b6d330924bde7c5fc0f17f533def9.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM posts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3d7376ca79ffd159830fc6d43042d5fe761b6d330924bde7c5fc0f17f533def9"
|
||||||
|
}
|
||||||
20
.sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json
generated
Normal file
20
.sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM notifications_delivered",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21"
|
||||||
|
}
|
||||||
22
.sqlx/query-4141df8c45db179016d8e87b023b572bec7e04a6f3324aa17de7e7a9b1fb32ef.json
generated
Normal file
22
.sqlx/query-4141df8c45db179016d8e87b023b572bec7e04a6f3324aa17de7e7a9b1fb32ef.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM subscriptions WHERE id = $1 RETURNING email",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4141df8c45db179016d8e87b023b572bec7e04a6f3324aa17de7e7a9b1fb32ef"
|
||||||
|
}
|
||||||
15
.sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json
generated
Normal file
15
.sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token,\n kind\n )\n SELECT $1, email, unsubscribe_token, $2\n FROM subscriptions\n WHERE status = 'confirmed'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal file
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO newsletter_issues (\n newsletter_issue_id, title, text_content, html_content, published_at\n )\n VALUES ($1, $2, $3, $4, now())\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2"
|
||||||
|
}
|
||||||
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal file
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO subscriptions (id, email, subscribed_at, status)\n VALUES ($1, $2, $3, 'pending_confirmation')\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Timestamptz"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14"
|
||||||
|
}
|
||||||
20
.sqlx/query-68a00cae18e40dc76ffea61dfc0ea84d8cb09502b24c11dbb8d403419899dfd1.json
generated
Normal file
20
.sqlx/query-68a00cae18e40dc76ffea61dfc0ea84d8cb09502b24c11dbb8d403419899dfd1.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM subscriptions",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "68a00cae18e40dc76ffea61dfc0ea84d8cb09502b24c11dbb8d403419899dfd1"
|
||||||
|
}
|
||||||
60
.sqlx/query-73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438.json
generated
Normal file
60
.sqlx/query-73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438.json
generated
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, username, role as \"role: Role\", full_name, bio, member_since\n FROM users\n ORDER BY member_since DESC\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": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438"
|
||||||
|
}
|
||||||
57
.sqlx/query-74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9.json
generated
Normal file
57
.sqlx/query-74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9.json
generated
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"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 idempotency_key = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "response_status_code!",
|
||||||
|
"type_info": "Int2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "response_headers!: Vec<HeaderPairRecord>",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair[]",
|
||||||
|
"kind": {
|
||||||
|
"Array": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair",
|
||||||
|
"kind": {
|
||||||
|
"Composite": [
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"Text"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"value",
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "response_body!",
|
||||||
|
"type_info": "Bytea"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
27
.sqlx/query-878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286.json
generated
Normal file
27
.sqlx/query-878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286.json
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO users (user_id, username, password_hash, role)\n VALUES ($1, $2, $3, $4)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
{
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286"
|
||||||
|
}
|
||||||
59
.sqlx/query-886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063.json
generated
Normal file
59
.sqlx/query-886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063.json
generated
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"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": "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": 4,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
|
||||||
|
}
|
||||||
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "unsubscribe_token",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
20
.sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json
generated
Normal file
20
.sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM subscriptions WHERE status = 'confirmed'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6"
|
||||||
|
}
|
||||||
14
.sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json
generated
Normal file
14
.sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d"
|
||||||
|
}
|
||||||
47
.sqlx/query-a6cb227efa5ac12189e662d68b8dcc39032f308f211f603dfcf539b7b071b8e3.json
generated
Normal file
47
.sqlx/query-a6cb227efa5ac12189e662d68b8dcc39032f308f211f603dfcf539b7b071b8e3.json
generated
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM subscriptions ORDER BY subscribed_at DESC LIMIT $1 OFFSET $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "subscribed_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "unsubscribe_token",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a6cb227efa5ac12189e662d68b8dcc39032f308f211f603dfcf539b7b071b8e3"
|
||||||
|
}
|
||||||
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM subscriptions WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd"
|
||||||
|
}
|
||||||
22
.sqlx/query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json
generated
Normal file
22
.sqlx/query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "subscriber_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal file
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM issue_delivery_queue\n WHERE\n newsletter_issue_id = $1\n AND subscriber_email = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249"
|
||||||
|
}
|
||||||
14
.sqlx/query-b47161386b21432693aa3827963e8167c942e395687cd5ffecb7c064ca2dde70.json
generated
Normal file
14
.sqlx/query-b47161386b21432693aa3827963e8167c942e395687cd5ffecb7c064ca2dde70.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM posts WHERE post_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b47161386b21432693aa3827963e8167c942e395687cd5ffecb7c064ca2dde70"
|
||||||
|
}
|
||||||
40
.sqlx/query-b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153.json
generated
Normal file
40
.sqlx/query-b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153.json
generated
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"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": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int2",
|
||||||
|
{
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair[]",
|
||||||
|
"kind": {
|
||||||
|
"Array": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair",
|
||||||
|
"kind": {
|
||||||
|
"Composite": [
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"Text"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"value",
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153"
|
||||||
|
}
|
||||||
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
|
||||||
|
}
|
||||||
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal file
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM comments WHERE post_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
14
.sqlx/query-caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58.json
generated
Normal file
14
.sqlx/query-caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM comments WHERE comment_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
14
.sqlx/query-dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2.json
generated
Normal file
14
.sqlx/query-dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM users WHERE user_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2"
|
||||||
|
}
|
||||||
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, username, full_name, role as \"role: Role\", member_since, bio\n FROM users\n WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "member_since",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "bio",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271"
|
||||||
|
}
|
||||||
20
.sqlx/query-e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034.json
generated
Normal file
20
.sqlx/query-e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM comments",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034"
|
||||||
|
}
|
||||||
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal file
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users SET password_hash = $1 WHERE user_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
40
.sqlx/query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json
generated
Normal file
40
.sqlx/query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json
generated
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT newsletter_issue_id, title, text_content, html_content\n FROM newsletter_issues\n WHERE newsletter_issue_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "newsletter_issue_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "text_content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "html_content",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f"
|
||||||
|
}
|
||||||
15
.sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json
generated
Normal file
15
.sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO notifications_delivered (email_id, newsletter_issue_id)\n VALUES ($1, $2)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385"
|
||||||
|
}
|
||||||
15
.sqlx/query-fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90.json
generated
Normal file
15
.sqlx/query-fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO subscription_tokens (subscription_token, subscriber_id)\n VALUES ($1, $2)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90"
|
||||||
|
}
|
||||||
60
.sqlx/query-fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259.json
generated
Normal file
60
.sqlx/query-fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259.json
generated
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"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": "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": 4,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
3562
Cargo.lock
generated
3562
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
66
Cargo.toml
@@ -2,7 +2,69 @@
|
|||||||
name = "zero2prod"
|
name = "zero2prod"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
path = "src/main.rs"
|
||||||
|
name = "zero2prod"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 'z'
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = 'abort'
|
||||||
|
strip = true
|
||||||
|
rpath = false
|
||||||
|
debug = false
|
||||||
|
debug-assertions = false
|
||||||
|
overflow-checks = false
|
||||||
|
incremental = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.4"
|
anyhow = "1.0.99"
|
||||||
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
|
askama = "0.14.0"
|
||||||
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
|
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
||||||
|
config = "0.15.14"
|
||||||
|
markdown = "1.0.0"
|
||||||
|
rand = { version = "0.9.2", features = ["std_rng"] }
|
||||||
|
reqwest = { version = "0.12.23", default-features = false, features = [
|
||||||
|
"cookies",
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde-aux = "4.7.0"
|
||||||
|
sqlx = { version = "0.8.6", features = [
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"macros",
|
||||||
|
"postgres",
|
||||||
|
"uuid",
|
||||||
|
"chrono",
|
||||||
|
"migrate",
|
||||||
|
] }
|
||||||
|
thiserror = "2.0.16"
|
||||||
|
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tower-http = { version = "0.6.6", features = ["fs", "trace"] }
|
||||||
|
tower-sessions = "0.14.0"
|
||||||
|
tower-sessions-redis-store = "0.16.0"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
||||||
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
claims = "0.8.0"
|
||||||
|
fake = "4.4.0"
|
||||||
|
linkify = "0.10.0"
|
||||||
|
once_cell = "1.21.3"
|
||||||
|
quickcheck = "1.0.3"
|
||||||
|
quickcheck_macros = "1.1.0"
|
||||||
|
scraper = "0.24.0"
|
||||||
|
serde_json = "1.0.143"
|
||||||
|
wiremock = "0.6.4"
|
||||||
|
|||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt update && apt install -y nodejs npm clang mold && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
ENV RUSTFLAGS="-C strip=symbols"
|
||||||
|
RUN cargo build --release --bin zero2prod
|
||||||
|
RUN npm install && npm run build-css
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/cc-debian12 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/zero2prod zero2prod
|
||||||
|
COPY --from=builder /app/assets assets
|
||||||
|
COPY --from=builder /app/configuration configuration
|
||||||
|
ENV APP_ENVIRONMENT=production
|
||||||
|
ENTRYPOINT [ "./zero2prod" ]
|
||||||
22
README.md
Normal file
22
README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# zero2prod
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt install postgresql-client
|
||||||
|
sudo apt install pkg-config
|
||||||
|
sudo apt install libssl-dev
|
||||||
|
cargo install sqlx-cli --no-default-features --features rustls,postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [axum](https://docs.rs/axum/latest/axum/) + [examples](https://github.com/tokio-rs/axum/tree/main/examples)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com)
|
||||||
|
- [htmx](https://htmx.org)
|
||||||
|
- [Rust](https://rust-lang.org)
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
- [Book repository](https://github.com/LukeMathWalker/zero-to-production)
|
||||||
|
- [Gitea](https://gitea.alphonsepaix.xyz/alphonse/zero2prod.git)
|
||||||
2
assets/css/main.css
Normal file
2
assets/css/main.css
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 874 B |
1
assets/js/htmx.min.js
vendored
Normal file
1
assets/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
configuration/base.yaml
Normal file
4
configuration/base.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
email_client:
|
||||||
|
timeout_milliseconds: 10000
|
||||||
|
base_url: "https://api.alphonsepaix.xyz"
|
||||||
|
sender_email: "newsletter@alphonsepaix.xyz"
|
||||||
17
configuration/local.yaml
Normal file
17
configuration/local.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
application:
|
||||||
|
port: 8080
|
||||||
|
host: "127.0.0.1"
|
||||||
|
base_url: "http://127.0.0.1:8080"
|
||||||
|
email_client:
|
||||||
|
authorization_token: "secret-token"
|
||||||
|
database:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 5432
|
||||||
|
database_name: "newsletter"
|
||||||
|
username: "postgres"
|
||||||
|
password: "password"
|
||||||
|
require_ssl: false
|
||||||
|
timeout_milliseconds: 1000
|
||||||
|
kv_store:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
4
configuration/production.yaml
Normal file
4
configuration/production.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
application:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
database:
|
||||||
|
timeout_milliseconds: 500
|
||||||
7
migrations/20250821091459_create_subscriptions_table.sql
Normal file
7
migrations/20250821091459_create_subscriptions_table.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE subscriptions (
|
||||||
|
id UUID NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
subscribed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
BEGIN;
|
||||||
|
UPDATE subscriptions SET status = 'confirmed' WHERE status IS NULL;
|
||||||
|
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE subscription_tokens (
|
||||||
|
subscription_token TEXT NOT NULL,
|
||||||
|
subscriber_id UUID NOT NULL REFERENCES subscriptions (id),
|
||||||
|
PRIMARY KEY (subscription_token)
|
||||||
|
);
|
||||||
5
migrations/20250828142613_create_users_table.sql
Normal file
5
migrations/20250828142613_create_users_table.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
user_id UUID PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
1
migrations/20250828161700_rename_password_column.sql
Normal file
1
migrations/20250828161700_rename_password_column.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users RENAME password TO password_hash;
|
||||||
1
migrations/20250828204455_add_salt_to_users.sql
Normal file
1
migrations/20250828204455_add_salt_to_users.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;
|
||||||
1
migrations/20250828212543_remove_salt_from_users.sql
Normal file
1
migrations/20250828212543_remove_salt_from_users.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN salt;
|
||||||
6
migrations/20250831121659_seed_user.sql
Normal file
6
migrations/20250831121659_seed_user.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO users (user_id, username, password_hash)
|
||||||
|
VALUES (
|
||||||
|
'd2492680-6e45-4179-b369-1439b8f22051',
|
||||||
|
'admin',
|
||||||
|
'$argon2id$v=19$m=19456,t=2,p=1$oWy180x7KxJYiTHzoN3sVw$vTgzvEqACiXjGalYUJHgb329Eb+s6wu5r+Cw8dHR5YE'
|
||||||
|
);
|
||||||
14
migrations/20250901135528_create_idempotency_table.sql
Normal file
14
migrations/20250901135528_create_idempotency_table.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TYPE header_pair AS (
|
||||||
|
name TEXT,
|
||||||
|
value BYTEA
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE idempotency (
|
||||||
|
user_id UUID NOT NULL REFERENCES users (user_id),
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
response_status_code SMALLINT NOT NULL,
|
||||||
|
response_headers header_pair[] NOT NULL,
|
||||||
|
response_body BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, idempotency_key)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE idempotency ALTER COLUMN response_status_code DROP NOT NULL;
|
||||||
|
ALTER TABLE idempotency ALTER COLUMN response_body DROP NOT NULL;
|
||||||
|
ALTER TABLE idempotency ALTER COLUMN response_headers DROP NOT NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE newsletter_issues (
|
||||||
|
newsletter_issue_id UUID NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
text_content TEXT NOT NULL,
|
||||||
|
html_content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (newsletter_issue_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE issue_delivery_queue (
|
||||||
|
newsletter_issue_id UUID NOT NULL
|
||||||
|
REFERENCES newsletter_issues (newsletter_issue_id),
|
||||||
|
subscriber_email TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (newsletter_issue_id, subscriber_email)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE subscriptions DROP COLUMN name;
|
||||||
7
migrations/20250918120924_create_posts_table.sql
Normal file
7
migrations/20250918120924_create_posts_table.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE posts (
|
||||||
|
post_id UUID PRIMARY KEY,
|
||||||
|
author_id UUID NOT NULL REFERENCES users (user_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE subscriptions ADD COLUMN unsubscribe_token TEXT UNIQUE;
|
||||||
|
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET unsubscribe_token = left(md5(random()::text), 25)
|
||||||
|
WHERE status = 'confirmed' AND unsubscribe_token IS NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
DROP CONSTRAINT subscription_tokens_subscriber_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
ADD CONSTRAINT subscription_tokens_subscriber_id_fkey
|
||||||
|
FOREIGN KEY (subscriber_id)
|
||||||
|
REFERENCES subscriptions (id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE issue_delivery_queue ADD COLUMN unsubscribe_token TEXT NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE issue_delivery_queue ADD COLUMN kind TEXT NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE notifications_delivered (
|
||||||
|
email_id UUID PRIMARY KEY,
|
||||||
|
newsletter_issue_id UUID NOT NULL
|
||||||
|
REFERENCES newsletter_issues (newsletter_issue_id),
|
||||||
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
opened BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
7
migrations/20250930145931_add_role_to_users.sql
Normal file
7
migrations/20250930145931_add_role_to_users.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TYPE user_role AS ENUM ('admin', 'writer');
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN role user_role;
|
||||||
|
|
||||||
|
UPDATE users SET role = 'admin' WHERE role IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
|
||||||
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN full_name TEXT,
|
||||||
|
ADD COLUMN bio TEXT,
|
||||||
|
ADD COLUMN member_since TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
7
migrations/20251001165158_create_comments_table.sql
Normal file
7
migrations/20251001165158_create_comments_table.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE comments (
|
||||||
|
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts (post_id) ON DELETE CASCADE,
|
||||||
|
author TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
1188
package-lock.json
generated
Normal file
1188
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build-css": "tailwindcss -i ./templates/input.css -o ./assets/css/main.css --minify",
|
||||||
|
"watch-css": "tailwindcss -i ./templates/input.css -o ./assets/css/main.css --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.13",
|
||||||
|
"tailwindcss": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
scripts/init_db.sh
Executable file
45
scripts/init_db.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -x
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v psql)" ]; then
|
||||||
|
echo >&2 "Error: psql is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v sqlx)" ]; then
|
||||||
|
echo >&2 "Error: sqlx is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_USER="${POSTGRES_USER:=postgres}"
|
||||||
|
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
|
||||||
|
DB_NAME="${POSTGRES_DB:=newsletter}"
|
||||||
|
DB_PORT="${POSTGRES_PORT:=5432}"
|
||||||
|
DB_HOST="${POSTGRES_HOST:=localhost}"
|
||||||
|
|
||||||
|
if [[ -z "${SKIP_DOCKER}" ]]; then
|
||||||
|
docker run \
|
||||||
|
-e POSTGRES_USER=${DB_USER} \
|
||||||
|
-e POSTGRES_PASSWORD=${DB_PASSWORD} \
|
||||||
|
-e POSTGRES_DB=${DB_NAME} \
|
||||||
|
-p "127.0.0.1:${DB_PORT}":5432 \
|
||||||
|
-d postgres \
|
||||||
|
postgres -N 1000
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PGPASSWORD="${DB_PASSWORD}"
|
||||||
|
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
|
||||||
|
>&2 echo "Postgres is still unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
>&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!"
|
||||||
|
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
export DATABASE_URL
|
||||||
|
sqlx database create
|
||||||
|
sqlx migrate run
|
||||||
|
|
||||||
|
>&2 echo "Postgres has been migrated, ready to go!"
|
||||||
18
scripts/init_redis.sh
Executable file
18
scripts/init_redis.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -x
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}')
|
||||||
|
if [[ -n $RUNNING_CONTAINER ]]; then
|
||||||
|
echo >&2 "A redis container is already running (${RUNNING_CONTAINER})."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run \
|
||||||
|
-p "127.0.0.1:6379:6379" \
|
||||||
|
-d \
|
||||||
|
--name "redis_$(date '+%s')" \
|
||||||
|
redis
|
||||||
|
|
||||||
|
>&2 echo "Redis is ready to go!"
|
||||||
162
src/authentication.rs
Normal file
162
src/authentication.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use crate::telemetry::spawn_blocking_with_tracing;
|
||||||
|
use anyhow::Context;
|
||||||
|
use argon2::{
|
||||||
|
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::fmt::Display;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct Credentials {
|
||||||
|
pub username: String,
|
||||||
|
pub password: SecretString,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error(transparent)]
|
||||||
|
UnexpectedError(#[from] anyhow::Error),
|
||||||
|
#[error("Invalid credentials.")]
|
||||||
|
InvalidCredentials(#[source] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Change password", skip(password, connection_pool))]
|
||||||
|
pub async fn change_password(
|
||||||
|
user_id: Uuid,
|
||||||
|
password: SecretString,
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password))
|
||||||
|
.await?
|
||||||
|
.context("Failed to hash password.")?;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET password_hash = $1 WHERE user_id = $2",
|
||||||
|
password_hash.expose_secret(),
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to update user password in the database.")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::Error> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let password_hash = Argon2::new(
|
||||||
|
Algorithm::Argon2id,
|
||||||
|
Version::V0x13,
|
||||||
|
Params::new(1500, 2, 1, None).unwrap(),
|
||||||
|
)
|
||||||
|
.hash_password(password.expose_secret().as_bytes(), &salt)?
|
||||||
|
.to_string();
|
||||||
|
Ok(SecretString::from(password_hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Validate credentials", skip_all)]
|
||||||
|
pub async fn validate_credentials(
|
||||||
|
Credentials { username, password }: Credentials,
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
) -> Result<(Uuid, Role), AuthError> {
|
||||||
|
let mut user_id = None;
|
||||||
|
let mut role = None;
|
||||||
|
let mut expected_password_hash = SecretString::from(
|
||||||
|
"$argon2id$v=19$m=15000,t=2,p=1$\
|
||||||
|
gZiV/M1gPc22ElAH/Jh1Hw$\
|
||||||
|
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some((stored_user_id, stored_expected_password_hash, stored_role)) =
|
||||||
|
get_stored_credentials(&username, connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to retrieve credentials from database.")
|
||||||
|
.map_err(AuthError::UnexpectedError)?
|
||||||
|
{
|
||||||
|
user_id = Some(stored_user_id);
|
||||||
|
role = Some(stored_role);
|
||||||
|
expected_password_hash = stored_expected_password_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle =
|
||||||
|
spawn_blocking_with_tracing(|| verify_password_hash(expected_password_hash, password));
|
||||||
|
|
||||||
|
let uuid = user_id
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
|
||||||
|
.map_err(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
let role = role
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Unknown role."))
|
||||||
|
.map_err(AuthError::UnexpectedError)?;
|
||||||
|
|
||||||
|
handle
|
||||||
|
.await
|
||||||
|
.context("Failed to spawn blocking task.")
|
||||||
|
.map_err(AuthError::UnexpectedError)?
|
||||||
|
.map_err(AuthError::InvalidCredentials)
|
||||||
|
.map(|_| (uuid, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Verify password", skip_all)]
|
||||||
|
fn verify_password_hash(
|
||||||
|
expected_password_hash: SecretString,
|
||||||
|
password_candidate: SecretString,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
|
||||||
|
.context("Failed to parse hash in PHC string format.")?;
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(
|
||||||
|
password_candidate.expose_secret().as_bytes(),
|
||||||
|
&expected_password_hash,
|
||||||
|
)
|
||||||
|
.context("Password verification failed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Get stored credentials", skip(connection_pool))]
|
||||||
|
async fn get_stored_credentials(
|
||||||
|
username: &str,
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
) -> Result<Option<(Uuid, SecretString, Role)>, sqlx::Error> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, password_hash, role as "role: Role"
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
.fetch_optional(connection_pool)
|
||||||
|
.await?
|
||||||
|
.map(|row| (row.user_id, SecretString::from(row.password_hash), row.role));
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
Admin,
|
||||||
|
Writer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Role {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Role::Admin => write!(f, "admin"),
|
||||||
|
Role::Writer => write!(f, "writer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthenticatedUser {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticatedUser {
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
matches!(self.role, Role::Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/configuration.rs
Normal file
167
src/configuration.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use crate::domain::SubscriberEmail;
|
||||||
|
use anyhow::Context;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_aux::field_attributes::deserialize_number_from_string;
|
||||||
|
use sqlx::postgres::{PgConnectOptions, PgSslMode};
|
||||||
|
use tower_sessions_redis_store::{
|
||||||
|
RedisStore,
|
||||||
|
fred::prelude::{ClientLike, Pool},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
|
||||||
|
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
|
||||||
|
let config_dir = base_path.join("configuration");
|
||||||
|
let environment: Environment = std::env::var("APP_ENVIRONMENT")
|
||||||
|
.unwrap_or_else(|_| "local".into())
|
||||||
|
.try_into()
|
||||||
|
.expect("Failed to parse APP_ENVIRONMENT");
|
||||||
|
let environment_filename = format!("{}.yaml", environment.as_str());
|
||||||
|
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::File::from(config_dir.join("base.yaml")))
|
||||||
|
.add_source(config::File::from(config_dir.join(environment_filename)))
|
||||||
|
.add_source(
|
||||||
|
config::Environment::with_prefix("APP")
|
||||||
|
.prefix_separator("_")
|
||||||
|
.separator("__"),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
settings.try_deserialize::<Settings>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Environment {
|
||||||
|
Local,
|
||||||
|
Production,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Environment {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Environment::Local => "local",
|
||||||
|
Environment::Production => "production",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
match value.to_lowercase().as_str() {
|
||||||
|
"local" => Ok(Environment::Local),
|
||||||
|
"production" => Ok(Environment::Production),
|
||||||
|
other => Err(format!(
|
||||||
|
"{} is not a supported environment. Use either `local` or `production`.",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub application: ApplicationSettings,
|
||||||
|
pub database: DatabaseSettings,
|
||||||
|
pub email_client: EmailClientSettings,
|
||||||
|
pub kv_store: RedisSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub struct ApplicationSettings {
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub port: u16,
|
||||||
|
pub host: String,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub struct EmailClientSettings {
|
||||||
|
pub base_url: String,
|
||||||
|
sender_email: String,
|
||||||
|
pub authorization_token: SecretString,
|
||||||
|
pub timeout_milliseconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailClientSettings {
|
||||||
|
pub fn sender(&self) -> Result<SubscriberEmail, anyhow::Error> {
|
||||||
|
SubscriberEmail::parse(self.sender_email.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
base_url: String,
|
||||||
|
sender_email: String,
|
||||||
|
authorization_token: String,
|
||||||
|
timeout_milliseconds: u64,
|
||||||
|
) -> Self {
|
||||||
|
let authorization_token = SecretString::from(authorization_token);
|
||||||
|
Self {
|
||||||
|
base_url,
|
||||||
|
sender_email,
|
||||||
|
authorization_token,
|
||||||
|
timeout_milliseconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub struct RedisSettings {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisSettings {
|
||||||
|
pub fn connection_string(&self) -> String {
|
||||||
|
format!("redis://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn session_store(&self) -> Result<RedisStore<Pool>, anyhow::Error> {
|
||||||
|
let pool = Pool::new(
|
||||||
|
tower_sessions_redis_store::fred::prelude::Config::from_url(&self.connection_string())
|
||||||
|
.context("Failed to parse Redis URL string.")?,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
pool.connect();
|
||||||
|
pool.wait_for_connect()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to the Redis server.")?;
|
||||||
|
Ok(RedisStore::new(pool))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub struct DatabaseSettings {
|
||||||
|
pub username: String,
|
||||||
|
pub password: SecretString,
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub port: u16,
|
||||||
|
pub host: String,
|
||||||
|
pub database_name: String,
|
||||||
|
pub require_ssl: bool,
|
||||||
|
pub timeout_milliseconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseSettings {
|
||||||
|
pub fn with_db(&self) -> PgConnectOptions {
|
||||||
|
self.without_db().database(&self.database_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn without_db(&self) -> PgConnectOptions {
|
||||||
|
let ssl_mode = if self.require_ssl {
|
||||||
|
PgSslMode::Require
|
||||||
|
} else {
|
||||||
|
PgSslMode::Prefer
|
||||||
|
};
|
||||||
|
PgConnectOptions::new()
|
||||||
|
.host(&self.host)
|
||||||
|
.username(&self.username)
|
||||||
|
.password(self.password.expose_secret())
|
||||||
|
.port(self.port)
|
||||||
|
.ssl_mode(ssl_mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
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(())
|
||||||
|
}
|
||||||
13
src/domain.rs
Normal file
13
src/domain.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
mod comment;
|
||||||
|
mod new_subscriber;
|
||||||
|
mod post;
|
||||||
|
mod subscriber_email;
|
||||||
|
mod subscribers;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use comment::CommentEntry;
|
||||||
|
pub use new_subscriber::NewSubscriber;
|
||||||
|
pub use post::PostEntry;
|
||||||
|
pub use subscriber_email::SubscriberEmail;
|
||||||
|
pub use subscribers::SubscriberEntry;
|
||||||
|
pub use user::UserEntry;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user