From a2ccec4bb13d99bf38fa0ef876933ba5818f06d0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 17 Jun 2026 10:59:45 +0200 Subject: [PATCH] init --- backend/Cargo.lock | 669 ++++++++++++++++++-- backend/Cargo.toml | 7 +- backend/migrations/0002_lists.sql | 53 ++ backend/migrations/0003_tracking.sql | 25 + backend/migrations/0004_notify.sql | 6 + backend/src/auth/password.rs | 4 +- backend/src/auth/routes.rs | 30 +- backend/src/auth/tokens.rs | 4 +- backend/src/config.rs | 9 + backend/src/fetch/mod.rs | 39 ++ backend/src/fetch/shopify.rs | 185 ++++++ backend/src/mail/mod.rs | 6 +- backend/src/main.rs | 6 + backend/src/models/mod.rs | 60 ++ backend/src/notify.rs | 143 +++++ backend/src/routes/lists.rs | 356 +++++++++++ backend/src/routes/mod.rs | 13 +- backend/src/state.rs | 2 + backend/src/worker.rs | 130 ++++ frontend/src/app.css | 254 +++++--- frontend/src/app.html | 4 +- frontend/src/lib/api.ts | 21 +- frontend/src/lib/auth.svelte.ts | 6 +- frontend/src/lib/lists.svelte.ts | 101 +++ frontend/src/routes/+layout.svelte | 25 +- frontend/src/routes/+page.svelte | 59 +- frontend/src/routes/forgot/+page.svelte | 8 +- frontend/src/routes/lists/+page.svelte | 125 ++++ frontend/src/routes/lists/[id]/+page.svelte | 336 ++++++++++ frontend/src/routes/login/+page.svelte | 6 +- frontend/src/routes/register/+page.svelte | 8 +- frontend/src/routes/reset/+page.svelte | 10 +- frontend/src/routes/settings/+page.svelte | 39 +- frontend/src/routes/verify/+page.svelte | 6 +- frontend/static/favicon.svg | 16 +- 35 files changed, 2514 insertions(+), 257 deletions(-) create mode 100644 backend/migrations/0002_lists.sql create mode 100644 backend/migrations/0003_tracking.sql create mode 100644 backend/migrations/0004_notify.sql create mode 100644 backend/src/fetch/mod.rs create mode 100644 backend/src/fetch/shopify.rs create mode 100644 backend/src/notify.rs create mode 100644 backend/src/routes/lists.rs create mode 100644 backend/src/worker.rs create mode 100644 frontend/src/lib/lists.svelte.ts create mode 100644 frontend/src/routes/lists/+page.svelte create mode 100644 frontend/src/routes/lists/[id]/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 45659ce..581aa64 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -35,6 +52,24 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -43,7 +78,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -128,7 +163,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -152,6 +187,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -170,12 +217,58 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -204,6 +297,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -254,6 +370,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -300,7 +425,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -311,7 +436,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -354,7 +479,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -438,6 +563,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -470,6 +605,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -536,7 +677,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -584,8 +725,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -596,11 +753,20 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -740,6 +906,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -748,13 +931,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -884,6 +1075,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -1000,6 +1197,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1037,6 +1240,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1077,7 +1290,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1160,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1249,7 +1462,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", ] [[package]] @@ -1271,7 +1493,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1283,6 +1505,81 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1298,12 +1595,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.6" @@ -1311,8 +1620,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1322,7 +1641,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1334,6 +1663,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1381,6 +1719,53 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -1395,6 +1780,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmp" version = "0.8.15" @@ -1427,13 +1841,36 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustls" version = "0.23.40" @@ -1455,6 +1892,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1487,6 +1925,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "semver" version = "1.0.28" @@ -1520,7 +1964,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1606,7 +2050,9 @@ dependencies = [ "dotenvy", "hex", "lettre", - "rand", + "rand 0.8.6", + "reqwest", + "rust_decimal", "serde", "serde_json", "sha2", @@ -1620,6 +2066,7 @@ dependencies = [ "tower-sessions-sqlx-store", "tracing", "tracing-subscriber", + "url", "uuid", "validator", ] @@ -1641,9 +2088,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1724,6 +2183,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rust_decimal", "rustls", "serde", "serde_json", @@ -1749,7 +2209,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.118", ] [[package]] @@ -1772,7 +2232,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.118", "tokio", "url", ] @@ -1806,8 +2266,9 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", + "rust_decimal", "serde", "sha1", "sha2", @@ -1846,7 +2307,8 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", + "rust_decimal", "serde", "serde_json", "sha2", @@ -1915,6 +2377,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.118" @@ -1931,6 +2404,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1940,9 +2416,15 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "thiserror" version = "1.0.69" @@ -1969,7 +2451,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1980,7 +2462,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2072,7 +2554,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2096,6 +2578,49 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -2134,14 +2659,22 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", + "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -2186,7 +2719,7 @@ dependencies = [ "futures", "http", "parking_lot", - "rand", + "rand 0.8.6", "serde", "serde_json", "thiserror 2.0.18", @@ -2241,7 +2774,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2283,6 +2816,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2385,7 +2924,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2406,6 +2945,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2445,10 +2993,21 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.125" @@ -2468,7 +3027,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2515,6 +3074,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2697,6 +3276,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2733,7 +3321,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.118", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2749,7 +3337,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2797,6 +3385,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2816,7 +3413,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2837,7 +3434,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2857,7 +3454,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2897,7 +3494,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 75d6965..971c325 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,7 +12,7 @@ tower-sessions = "0.14" tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } sqlx = { version = "0.8", default-features = false, features = [ - "runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros", + "runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros", "rust_decimal", ] } serde = { version = "1", features = ["derive"] } @@ -32,6 +32,9 @@ thiserror = "2" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -time = { version = "0.3", features = ["serde"] } +time = { version = "0.3", features = ["serde", "serde-well-known"] } uuid = { version = "1", features = ["v4", "serde"] } +rust_decimal = { version = "1", features = ["serde-float"] } dotenvy = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } +url = "2" diff --git a/backend/migrations/0002_lists.sql b/backend/migrations/0002_lists.sql new file mode 100644 index 0000000..993393a --- /dev/null +++ b/backend/migrations/0002_lists.sql @@ -0,0 +1,53 @@ +-- Phase 2: topic-based wantlists + items +-- A "list" is a topic (clothes, gear, …). An "item" is a thing the user covets, +-- usually backed by a pasted product URL. Price/metadata columns are filled by the +-- Phase 3 refetch worker (generic Shopify .json adapter, etc.) and stay NULL until then. + +CREATE TABLE lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + emoji TEXT, -- optional decorative glyph + description TEXT, + position INTEGER NOT NULL DEFAULT 0, -- manual ordering + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_lists_user ON lists(user_id); + +CREATE TYPE item_status AS ENUM ('coveted', 'acquired', 'renounced'); + +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT, -- pasted product URL (Phase 3 tracks this) + note TEXT, + status item_status NOT NULL DEFAULT 'coveted', + target_price NUMERIC(12, 2), -- alert threshold the user sets + position INTEGER NOT NULL DEFAULT 0, + + -- Filled by the Phase 3 fetcher; NULL until first successful fetch. + title_fetched TEXT, + current_price NUMERIC(12, 2), + currency TEXT, -- ISO 4217, e.g. 'EUR' + image_url TEXT, + in_stock BOOLEAN, + source TEXT, -- adapter that produced the data, e.g. 'shopify' + fetched_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_items_list ON items(list_id); +CREATE INDEX idx_items_url ON items(url) WHERE url IS NOT NULL; + +CREATE TRIGGER trg_lists_updated + BEFORE UPDATE ON lists + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_items_updated + BEFORE UPDATE ON items + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/migrations/0003_tracking.sql b/backend/migrations/0003_tracking.sql new file mode 100644 index 0000000..92beb23 --- /dev/null +++ b/backend/migrations/0003_tracking.sql @@ -0,0 +1,25 @@ +-- Phase 3: price tracking. The refetch worker pulls product data for items that +-- carry a URL (generic Shopify .json adapter first), updates the item's metadata +-- columns, and appends a row to price_history on every successful fetch. + +-- Per-item tracking control + last fetch outcome. +ALTER TABLE items + ADD COLUMN track_enabled BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN last_error TEXT, + ADD COLUMN checked_at TIMESTAMPTZ; -- last fetch attempt (success or failure) + +-- Append-only price observations. One row per successful fetch. +CREATE TABLE price_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + price NUMERIC(12, 2) NOT NULL, + currency TEXT NOT NULL, + in_stock BOOLEAN, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_price_history_item ON price_history(item_id, fetched_at DESC); + +-- Worker scan: trackable items that have a URL, cheapest checked first. +CREATE INDEX idx_items_trackable ON items(checked_at NULLS FIRST) + WHERE url IS NOT NULL AND track_enabled; diff --git a/backend/migrations/0004_notify.sql b/backend/migrations/0004_notify.sql new file mode 100644 index 0000000..83138bc --- /dev/null +++ b/backend/migrations/0004_notify.sql @@ -0,0 +1,6 @@ +-- Phase 4: price-drop notifications. +-- Tracks when we last emailed the owner that an item reached its target price. +-- NULL = "armed": a future drop to/under target will notify. Stamped non-NULL +-- after sending; cleared (re-armed) when the price rises back above target. +ALTER TABLE items + ADD COLUMN notified_at TIMESTAMPTZ; diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 481a112..027e7e1 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -1,4 +1,6 @@ -use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, +}; use argon2::Argon2; use crate::error::{AppError, AppResult}; diff --git a/backend/src/auth/routes.rs b/backend/src/auth/routes.rs index 8b26a4a..0ffbde6 100644 --- a/backend/src/auth/routes.rs +++ b/backend/src/auth/routes.rs @@ -91,7 +91,11 @@ async fn register( validate(&req)?; let hash = hash_password(&req.password)?; - let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let display = req + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); let user = sqlx::query_as::<_, User>( "INSERT INTO users (email, password_hash, display_name) @@ -190,9 +194,13 @@ async fn request_password_reset( .fetch_optional(&state.pool) .await? { - let token = - tokens::create(&state.pool, user.id, TokenPurpose::PasswordReset, Duration::hours(1)) - .await?; + let token = tokens::create( + &state.pool, + user.id, + TokenPurpose::PasswordReset, + Duration::hours(1), + ) + .await?; let link = format!("{}/reset?token={}", state.config.public_app_url, token); let _ = state .mailer @@ -225,10 +233,7 @@ async fn reset_password( Ok(StatusCode::NO_CONTENT) } -async fn me( - State(state): State, - AuthUser(user): AuthUser, -) -> AppResult> { +async fn me(State(state): State, AuthUser(user): AuthUser) -> AppResult> { let settings = sqlx::query_as::<_, UserSettings>( "SELECT user_id, locale, currency, theme, notify_email FROM user_settings WHERE user_id = $1", @@ -246,8 +251,13 @@ async fn me( // ── Helpers ───────────────────────────────────────────────── async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> { - let token = - tokens::create(&state.pool, user.id, TokenPurpose::VerifyEmail, Duration::hours(24)).await?; + let token = tokens::create( + &state.pool, + user.id, + TokenPurpose::VerifyEmail, + Duration::hours(24), + ) + .await?; let link = format!("{}/verify?token={}", state.config.public_app_url, token); state .mailer diff --git a/backend/src/auth/tokens.rs b/backend/src/auth/tokens.rs index 3060954..74797df 100644 --- a/backend/src/auth/tokens.rs +++ b/backend/src/auth/tokens.rs @@ -72,7 +72,5 @@ pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResu .fetch_optional(pool) .await?; - user_id.ok_or(AppError::BadRequest( - "invalid or expired token".to_string(), - )) + user_id.ok_or(AppError::BadRequest("invalid or expired token".to_string())) } diff --git a/backend/src/config.rs b/backend/src/config.rs index 5b9b746..c23c7b5 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -10,6 +10,12 @@ pub struct Config { pub public_app_url: String, pub cors_origins: Vec, pub smtp: SmtpConfig, + /// Background refetch worker tick. 0 disables the worker. + pub refetch_interval_secs: u64, + /// Min age before an item is eligible for the next automatic refetch. + pub refetch_min_age_secs: i64, + /// Default ISO 4217 currency when an adapter can't determine one. + pub default_currency: String, } #[derive(Clone, Debug)] @@ -58,6 +64,9 @@ impl Config { session_secret, public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"), cors_origins, + refetch_interval_secs: opt("REFETCH_INTERVAL_SECS", "300").parse()?, + refetch_min_age_secs: opt("REFETCH_MIN_AGE_SECS", "21600").parse()?, + default_currency: opt("DEFAULT_CURRENCY", "EUR").to_uppercase(), smtp: SmtpConfig { host: opt("SMTP_HOST", "localhost"), port: opt("SMTP_PORT", "587").parse()?, diff --git a/backend/src/fetch/mod.rs b/backend/src/fetch/mod.rs new file mode 100644 index 0000000..a41bd98 --- /dev/null +++ b/backend/src/fetch/mod.rs @@ -0,0 +1,39 @@ +//! Product data adapters. The deal source is a user-pasted product URL — no +//! per-retailer scrapers. Adapters are generic platform readers; the first is +//! Shopify, whose storefronts expose a public `/products/{handle}.json` document. + +use rust_decimal::Decimal; + +mod shopify; + +/// Normalised product snapshot produced by an adapter. +#[derive(Debug, Clone)] +pub struct FetchedProduct { + pub title: String, + pub price: Decimal, + pub currency: String, + pub image_url: Option, + pub in_stock: Option, + pub source: &'static str, +} + +/// A shared HTTP client tuned for storefront fetches. +pub fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent("consumers-bot/0.1 (+self-hosted wantlist price watcher)") + .timeout(std::time::Duration::from_secs(15)) + .build() + .expect("failed to build reqwest client") +} + +/// Try every adapter in turn. Returns the first that recognises the URL. +pub async fn fetch_product( + client: &reqwest::Client, + url: &str, + default_currency: &str, +) -> anyhow::Result { + if let Some(p) = shopify::fetch(client, url, default_currency).await? { + return Ok(p); + } + anyhow::bail!("no adapter could read this URL (only Shopify storefronts are supported for now)") +} diff --git a/backend/src/fetch/shopify.rs b/backend/src/fetch/shopify.rs new file mode 100644 index 0000000..c1c73d8 --- /dev/null +++ b/backend/src/fetch/shopify.rs @@ -0,0 +1,185 @@ +//! Generic Shopify storefront adapter. +//! +//! Every Shopify shop exposes an Ajax product document at +//! `https://{shop}/products/{handle}.js`, which carries title, image, per-variant +//! price (integer minor units) and availability. We derive that URL from the +//! pasted product URL (any path with a `/products/{handle}` segment). No shop is +//! hardcoded. +//! +//! Pricing is the subtle part. The `.js` doc reports the shop's *base* currency +//! price. Shops using Shopify Markets show visitors a converted *presentment* +//! price (e.g. a PLN shop shows EUR in the EU). That conversion is reachable +//! generically via `.js?currency=EUR`. So we fetch both and: +//! - if the converted price differs from the base price → Markets converted it, +//! and the price is genuinely in our requested currency; +//! - if they're equal → no conversion happened, so the price is in the shop's +//! base currency (read from `/meta.json`), not our requested one. +//! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN. + +use rust_decimal::Decimal; +use serde::Deserialize; +use url::{Position, Url}; + +use super::FetchedProduct; + +#[derive(Deserialize)] +struct JsDoc { + #[serde(default)] + title: Option, + #[serde(default)] + featured_image: Option, + #[serde(default)] + variants: Vec, +} + +#[derive(Deserialize)] +struct JsVariant { + /// Price in the currency's minor units (cents), e.g. 19795 = 197.95. + price: i64, + #[serde(default)] + available: Option, +} + +/// Returns `Ok(None)` when the URL isn't a Shopify product URL (so other +/// adapters could try), `Err` when it looks like one but the fetch/parse fails. +pub async fn fetch( + client: &reqwest::Client, + raw_url: &str, + default_currency: &str, +) -> anyhow::Result> { + let Some(base_url) = product_doc_url(raw_url, "js") else { + return Ok(None); + }; + + // Presentment price in the requested currency (Markets-converted if enabled). + let conv_url = format!("{base_url}?currency={default_currency}"); + let Some(conv) = fetch_js(client, &conv_url).await? else { + return Ok(None); + }; + + let Some(conv_cents) = cheapest(&conv.variants) else { + anyhow::bail!("Shopify product has no readable price"); + }; + + // Base price (no currency param) to detect whether conversion happened. + let base = fetch_js(client, &base_url).await?; + let base_cents = base.as_ref().and_then(|b| cheapest(&b.variants)); + + let (cents, currency) = match base_cents { + // Converted: value really is in the requested currency. + Some(b) if b != conv_cents => (conv_cents, default_currency.to_string()), + // No conversion: value is the shop's base currency. + Some(b) => ( + b, + shop_currency(client, raw_url) + .await + .unwrap_or_else(|| default_currency.to_string()), + ), + None => (conv_cents, default_currency.to_string()), + }; + + let in_stock = availability(&conv.variants); + let title = conv + .title + .clone() + .or_else(|| base.as_ref().and_then(|b| b.title.clone())) + .unwrap_or_else(|| "Untitled product".to_string()); + let image_url = conv + .featured_image + .clone() + .or_else(|| base.and_then(|b| b.featured_image)) + .map(normalize_image); + + Ok(Some(FetchedProduct { + title, + price: Decimal::new(cents, 2), + currency, + image_url, + in_stock, + source: "shopify", + })) +} + +/// GET a `.js` product doc. `Ok(None)` if it isn't a Shopify product document. +async fn fetch_js(client: &reqwest::Client, url: &str) -> anyhow::Result> { + let resp = client.get(url).send().await?; + if !resp.status().is_success() { + return Ok(None); + } + let body = resp.text().await?; + Ok(serde_json::from_str::(&body).ok()) +} + +/// Cheapest available variant's price (minor units); falls back to cheapest +/// overall. `None` if there are no priced variants. +fn cheapest(variants: &[JsVariant]) -> Option { + variants + .iter() + .filter(|v| v.available == Some(true)) + .map(|v| v.price) + .min() + .or_else(|| variants.iter().map(|v| v.price).min()) +} + +/// `Some(true/false)` if any variant reported availability, else `None`. +fn availability(variants: &[JsVariant]) -> Option { + if variants.iter().all(|v| v.available.is_none()) { + return None; + } + Some(variants.iter().any(|v| v.available == Some(true))) +} + +/// The shop's base ISO 4217 currency, from `{origin}/meta.json`. Best-effort. +async fn shop_currency(client: &reqwest::Client, raw_url: &str) -> Option { + #[derive(Deserialize)] + struct Meta { + currency: String, + } + let origin = origin_of(raw_url)?; + let resp = client + .get(format!("{origin}/meta.json")) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let meta: Meta = resp.json().await.ok()?; + let c = meta.currency.trim().to_uppercase(); + (c.len() == 3).then_some(c) +} + +/// Shopify image URLs are often protocol-relative (`//cdn.shopify.com/...`). +fn normalize_image(src: String) -> String { + if let Some(rest) = src.strip_prefix("//") { + format!("https://{rest}") + } else { + src + } +} + +fn origin_of(raw: &str) -> Option { + let u = Url::parse(raw).ok()?; + if !matches!(u.scheme(), "http" | "https") { + return None; + } + Some(u[..Position::BeforePath].to_string()) +} + +/// Build `{origin}/products/{handle}.{ext}`, or `None` if there's no +/// `/products/{handle}` segment. +fn product_doc_url(raw: &str, ext: &str) -> Option { + let u = Url::parse(raw).ok()?; + if !matches!(u.scheme(), "http" | "https") { + return None; + } + let segs: Vec<&str> = u.path_segments()?.filter(|s| !s.is_empty()).collect(); + let pos = segs.iter().position(|s| *s == "products")?; + let handle = segs.get(pos + 1)?; + let handle = handle.trim_end_matches(".json").trim_end_matches(".js"); + if handle.is_empty() { + return None; + } + let origin = &u[..Position::BeforePath]; + Some(format!("{origin}/products/{handle}.{ext}")) +} diff --git a/backend/src/mail/mod.rs b/backend/src/mail/mod.rs index aea6f24..27627d7 100644 --- a/backend/src/mail/mod.rs +++ b/backend/src/mail/mod.rs @@ -26,10 +26,8 @@ impl Mailer { .port(cfg.port); if !cfg.username.is_empty() { - builder = builder.credentials(Credentials::new( - cfg.username.clone(), - cfg.password.clone(), - )); + builder = + builder.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone())); } let from: Mailbox = cfg diff --git a/backend/src/main.rs b/backend/src/main.rs index e93ba6a..46bdc30 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,10 +2,13 @@ mod auth; mod config; mod db; mod error; +mod fetch; mod mail; mod models; +mod notify; mod routes; mod state; +mod worker; use std::sync::Arc; @@ -50,8 +53,11 @@ async fn main() -> anyhow::Result<()> { pool, config: Arc::new(config.clone()), mailer, + http: fetch::http_client(), }; + worker::spawn(state.clone()); + let api = Router::new() .merge(routes::router()) .nest("/auth", auth::routes::router()); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 026b5ef..4eae2a7 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,3 +1,4 @@ +use rust_decimal::Decimal; use serde::Serialize; use time::OffsetDateTime; use uuid::Uuid; @@ -43,3 +44,62 @@ pub struct UserSettings { pub theme: String, pub notify_email: bool, } + +/// A topic-based wantlist ("altar"). Scoped to a user. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct List { + pub id: Uuid, + #[serde(skip)] + pub user_id: Uuid, + pub name: String, + pub emoji: Option, + pub description: Option, + pub position: i32, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// A coveted thing inside a list. Price/metadata columns are filled by the +/// Phase 3 refetch worker and stay None until then. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Item { + pub id: Uuid, + pub list_id: Uuid, + pub title: String, + pub url: Option, + pub note: Option, + pub status: String, + pub target_price: Option, + pub position: i32, + + pub title_fetched: Option, + pub current_price: Option, + pub currency: Option, + pub image_url: Option, + pub in_stock: Option, + pub source: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub fetched_at: Option, + + pub track_enabled: bool, + pub last_error: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub checked_at: Option, + + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// One observed price point for an item. Append-only. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct PricePoint { + pub price: Decimal, + pub currency: String, + pub in_stock: Option, + #[serde(with = "time::serde::rfc3339")] + pub fetched_at: OffsetDateTime, +} diff --git a/backend/src/notify.rs b/backend/src/notify.rs new file mode 100644 index 0000000..4889bce --- /dev/null +++ b/backend/src/notify.rs @@ -0,0 +1,143 @@ +//! Phase 4: price-drop notifications. +//! +//! After a successful refetch we check whether an item's watched price has +//! fallen to or below the owner's target. If so — and we haven't already told +//! them about this drop — we email them in the house gospel voice. The +//! `items.notified_at` column is the de-dupe latch: +//! - `NULL` = armed; a drop to/under target fires one email and stamps `now()`. +//! - non-NULL = already announced; stays quiet until the price rises back +//! above target, which clears the latch (re-arms) for the next drop. + +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::state::AppState; + +/// Row gathered for one item's notification decision. +#[derive(sqlx::FromRow)] +struct NotifyRow { + title: String, + title_fetched: Option, + url: Option, + current_price: Option, + target_price: Option, + currency: Option, + in_stock: Option, + notified_at: Option, + email: String, + display_name: Option, + notify_email: bool, +} + +/// Inspect one item after refetch and email its owner if the price just reached +/// the target. Best-effort: never returns an error to the caller (a failed +/// send must not fail the refetch); failures are logged. +pub async fn maybe_notify_drop(state: &AppState, item_id: Uuid) { + if let Err(e) = run(state, item_id).await { + tracing::warn!(item = %item_id, error = %e, "price-drop notification failed"); + } +} + +async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> { + let row: Option = sqlx::query_as( + "SELECT i.title, i.title_fetched, i.url, i.current_price, i.target_price, + i.currency, i.in_stock, i.notified_at, + u.email, u.display_name, s.notify_email + FROM items i + JOIN lists l ON l.id = i.list_id + JOIN users u ON u.id = l.user_id + JOIN user_settings s ON s.user_id = u.id + WHERE i.id = $1", + ) + .bind(item_id) + .fetch_optional(&state.pool) + .await?; + + let Some(row) = row else { return Ok(()) }; + + // Need both a watched price and a target to judge a drop. + let (Some(price), Some(target)) = (row.current_price, row.target_price) else { + return Ok(()); + }; + let on_sale = price <= target; + + match (on_sale, row.notified_at.is_some()) { + // Reached target and not yet announced → email + latch. + (true, false) => { + if row.notify_email { + send(state, &row, price, target).await?; + } + // Latch even if the user has email off, so flipping it on later + // doesn't replay an old drop. Re-arms when price climbs back up. + sqlx::query("UPDATE items SET notified_at = now() WHERE id = $1") + .bind(item_id) + .execute(&state.pool) + .await?; + } + // Price rose back above target → clear the latch (re-arm). + (false, true) => { + sqlx::query("UPDATE items SET notified_at = NULL WHERE id = $1") + .bind(item_id) + .execute(&state.pool) + .await?; + } + _ => {} + } + Ok(()) +} + +async fn send( + state: &AppState, + row: &NotifyRow, + price: Decimal, + target: Decimal, +) -> anyhow::Result<()> { + let name = row.title_fetched.as_deref().unwrap_or(&row.title); + let cur = row.currency.as_deref().unwrap_or("EUR"); + let now = format!("{cur} {price:.2}"); + let goal = format!("{cur} {target:.2}"); + let stock = match row.in_stock { + Some(false) => " (sold out for now — but the sign is given)", + _ => "", + }; + let greeting = match row.display_name.as_deref() { + Some(n) if !n.is_empty() => format!("{n}, "), + _ => String::new(), + }; + let link = row.url.as_deref(); + + let subject = format!("✦ The price has fallen — {name}"); + + let mut text = format!( + "{greeting}your vigil is rewarded.\n\n\ + {name} now asks {now}, at or beneath your target of {goal}{stock}.\n\n\ + The moment is upon you. Consume, and ascend.\n" + ); + if let Some(l) = link { + text.push_str(&format!("\nApproach the shrine: {l}\n")); + } + text.push_str("\n— consume·rs\n"); + + let link_html = link + .map(|l| { + format!("

Approach the shrine ↗

") + }) + .unwrap_or_default(); + let html = format!( + "
\ +

{greeting}your vigil is rewarded.

\ +

{name} now asks \ + {now}, at or beneath your target of {goal}{stock}.

\ +

The moment is upon you. Consume, and ascend.

\ + {link_html}\ +

— consume·rs

\ +
" + ); + + state + .mailer + .send(&row.email, &subject, &text, &html) + .await?; + tracing::info!(to = %row.email, item = %name, "sent price-drop notification"); + Ok(()) +} diff --git a/backend/src/routes/lists.rs b/backend/src/routes/lists.rs new file mode 100644 index 0000000..5a901ab --- /dev/null +++ b/backend/src/routes/lists.rs @@ -0,0 +1,356 @@ +use axum::extract::{Path, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use rust_decimal::Decimal; +use serde::Deserialize; +use uuid::Uuid; +use validator::Validate; + +use crate::auth::session::AuthUser; +use crate::error::{AppError, AppResult}; +use crate::models::{Item, List, PricePoint}; +use crate::state::AppState; +use crate::worker; + +pub fn router() -> Router { + Router::new() + .route("/lists", get(list_lists).post(create_list)) + .route( + "/lists/{id}", + axum::routing::patch(update_list).delete(delete_list), + ) + .route("/lists/{id}/items", get(list_items).post(create_item)) + .route( + "/items/{id}", + axum::routing::patch(update_item).delete(delete_item), + ) + .route("/items/{id}/refetch", post(refetch_item)) + .route("/items/{id}/history", get(item_history)) +} + +pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \ + position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \ + track_enabled, last_error, checked_at, created_at, updated_at"; + +// Same columns, qualified with the `i` alias for use in UPDATE … FROM lists, +// where bare `id`/`position`/`created_at` would be ambiguous across both tables. +const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \ + i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \ + i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \ + i.created_at, i.updated_at"; + +const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"]; + +// ---- Lists ---------------------------------------------------------------- + +async fn list_lists( + State(state): State, + AuthUser(user): AuthUser, +) -> AppResult>> { + let lists = sqlx::query_as::<_, List>( + "SELECT id, user_id, name, emoji, description, position, created_at, updated_at + FROM lists WHERE user_id = $1 ORDER BY position, created_at", + ) + .bind(user.id) + .fetch_all(&state.pool) + .await?; + Ok(Json(lists)) +} + +#[derive(Debug, Deserialize, Validate)] +struct CreateListReq { + #[validate(length(min = 1, max = 80, message = "name must be 1–80 chars"))] + name: String, + #[validate(length(max = 16))] + emoji: Option, + #[validate(length(max = 500))] + description: Option, +} + +async fn create_list( + State(state): State, + AuthUser(user): AuthUser, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let list = sqlx::query_as::<_, List>( + "INSERT INTO lists (user_id, name, emoji, description, position) + VALUES ($1, $2, $3, $4, + COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0)) + RETURNING id, user_id, name, emoji, description, position, created_at, updated_at", + ) + .bind(user.id) + .bind(req.name.trim()) + .bind(opt_trim(req.emoji)) + .bind(opt_trim(req.description)) + .fetch_one(&state.pool) + .await?; + Ok(Json(list)) +} + +#[derive(Debug, Deserialize, Validate)] +struct UpdateListReq { + #[validate(length(min = 1, max = 80, message = "name must be 1–80 chars"))] + name: Option, + #[validate(length(max = 16))] + emoji: Option, + #[validate(length(max = 500))] + description: Option, + position: Option, +} + +async fn update_list( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let list = sqlx::query_as::<_, List>( + "UPDATE lists SET + name = COALESCE($3, name), + emoji = COALESCE($4, emoji), + description = COALESCE($5, description), + position = COALESCE($6, position) + WHERE id = $1 AND user_id = $2 + RETURNING id, user_id, name, emoji, description, position, created_at, updated_at", + ) + .bind(id) + .bind(user.id) + .bind(req.name.map(|s| s.trim().to_string())) + .bind(opt_trim(req.emoji)) + .bind(opt_trim(req.description)) + .bind(req.position) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(list)) +} + +async fn delete_list( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let res = sqlx::query("DELETE FROM lists WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(Json(serde_json::json!({ "deleted": id }))) +} + +// ---- Items ---------------------------------------------------------------- + +/// Confirm the list exists and belongs to the user. Returns NotFound otherwise. +async fn assert_list_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> { + let owns = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)", + ) + .bind(list_id) + .bind(user_id) + .fetch_one(&state.pool) + .await?; + if owns { + Ok(()) + } else { + Err(AppError::NotFound) + } +} + +async fn list_items( + State(state): State, + AuthUser(user): AuthUser, + Path(list_id): Path, +) -> AppResult>> { + assert_list_owner(&state, list_id, user.id).await?; + let items = sqlx::query_as::<_, Item>(&format!( + "SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at" + )) + .bind(list_id) + .fetch_all(&state.pool) + .await?; + Ok(Json(items)) +} + +#[derive(Debug, Deserialize, Validate)] +struct CreateItemReq { + #[validate(length(min = 1, max = 200, message = "title must be 1–200 chars"))] + title: String, + #[validate(url(message = "url must be a valid URL"))] + url: Option, + #[validate(length(max = 1000))] + note: Option, + target_price: Option, +} + +async fn create_item( + State(state): State, + AuthUser(user): AuthUser, + Path(list_id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + assert_list_owner(&state, list_id, user.id).await?; + + let item = sqlx::query_as::<_, Item>(&format!( + "INSERT INTO items (list_id, title, url, note, target_price, position) + VALUES ($1, $2, $3, $4, $5, + COALESCE((SELECT MAX(position) + 1 FROM items WHERE list_id = $1), 0)) + RETURNING {ITEM_COLS}" + )) + .bind(list_id) + .bind(req.title.trim()) + .bind(opt_trim(req.url)) + .bind(opt_trim(req.note)) + .bind(req.target_price) + .fetch_one(&state.pool) + .await?; + Ok(Json(item)) +} + +#[derive(Debug, Deserialize, Validate)] +struct UpdateItemReq { + #[validate(length(min = 1, max = 200, message = "title must be 1–200 chars"))] + title: Option, + #[validate(url(message = "url must be a valid URL"))] + url: Option, + #[validate(length(max = 1000))] + note: Option, + status: Option, + target_price: Option, + position: Option, +} + +async fn update_item( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + if let Some(s) = &req.status { + if !ALLOWED_STATUS.contains(&s.as_str()) { + return Err(AppError::Validation(format!("unknown status: {s}"))); + } + } + + // Ownership enforced via the join to lists.user_id. + let item = sqlx::query_as::<_, Item>(&format!( + "UPDATE items i SET + title = COALESCE($3, i.title), + url = COALESCE($4, i.url), + note = COALESCE($5, i.note), + status = COALESCE($6::item_status, i.status), + target_price = COALESCE($7, i.target_price), + position = COALESCE($8, i.position) + FROM lists l + WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2 + RETURNING {ITEM_COLS_I}" + )) + .bind(id) + .bind(user.id) + .bind(req.title.map(|s| s.trim().to_string())) + .bind(opt_trim(req.url)) + .bind(opt_trim(req.note)) + .bind(req.status) + .bind(req.target_price) + .bind(req.position) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(item)) +} + +async fn delete_item( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let res = sqlx::query( + "DELETE FROM items i USING lists l + WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2", + ) + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(Json(serde_json::json!({ "deleted": id }))) +} + +// ---- Tracking ------------------------------------------------------------- + +/// Owned item's URL, or NotFound. Inner Option is the (nullable) url. +async fn owned_item_url( + state: &AppState, + item_id: Uuid, + user_id: Uuid, +) -> AppResult> { + let row = sqlx::query_as::<_, (Option,)>( + "SELECT i.url FROM items i JOIN lists l ON l.id = i.list_id + WHERE i.id = $1 AND l.user_id = $2", + ) + .bind(item_id) + .bind(user_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(row.0) +} + +/// Refetch a single item's price on demand. Surfaces fetch errors to the user. +async fn refetch_item( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let url = owned_item_url(&state, id, user.id).await?.ok_or_else(|| { + AppError::BadRequest("this temptation has no URL to keep vigil over".into()) + })?; + + worker::refetch(&state, id, &url) + .await + .map_err(|e| AppError::BadRequest(e.to_string()))?; + + let item = sqlx::query_as::<_, Item>(&format!("SELECT {ITEM_COLS} FROM items WHERE id = $1")) + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(Json(item)) +} + +/// Price observations for an item, newest first. +async fn item_history( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult>> { + // Ownership: NotFound if the item isn't the user's. + owned_item_url(&state, id, user.id).await?; + + let history = sqlx::query_as::<_, PricePoint>( + "SELECT price, currency, in_stock, fetched_at + FROM price_history WHERE item_id = $1 + ORDER BY fetched_at DESC LIMIT 200", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + Ok(Json(history)) +} + +fn opt_trim(s: Option) -> Option { + s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b8fcdc8..77e0c7f 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -10,11 +10,14 @@ use crate::error::{AppError, AppResult}; use crate::models::UserSettings; use crate::state::AppState; +mod lists; + pub fn router() -> Router { Router::new() .route("/health", get(health)) .route("/settings", patch(update_settings)) .route("/profile", patch(update_profile)) + .merge(lists::router()) } async fn health() -> Json { @@ -50,7 +53,9 @@ async fn update_settings( } if let Some(cur) = &req.currency { if cur.len() != 3 { - return Err(AppError::Validation("currency must be a 3-letter code".into())); + return Err(AppError::Validation( + "currency must be a 3-letter code".into(), + )); } } @@ -88,7 +93,11 @@ async fn update_profile( req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; - let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let display = req + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1") .bind(user.id) .bind(display) diff --git a/backend/src/state.rs b/backend/src/state.rs index 408f27c..b6a2f74 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -11,4 +11,6 @@ pub struct AppState { pub pool: PgPool, pub config: Arc, pub mailer: Mailer, + /// Shared outbound HTTP client for product fetches. + pub http: reqwest::Client, } diff --git a/backend/src/worker.rs b/backend/src/worker.rs new file mode 100644 index 0000000..d2c424c --- /dev/null +++ b/backend/src/worker.rs @@ -0,0 +1,130 @@ +//! Background price-refetch worker. Periodically pulls product data for +//! trackable items via the generic adapters in [`crate::fetch`], updates each +//! item's metadata columns, and appends a `price_history` row on success. + +use std::time::Duration; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::fetch::{self, FetchedProduct}; +use crate::notify; +use crate::state::AppState; + +const BATCH: i64 = 20; + +/// Spawn the periodic worker. A zero interval disables it. +pub fn spawn(state: AppState) { + let interval = state.config.refetch_interval_secs; + if interval == 0 { + tracing::info!("refetch worker disabled (REFETCH_INTERVAL_SECS=0)"); + return; + } + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(interval)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + tracing::info!(interval_secs = interval, "refetch worker started"); + loop { + ticker.tick().await; + if let Err(e) = run_once(&state).await { + tracing::error!(error = ?e, "refetch tick failed"); + } + } + }); +} + +/// One pass: refetch a batch of due items. +async fn run_once(state: &AppState) -> anyhow::Result<()> { + let due: Vec<(Uuid, String)> = sqlx::query_as( + "SELECT id, url FROM items + WHERE url IS NOT NULL AND track_enabled + AND (checked_at IS NULL OR checked_at < now() - ($1 * interval '1 second')) + ORDER BY checked_at NULLS FIRST + LIMIT $2", + ) + .bind(state.config.refetch_min_age_secs) + .bind(BATCH) + .fetch_all(&state.pool) + .await?; + + if due.is_empty() { + return Ok(()); + } + tracing::debug!(count = due.len(), "refetching due items"); + + for (id, url) in due { + if let Err(e) = refetch(state, id, &url).await { + tracing::warn!(item = %id, error = %e, "item refetch failed"); + } + // Be a polite guest on storefronts. + tokio::time::sleep(Duration::from_millis(500)).await; + } + Ok(()) +} + +/// Fetch one item and persist the outcome. Records `last_error` + `checked_at` +/// on failure (and still returns `Err` so callers can surface it). On success, +/// fires a price-drop notification if the item just reached its target price. +pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Result<()> { + match fetch::fetch_product(&state.http, url, &state.config.default_currency).await { + Ok(p) => { + apply_success(&state.pool, item_id, &p).await?; + notify::maybe_notify_drop(state, item_id).await; + Ok(()) + } + Err(e) => { + let msg = e.to_string(); + apply_failure(&state.pool, item_id, &msg).await?; + Err(e) + } + } +} + +async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query( + "UPDATE items SET + title_fetched = $2, + current_price = $3, + currency = $4, + image_url = COALESCE($5, image_url), + in_stock = $6, + source = $7, + fetched_at = now(), + checked_at = now(), + last_error = NULL + WHERE id = $1", + ) + .bind(item_id) + .bind(&p.title) + .bind(p.price) + .bind(&p.currency) + .bind(p.image_url.as_deref()) + .bind(p.in_stock) + .bind(p.source) + .execute(&mut *tx) + .await?; + + sqlx::query( + "INSERT INTO price_history (item_id, price, currency, in_stock) + VALUES ($1, $2, $3, $4)", + ) + .bind(item_id) + .bind(p.price) + .bind(&p.currency) + .bind(p.in_stock) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +async fn apply_failure(pool: &PgPool, item_id: Uuid, msg: &str) -> anyhow::Result<()> { + sqlx::query("UPDATE items SET checked_at = now(), last_error = $2 WHERE id = $1") + .bind(item_id) + .bind(msg) + .execute(pool) + .await?; + Ok(()) +} diff --git a/frontend/src/app.css b/frontend/src/app.css index ffb01f5..b4f273d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,26 +1,30 @@ -@import 'tailwindcss'; +@import "tailwindcss"; -/* Web fonts loaded via in app.html (terminal + brutalist mono vibe). */ +/* Web fonts loaded via in app.html (ethereal serif + clean sans + mono). */ /* ── Design tokens ─────────────────────────────────────────── */ @theme { - --color-void: #0a0a0b; - --color-ash: #131316; - --color-panel: #17171b; - --color-smoke: #2a2a30; - --color-ink: #e9e7e1; - --color-mute: #8a8a93; + /* Twilight base — dark but dreamy, never pitch black. */ + --color-void: #0c0a14; + --color-ash: #14111f; + --color-panel: #181425; + --color-smoke: #2e2740; + --color-ink: #f3eefb; + --color-mute: #9a90b5; - --color-acid: #c2f73f; /* toxic green */ - --color-blood: #ff1f6b; /* hot magenta */ - --color-cyber: #28e0e0; /* cyan */ - --color-bruise: #7a3cff; /* electric purple */ + /* Ethereal pastels — heaven's clearance sale. */ + --color-iris: #b9a7ff; /* lavender-violet (primary) */ + --color-rose: #ffaecb; /* rose quartz */ + --color-mint: #9af7d8; /* aqua halo */ + --color-gold: #ffe6a3; /* divine gilt */ + --color-holo: #cbd6ff; /* holographic sheen */ - --font-display: 'Space Grotesk', system-ui, sans-serif; - --font-mono: 'Space Mono', ui-monospace, monospace; - --font-term: 'VT323', monospace; + --font-display: "Space Grotesk", system-ui, sans-serif; + --font-gospel: "Fraunces", "Times New Roman", serif; + --font-mono: "Space Mono", ui-monospace, monospace; --radius-none: 0px; + --radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */ } /* ── Base ──────────────────────────────────────────────────── */ @@ -42,38 +46,40 @@ overflow-x: hidden; } - /* Film grain + scanlines layered over everything. */ + /* Soft drifting aurora — celestial light pollution. */ body::before { - content: ''; + content: ""; position: fixed; - inset: 0; + inset: -20%; pointer-events: none; - z-index: 9999; - opacity: 0.05; - background-image: repeating-linear-gradient( - 0deg, - rgba(255, 255, 255, 0.6) 0px, - rgba(255, 255, 255, 0.6) 1px, - transparent 1px, - transparent 3px - ); - mix-blend-mode: overlay; - } - - body::after { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 9998; - opacity: 0.4; + z-index: -1; background: - radial-gradient(circle at 20% 10%, rgba(122, 60, 255, 0.12), transparent 40%), - radial-gradient(circle at 85% 80%, rgba(255, 31, 107, 0.1), transparent 45%); + radial-gradient( + 40% 35% at 18% 12%, + rgba(185, 167, 255, 0.18), + transparent 70% + ), + radial-gradient( + 38% 40% at 85% 20%, + rgba(255, 174, 203, 0.14), + transparent 70% + ), + radial-gradient( + 45% 45% at 70% 88%, + rgba(154, 247, 216, 0.12), + transparent 70% + ), + radial-gradient( + 50% 50% at 30% 80%, + rgba(203, 214, 255, 0.1), + transparent 70% + ); + filter: blur(10px); + animation: drift 26s ease-in-out infinite alternate; } ::selection { - background: var(--color-acid); + background: var(--color-iris); color: var(--color-void); } @@ -85,14 +91,15 @@ } a { - color: var(--color-cyber); + color: var(--color-iris); text-decoration: none; + transition: color 0.12s ease; } a:hover { - color: var(--color-acid); + color: var(--color-rose); } - /* Brutalist scrollbar. */ + /* Soft scrollbar with a pastel edge. */ ::-webkit-scrollbar { width: 10px; } @@ -101,21 +108,29 @@ } ::-webkit-scrollbar-thumb { background: var(--color-smoke); - border: 1px solid var(--color-blood); + border: 1px solid var(--color-iris); } } /* ── Components ────────────────────────────────────────────── */ @layer components { - /* Hard-edged panel with offset shadow — xerox/zine look. */ + /* Panel: glassy twilight slab with a pastel halo glow + faint offset. */ .panel { - background: var(--color-panel); - border: 2px solid var(--color-smoke); - box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-blood); + position: relative; + border-radius: var(--radius-soft); + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%), + var(--color-panel); + border: 1px solid var(--color-smoke); + box-shadow: + 0 0 0 1px rgba(185, 167, 255, 0.06), + 0 18px 50px -24px rgba(185, 167, 255, 0.45); } .panel-acid { - box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid); + box-shadow: + 0 0 0 1px rgba(154, 247, 216, 0.12), + 0 18px 50px -22px rgba(154, 247, 216, 0.45); } .label { @@ -126,25 +141,46 @@ color: var(--color-mute); } + /* Ethereal gilt serif italic — the consumerist gospel voice. */ + .gospel { + font-family: var(--font-gospel); + font-style: italic; + font-weight: 300; + background: linear-gradient( + 100deg, + var(--color-gold), + var(--color-rose), + var(--color-iris) + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + .field { width: 100%; - background: var(--color-void); - border: 2px solid var(--color-smoke); + border-radius: var(--radius-soft); + background: rgba(12, 10, 20, 0.7); + border: 1px solid var(--color-smoke); color: var(--color-ink); padding: 0.7rem 0.8rem; font-family: var(--font-mono); outline: none; - transition: border-color 0.1s ease, box-shadow 0.1s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .field::placeholder { - color: #55555e; + color: #5d5474; } .field:focus { - border-color: var(--color-acid); - box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid); + border-color: var(--color-iris); + box-shadow: + 0 0 0 1px var(--color-iris), + 0 0 24px -6px var(--color-iris); } - /* Chunky brutalist button. */ + /* Button: soft glow, gentle lift. Halo instead of hard shadow. */ .btn { display: inline-flex; align-items: center; @@ -155,33 +191,45 @@ text-transform: uppercase; letter-spacing: 0.05em; padding: 0.7rem 1.2rem; - border: 2px solid var(--color-ink); + border-radius: var(--radius-soft); + border: 1px solid var(--color-ink); background: var(--color-ink); color: var(--color-void); cursor: pointer; - transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.1s; - box-shadow: 4px 4px 0 0 var(--color-blood); + transition: + transform 0.1s ease, + box-shadow 0.15s ease, + filter 0.15s; + box-shadow: 0 8px 24px -10px rgba(255, 174, 203, 0.7); } .btn:hover { - transform: translate(-2px, -2px); - box-shadow: 6px 6px 0 0 var(--color-blood); + transform: translateY(-2px); + box-shadow: 0 14px 34px -10px rgba(255, 174, 203, 0.85); } .btn:active { - transform: translate(2px, 2px); - box-shadow: 1px 1px 0 0 var(--color-blood); + transform: translateY(0); + box-shadow: 0 6px 16px -10px rgba(255, 174, 203, 0.7); } + /* Primary "ascend" button — holographic gradient. */ .btn-acid { - background: var(--color-acid); - border-color: var(--color-acid); - box-shadow: 4px 4px 0 0 var(--color-void); + border: none; + color: var(--color-void); + background: linear-gradient( + 110deg, + var(--color-iris), + var(--color-rose) 55%, + var(--color-gold) + ); + box-shadow: 0 10px 30px -8px rgba(185, 167, 255, 0.8); } .btn-acid:hover { - box-shadow: 6px 6px 0 0 var(--color-void); + box-shadow: 0 16px 40px -8px rgba(185, 167, 255, 0.95); } .btn-ghost { background: transparent; color: var(--color-ink); - box-shadow: 4px 4px 0 0 var(--color-smoke); + border-color: var(--color-smoke); + box-shadow: 0 8px 24px -14px rgba(185, 167, 255, 0.6); } .btn:disabled { opacity: 0.4; @@ -196,9 +244,10 @@ text-transform: uppercase; padding: 0.15rem 0.5rem; border: 1px solid currentColor; + border-radius: 999px; } - /* Glitchy duplicated-layer heading. */ + /* Chromatic aura heading — pastel iris/rose ghosts drift behind the text. */ .glitch { position: relative; color: var(--color-ink); @@ -209,44 +258,71 @@ position: absolute; inset: 0; pointer-events: none; + filter: blur(0.5px); } .glitch::before { - color: var(--color-blood); - transform: translate(-2px, 0); + color: var(--color-iris); + transform: translate(-1.5px, 0); mix-blend-mode: screen; - clip-path: inset(0 0 55% 0); - animation: glitch-x 3.5s infinite steps(2); + opacity: 0.7; + animation: aura 5s ease-in-out infinite; } .glitch::after { - color: var(--color-cyber); - transform: translate(2px, 0); + color: var(--color-rose); + transform: translate(1.5px, 0); mix-blend-mode: screen; - clip-path: inset(55% 0 0 0); - animation: glitch-x 2.7s infinite steps(2) reverse; + opacity: 0.7; + animation: aura 5s ease-in-out infinite reverse; } } -@keyframes glitch-x { - 0%, 92%, 100% { transform: translate(0, 0); } - 93% { transform: translate(-3px, 1px); } - 96% { transform: translate(3px, -1px); } +@keyframes aura { + 0%, + 100% { + transform: translate(-1.5px, 0); + } + 50% { + transform: translate(1.5px, 0.5px); + } +} + +@keyframes drift { + from { + transform: translate(0, 0) scale(1); + } + to { + transform: translate(2%, -2%) scale(1.08); + } } .marquee { white-space: nowrap; - animation: marquee 22s linear infinite; + animation: marquee 28s linear infinite; } @keyframes marquee { - from { transform: translateX(0); } - to { transform: translateX(-50%); } + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } } .flicker { - animation: flicker 4s infinite; + animation: flicker 6s infinite; } @keyframes flicker { - 0%, 100% { opacity: 1; } - 97% { opacity: 1; } - 98% { opacity: 0.4; } - 99% { opacity: 0.9; } + 0%, + 100% { + opacity: 1; + } + 97% { + opacity: 1; + } + 98% { + opacity: 0.6; + } + 99% { + opacity: 0.95; + } } diff --git a/frontend/src/app.html b/frontend/src/app.html index b859b5b..70f7f4c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,11 +4,11 @@ - + %sveltekit.head% diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fbf0f5b..cb81c49 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,6 @@ -import { env } from '$env/dynamic/public'; +import { env } from "$env/dynamic/public"; -const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080'; +const BASE = env.PUBLIC_API_BASE || "http://localhost:8080"; export class ApiError extends Error { status: number; @@ -12,9 +12,9 @@ export class ApiError extends Error { async function request(path: string, opts: RequestInit = {}): Promise { const res = await fetch(`${BASE}/api${path}`, { - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) }, - ...opts + credentials: "include", + headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) }, + ...opts, }); if (res.status === 204) return undefined as T; @@ -31,7 +31,14 @@ async function request(path: string, opts: RequestInit = {}): Promise { export const api = { get: (p: string) => request(p), post: (p: string, body?: unknown) => - request(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + request(p, { + method: "POST", + body: body ? JSON.stringify(body) : undefined, + }), patch: (p: string, body?: unknown) => - request(p, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }) + request(p, { + method: "PATCH", + body: body ? JSON.stringify(body) : undefined, + }), + del: (p: string) => request(p, { method: "DELETE" }), }; diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index cb2700d..c21c7d9 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api } from "./api"; export type User = { id: string; @@ -23,7 +23,7 @@ class AuthStore { async refresh() { try { - const me = await api.get('/auth/me'); + const me = await api.get("/auth/me"); this.user = me.user; this.settings = me.settings; } catch { @@ -42,7 +42,7 @@ class AuthStore { async logout() { try { - await api.post('/auth/logout'); + await api.post("/auth/logout"); } finally { this.user = null; this.settings = null; diff --git a/frontend/src/lib/lists.svelte.ts b/frontend/src/lib/lists.svelte.ts new file mode 100644 index 0000000..dde12b2 --- /dev/null +++ b/frontend/src/lib/lists.svelte.ts @@ -0,0 +1,101 @@ +import { api } from "./api"; + +export type ItemStatus = "coveted" | "acquired" | "renounced"; + +export type List = { + id: string; + name: string; + emoji: string | null; + description: string | null; + position: number; + created_at: string; + updated_at: string; +}; + +export type Item = { + id: string; + list_id: string; + title: string; + url: string | null; + note: string | null; + status: ItemStatus; + target_price: number | null; + position: number; + // Filled by the Phase 3 fetcher; null until then. + title_fetched: string | null; + current_price: number | null; + currency: string | null; + image_url: string | null; + in_stock: boolean | null; + source: string | null; + fetched_at: string | null; + track_enabled: boolean; + last_error: string | null; + checked_at: string | null; + created_at: string; + updated_at: string; +}; + +export type PricePoint = { + price: number; + currency: string; + in_stock: boolean | null; + fetched_at: string; +}; + +export type NewList = { + name: string; + emoji?: string | null; + description?: string | null; +}; +export type NewItem = { + title: string; + url?: string | null; + note?: string | null; + target_price?: number | null; +}; + +// ---- Lists ---------------------------------------------------------------- + +export const listsApi = { + all: () => api.get("/lists"), + create: (b: NewList) => api.post("/lists", b), + update: (id: string, b: Partial & { position?: number }) => + api.patch(`/lists/${id}`, b), + remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`), + + items: (listId: string) => api.get(`/lists/${listId}/items`), + addItem: (listId: string, b: NewItem) => + api.post(`/lists/${listId}/items`, b), + updateItem: ( + id: string, + b: Partial & { status?: ItemStatus; position?: number }, + ) => api.patch(`/items/${id}`, b), + removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`), + refetch: (id: string) => api.post(`/items/${id}/refetch`, {}), + history: (id: string) => api.get(`/items/${id}/history`), +}; + +/** Reactive store for the user's lists. */ +class ListsStore { + items = $state([]); + loaded = $state(false); + + async load() { + this.items = await listsApi.all(); + this.loaded = true; + } + + async create(b: NewList): Promise { + const created = await listsApi.create(b); + this.items.push(created); + return created; + } + + async remove(id: string) { + await listsApi.remove(id); + this.items = this.items.filter((l) => l.id !== id); + } +} + +export const lists = new ListsStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b84ee64..ef6ca78 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -17,12 +17,15 @@ } const ticker = - 'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · '; + 'CONSUME · ASCEND · ACCUMULATE · YOU DESERVE IT · MANIFEST THE DEBT · TREAT YOURSELF · ONE MORE WON’T HURT · ';
-
+
{ticker.repeat(6)}
@@ -34,15 +37,16 @@ - //WANTLIST + consume·rs