diff --git a/anon/backend/.sqlx/query-0c954164fef59ae1bf70c5ddee143efbf0bb07d21902f3c4f6c937b07dce2113.json b/anon/backend/.sqlx/query-0c954164fef59ae1bf70c5ddee143efbf0bb07d21902f3c4f6c937b07dce2113.json new file mode 100644 index 00000000..b5a05f6f --- /dev/null +++ b/anon/backend/.sqlx/query-0c954164fef59ae1bf70c5ddee143efbf0bb07d21902f3c4f6c937b07dce2113.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "ALTER SEQUENCE reviews_id_seq RESTART WITH 1", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "0c954164fef59ae1bf70c5ddee143efbf0bb07d21902f3c4f6c937b07dce2113" +} diff --git a/anon/backend/.sqlx/query-3c4225f5eac4b0d35fa57fef825924a682df1f33aef5a7544176a502ee9220fb.json b/anon/backend/.sqlx/query-3c4225f5eac4b0d35fa57fef825924a682df1f33aef5a7544176a502ee9220fb.json new file mode 100644 index 00000000..413d5b88 --- /dev/null +++ b/anon/backend/.sqlx/query-3c4225f5eac4b0d35fa57fef825924a682df1f33aef5a7544176a502ee9220fb.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO reviews (company, tag, sentiment, body, created_at, status, deleted_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Numeric", + "Text", + "Timestamptz", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "3c4225f5eac4b0d35fa57fef825924a682df1f33aef5a7544176a502ee9220fb" +} diff --git a/anon/backend/.sqlx/query-4b962e3c789b193e264dd3255f3234b099b1e3576da68a59627ff8836d838e0f.json b/anon/backend/.sqlx/query-4b962e3c789b193e264dd3255f3234b099b1e3576da68a59627ff8836d838e0f.json new file mode 100644 index 00000000..4144e5ed --- /dev/null +++ b/anon/backend/.sqlx/query-4b962e3c789b193e264dd3255f3234b099b1e3576da68a59627ff8836d838e0f.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO reviews (id, company, tag, sentiment, body, created_at, status)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Numeric", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4b962e3c789b193e264dd3255f3234b099b1e3576da68a59627ff8836d838e0f" +} diff --git a/anon/backend/.sqlx/query-9423218d730df76bd6e528ff9319a9514b0304627ca03c530fde328e7d19922d.json b/anon/backend/.sqlx/query-9423218d730df76bd6e528ff9319a9514b0304627ca03c530fde328e7d19922d.json new file mode 100644 index 00000000..7de6ca55 --- /dev/null +++ b/anon/backend/.sqlx/query-9423218d730df76bd6e528ff9319a9514b0304627ca03c530fde328e7d19922d.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM reviews", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "9423218d730df76bd6e528ff9319a9514b0304627ca03c530fde328e7d19922d" +} diff --git a/anon/backend/.sqlx/query-cb0edcad5892f98618b03addb64f95e56682ba86e4160dc9905332312351d99b.json b/anon/backend/.sqlx/query-cb0edcad5892f98618b03addb64f95e56682ba86e4160dc9905332312351d99b.json new file mode 100644 index 00000000..a3ce8747 --- /dev/null +++ b/anon/backend/.sqlx/query-cb0edcad5892f98618b03addb64f95e56682ba86e4160dc9905332312351d99b.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO reviews (company, tag, sentiment, body, created_at, status)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Numeric", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cb0edcad5892f98618b03addb64f95e56682ba86e4160dc9905332312351d99b" +} diff --git a/anon/backend/Cargo.lock b/anon/backend/Cargo.lock index 5f3fc661..3ae316a6 100644 --- a/anon/backend/Cargo.lock +++ b/anon/backend/Cargo.lock @@ -43,6 +43,19 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -266,13 +279,18 @@ dependencies = [ name = "backend" version = "0.1.0" dependencies = [ + "ammonia", + "anyhow", "async-trait", "axum", "axum-test", "base64 0.22.1", + "bigdecimal", "chrono", "dotenvy", "jsonwebtoken", + "maplit", + "once_cell", "rand 0.8.5", "serde", "serde_json", @@ -336,6 +354,20 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bigdecimal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "bitflags" version = "2.9.3" @@ -688,6 +720,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "ctr" version = "0.9.2" @@ -810,6 +865,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1059,6 +1129,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -1273,6 +1353,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.3.1" @@ -1787,6 +1878,40 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1871,6 +1996,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2140,6 +2271,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -2223,6 +2406,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2988,6 +3177,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "size-of" version = "0.1.5" @@ -3091,6 +3286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", + "bigdecimal", "bytes", "chrono", "crc", @@ -3166,6 +3362,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags", "byteorder", "bytes", @@ -3209,6 +3406,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags", "byteorder", "chrono", @@ -3226,6 +3424,7 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand 0.8.5", "serde", @@ -3473,6 +3672,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3546,6 +3770,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "terminal_size" version = "0.4.3" @@ -4010,6 +4245,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4241,6 +4482,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.2" diff --git a/anon/backend/Cargo.toml b/anon/backend/Cargo.toml index 97774d14..04971eb3 100644 --- a/anon/backend/Cargo.toml +++ b/anon/backend/Cargo.toml @@ -13,7 +13,7 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] } dotenvy = "0.15.7" thiserror = "2.0.16" -sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "macros", "migrate", "chrono", "json"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "macros", "migrate", "chrono", "json", "bigdecimal"] } starknet = "0.15.1" utoipa = { version = "5.4.0", features = ["axum_extras", "chrono"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } @@ -22,6 +22,10 @@ jsonwebtoken = "9.3.1" async-trait = "0.1.89" base64 = "0.22.1" rand = "0.8.5" +ammonia = "4.0.0" +once_cell = "1.19.0" +maplit = "1.0.2" +bigdecimal = { version = "0.4.8", features = ["serde"] } [dev-dependencies] @@ -29,3 +33,5 @@ sqlx-cli = { version = "0.8.2", features = ["native-tls", "postgres"] } axum-test = "18.0.0" tower = { version = "0.4", features = ["util"] } serde_json = "1.0.143" +anyhow = "1.0.99" +bigdecimal = "0.4.8" diff --git a/anon/backend/migrations/0005__review_status.sql b/anon/backend/migrations/0005__review_status.sql new file mode 100644 index 00000000..48edad43 --- /dev/null +++ b/anon/backend/migrations/0005__review_status.sql @@ -0,0 +1,11 @@ +-- Add status field to reviews table +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'published'; +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Index for status filtering +CREATE INDEX IF NOT EXISTS idx_reviews_status_created_at_id_desc + ON reviews (status, created_at DESC, id DESC); + +-- Combined index for company + status filtering +CREATE INDEX IF NOT EXISTS idx_reviews_company_status_created_at_id_desc + ON reviews (company, status, created_at DESC, id DESC); diff --git a/anon/backend/src/lib.rs b/anon/backend/src/lib.rs index 1a748d53..d514afe2 100644 --- a/anon/backend/src/lib.rs +++ b/anon/backend/src/lib.rs @@ -6,6 +6,7 @@ pub mod libs { pub mod jwt; pub mod logging; pub mod pagination; + pub mod sanitize; pub mod wallet; } @@ -55,6 +56,11 @@ pub fn create_app(state: AppState) -> Router { get(routes::generate::list_generated_contracts), ) .route("/reviews", get(routes::reviews::list_reviews)) + .route("/posts/{id}", get(routes::reviews::get_review_by_id)) + .route( + "/companies/{slug}/posts", + get(routes::reviews::list_company_reviews), + ) .route("/health", get(routes::health::health)) // Swagger UI at /docs and OpenAPI JSON at /api-docs/openapi.json .merge(SwaggerUi::new("/docs").url( diff --git a/anon/backend/src/libs/apispec.rs b/anon/backend/src/libs/apispec.rs index 1f7ce183..84be22f3 100644 --- a/anon/backend/src/libs/apispec.rs +++ b/anon/backend/src/libs/apispec.rs @@ -28,7 +28,9 @@ impl Modify for SecurityAddon { crate::routes::health::healthz, crate::routes::generate::generate_contract, crate::routes::generate::list_generated_contracts, - crate::routes::reviews::list_reviews + crate::routes::reviews::list_reviews, + crate::routes::reviews::get_review_by_id, + crate::routes::reviews::list_company_reviews ), components( schemas( @@ -43,9 +45,12 @@ impl Modify for SecurityAddon { crate::routes::generate::GenerateContractRes, crate::routes::generate::GeneratedContractItem, crate::routes::generate::GeneratedContractsListRes, + crate::routes::generate::GeneratedContractsQuery, // Reviews crate::routes::reviews::ReviewItem, - crate::routes::reviews::ReviewsListRes + crate::routes::reviews::ReviewsListRes, + crate::routes::reviews::ReviewsQuery, + crate::routes::reviews::CompanyReviewsQuery ) ), modifiers(&SecurityAddon), diff --git a/anon/backend/src/libs/error.rs b/anon/backend/src/libs/error.rs index 6f770ee5..d4969900 100644 --- a/anon/backend/src/libs/error.rs +++ b/anon/backend/src/libs/error.rs @@ -13,6 +13,7 @@ pub enum ApiError { Conflict(&'static str), NotFound(&'static str), Internal(&'static str), + Custom(StatusCode, String), } #[derive(Serialize, ToSchema)] @@ -58,6 +59,9 @@ impl IntoResponse for ApiError { }), ) .into_response(), + ApiError::Custom(status, msg) => { + (status, Json(ErrorBody { error: msg })).into_response() + } } } } diff --git a/anon/backend/src/libs/mod.rs b/anon/backend/src/libs/mod.rs new file mode 100644 index 00000000..aced3394 --- /dev/null +++ b/anon/backend/src/libs/mod.rs @@ -0,0 +1,4 @@ +pub mod db; +pub mod error; +pub mod pagination; +pub mod sanitize; diff --git a/anon/backend/src/libs/sanitize.rs b/anon/backend/src/libs/sanitize.rs new file mode 100644 index 00000000..1249c419 --- /dev/null +++ b/anon/backend/src/libs/sanitize.rs @@ -0,0 +1,33 @@ +use ammonia::Builder; +use maplit::hashset; +use once_cell::sync::Lazy; + +static SANITIZER: Lazy> = Lazy::new(|| { + let mut builder = Builder::new(); + // Allow only basic text formatting tags + builder.tags(hashset!["p", "br", "b", "i", "em", "strong"]); + // Remove all attributes + builder.link_rel(None); + builder.add_generic_attributes::<&str, _>(hashset![]); + builder +}); + +pub fn sanitize_text(text: &str) -> String { + SANITIZER.clean(text).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_text() { + let input = + r#"

Hello

"#; + let expected = "

Hello

"; + assert_eq!(sanitize_text(input), expected); + + let input = r#"

This is bold and italic

"#; + assert_eq!(sanitize_text(input), input); + } +} diff --git a/anon/backend/src/main.rs b/anon/backend/src/main.rs index 64df9e8f..6708c4ac 100644 --- a/anon/backend/src/main.rs +++ b/anon/backend/src/main.rs @@ -56,6 +56,11 @@ async fn main() { get(routes::generate::list_generated_contracts), ) .route("/reviews", get(routes::reviews::list_reviews)) + .route("/posts/:id", get(routes::reviews::get_review_by_id)) + .route( + "/companies/:slug/posts", + get(routes::reviews::list_company_reviews), + ) // Swagger UI at /docs and OpenAPI JSON at /api-docs/openapi.json .merge(SwaggerUi::new("/docs").url( "/api-docs/openapi.json", diff --git a/anon/backend/src/routes/reviews.rs b/anon/backend/src/routes/reviews.rs index 41ed5f18..e47a9097 100644 --- a/anon/backend/src/routes/reviews.rs +++ b/anon/backend/src/routes/reviews.rs @@ -1,12 +1,13 @@ use axum::{ Json, - extract::{Query, State}, + extract::{Path, Query, State}, + http::StatusCode, }; use serde::{Deserialize, Serialize}; use sqlx::Arguments; use utoipa::ToSchema; -use crate::libs::{db::AppState, error::ApiError}; +use crate::libs::{db::AppState, error::ApiError, sanitize::sanitize_text}; #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] pub struct ReviewsQuery { @@ -19,14 +20,27 @@ pub struct ReviewsQuery { pub limit: Option, } +#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] +pub struct CompanyReviewsQuery { + pub tag: Option, + pub since: Option>, // inclusive + pub until: Option>, // exclusive + pub cursor: Option, + pub limit: Option, + pub status: Option, +} + #[derive(Debug, Serialize, ToSchema)] pub struct ReviewItem { pub id: i64, pub company: String, pub tag: Option, - pub sentiment: f32, + #[schema(value_type = f32)] + pub sentiment: bigdecimal::BigDecimal, pub body: String, pub created_at: chrono::DateTime, + pub status: String, + pub deleted_at: Option>, } #[derive(Debug, Serialize, ToSchema)] @@ -39,9 +53,11 @@ type ReviewRow = ( i64, String, Option, - f32, + bigdecimal::BigDecimal, String, chrono::DateTime, + String, + Option>, ); #[utoipa::path( @@ -71,7 +87,7 @@ pub async fn list_reviews( // We keep ordering stable by (created_at DESC, id DESC) // Cursor condition: (created_at, id) < (cursor.created_at, cursor.id) let mut sql = String::from( - r#"SELECT id, company, tag, sentiment, body, created_at + r#"SELECT id, company, tag, sentiment, body, created_at, status, deleted_at FROM reviews WHERE 1=1"#, ); @@ -135,18 +151,204 @@ pub async fn list_reviews( let rows: Vec = sqlx::query_as_with(&sql, args) .fetch_all(&pool) .await - .map_err(|e| crate::libs::error::map_sqlx_error(&e))?; + .map_err(|e| { + eprintln!("Database error: {:?}", e); + crate::libs::error::map_sqlx_error(&e) + })?; let items: Vec = rows .into_iter() .map( - |(id, company, tag, sentiment, body, created_at)| ReviewItem { + |(id, company, tag, sentiment, body, created_at, status, deleted_at)| ReviewItem { + id, + company: company.to_string(), + tag, + sentiment, + body: sanitize_text(&body), + created_at, + status: status.to_string(), + deleted_at, + }, + ) + .collect(); + + let next_cursor = items.last().map(|last| { + let c = crate::libs::pagination::ReviewsCursor { + created_at: last.created_at, + id: last.id, + }; + crate::libs::pagination::encode_cursor(&c) + }); + + Ok(Json(ReviewsListRes { items, next_cursor })) +} + +#[utoipa::path( + get, + path = "/posts/{id}", + tag = "reviews", + params( + ("id" = i64, Path, description = "Review ID") + ), + responses( + (status = 200, description = "Get review by ID", body = ReviewItem), + (status = 404, description = "Review not found", body = crate::libs::error::ErrorBody), + (status = 410, description = "Review was deleted", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn get_review_by_id( + State(AppState { pool }): State, + Path(id): Path, +) -> Result, ApiError> { + let sql = r#" + SELECT id, company, tag, sentiment, body, created_at, status, deleted_at + FROM reviews + WHERE id = $1"#; + + let row: Option = sqlx::query_as(sql) + .bind(id) + .fetch_optional(&pool) + .await + .map_err(|e| { + eprintln!("Database error: {:?}", e); + crate::libs::error::map_sqlx_error(&e) + })?; + + match row { + Some((id, company, tag, sentiment, body, created_at, status, deleted_at)) => { + if deleted_at.is_some() { + return Err(ApiError::Custom( + StatusCode::GONE, + "Review was deleted".into(), + )); + } + Ok(Json(ReviewItem { id, company, tag, sentiment, - body, + body: sanitize_text(&body), + created_at, + status, + deleted_at, + })) + } + None => Err(ApiError::Custom( + StatusCode::NOT_FOUND, + "Review not found".into(), + )), + } +} + +#[utoipa::path( + get, + path = "/companies/{slug}/posts", + tag = "reviews", + params( + ("slug" = String, Path, description = "Company slug"), + CompanyReviewsQuery + ), + responses( + (status = 200, description = "List company reviews", body = ReviewsListRes), + (status = 400, description = "Bad request", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn list_company_reviews( + State(AppState { pool }): State, + Path(company_slug): Path, + Query(q): Query, +) -> Result, ApiError> { + let limit = q.limit.unwrap_or(20).clamp(1, 50); + + // Decode cursor if provided + let cursor = match q.cursor.as_deref() { + Some(s) => crate::libs::pagination::decode_cursor(s), + None => None, + }; + + // Build dynamic SQL with parameters + // We keep ordering stable by (created_at DESC, id DESC) + // Cursor condition: (created_at, id) < (cursor.created_at, cursor.id) + let mut sql = String::from( + r#"SELECT id, company, tag, sentiment, body, created_at, status, deleted_at + FROM reviews + WHERE company = $1 AND deleted_at IS NULL"#, + ); + let mut args: sqlx::postgres::PgArguments = sqlx::postgres::PgArguments::default(); + args.add(&company_slug) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add company arg"))?; + let mut i: i32 = 2; + + if let Some(tag) = &q.tag { + sql.push_str(&format!(" AND tag = ${}", i)); + args.add(tag) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add tag arg"))?; + i += 1; + } + if let Some(since) = &q.since { + sql.push_str(&format!(" AND created_at >= ${}", i)); + args.add(since) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add since arg"))?; + i += 1; + } + if let Some(until) = &q.until { + sql.push_str(&format!(" AND created_at < ${}", i)); + args.add(until) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add until arg"))?; + i += 1; + } + if let Some(status) = &q.status { + sql.push_str(&format!(" AND status = ${}", i)); + args.add(status) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add status arg"))?; + i += 1; + } + + if let Some(c) = &cursor { + // (created_at, id) < (c.created_at, c.id) in DESC order means + // created_at < c.created_at OR (created_at = c.created_at AND id < c.id) + sql.push_str(&format!( + " AND (created_at < ${} OR (created_at = ${} AND id < ${}))", + i, + i + 1, + i + 2 + )); + args.add(&c.created_at) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add created_at arg"))?; + args.add(&c.created_at) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add created_at arg"))?; + args.add(&c.id) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add id arg"))?; + i += 3; + } + + sql.push_str(" ORDER BY created_at DESC, id DESC"); + sql.push_str(&format!(" LIMIT ${}", i)); + args.add(&limit) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add limit arg"))?; + + let rows: Vec = sqlx::query_as_with(&sql, args) + .fetch_all(&pool) + .await + .map_err(|e| { + eprintln!("Database error: {:?}", e); + crate::libs::error::map_sqlx_error(&e) + })?; + + let items: Vec = rows + .into_iter() + .map( + |(id, company, tag, sentiment, body, created_at, status, deleted_at)| ReviewItem { + id, + company: company.to_string(), + tag, + sentiment, + body: sanitize_text(&body), created_at, + status: status.to_string(), + deleted_at, }, ) .collect(); diff --git a/anon/backend/tests/reviews_test.rs b/anon/backend/tests/reviews_test.rs new file mode 100644 index 00000000..1802b9c6 --- /dev/null +++ b/anon/backend/tests/reviews_test.rs @@ -0,0 +1,211 @@ +use axum::http::StatusCode; +use axum_test::TestServer; +use chrono::Utc; +use serde_json::json; +use sqlx::PgPool; +use std::env; + +use backend::libs::db::AppState; + +fn get_test_db_url() -> String { + env::var("TEST_DATABASE_URL").unwrap_or_else(|_| { + "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable".to_string() + }) +} + +async fn setup_test_db(pool: &PgPool) -> anyhow::Result<()> { + // Clean up any existing data + sqlx::query!("DELETE FROM reviews").execute(pool).await?; + + // Reset sequence + sqlx::query!("ALTER SEQUENCE reviews_id_seq RESTART WITH 1") + .execute(pool) + .await?; + + // Insert test data + let now = Utc::now(); + let company = "test-company"; + + // Published review + sqlx::query!( + r#"INSERT INTO reviews (company, tag, sentiment, body, created_at, status) + VALUES ($1, $2, $3, $4, $5, $6)"#, + company, + Some("tag1"), + "0.8".parse::().unwrap(), + "Test review 1", + now, + "published" + ) + .execute(pool) + .await?; + + // Deleted review + sqlx::query!( + r#"INSERT INTO reviews (company, tag, sentiment, body, created_at, status, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)"#, + company, + Some("tag2"), + "0.6".parse::().unwrap(), + "Test review 2", + now, + "published", + Some(now) + ) + .execute(pool) + .await?; + + // Draft review + sqlx::query!( + r#"INSERT INTO reviews (company, tag, sentiment, body, created_at, status) + VALUES ($1, $2, $3, $4, $5, $6)"#, + company, + Some("tag1"), + "0.7".parse::().unwrap(), + "Test review 3", + now, + "draft" + ) + .execute(pool) + .await?; + + // Another company's review + sqlx::query!( + r#"INSERT INTO reviews (company, tag, sentiment, body, created_at, status) + VALUES ($1, $2, $3, $4, $5, $6)"#, + "other-company", + Some("tag1"), + "0.9".parse::().unwrap(), + "Test review 4", + now, + "published" + ) + .execute(pool) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_get_review_by_id() -> anyhow::Result<()> { + let pool = sqlx::PgPool::connect(&get_test_db_url()).await?; + setup_test_db(&pool).await?; + + let app = backend::create_app(AppState { pool }); + let server = TestServer::new(app)?; + + // Test getting a published review + let response = server.get("/posts/1").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["id"], json!(1)); + assert_eq!(body["company"], json!("test-company")); + assert_eq!(body["status"], json!("published")); + + // Test getting a deleted review + let response = server.get("/posts/2").await; + assert_eq!(response.status_code(), StatusCode::GONE); + + // Test getting a non-existent review + let response = server.get("/posts/999").await; + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); + + Ok(()) +} + +#[tokio::test] +async fn test_list_company_reviews() -> anyhow::Result<()> { + let pool = sqlx::PgPool::connect(&get_test_db_url()).await?; + setup_test_db(&pool).await?; + + let app = backend::create_app(AppState { pool }); + let server = TestServer::new(app)?; + + // Test listing published reviews for a company + let response = server.get("/companies/test-company/posts").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); // Published and draft reviews for test-company (excluding deleted) + assert!(items.iter().any(|item| item["id"] == json!(1))); // Check review 1 exists in results + + // Test with status filter + let response = server + .get("/companies/test-company/posts?status=draft") + .await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); // Only draft review + assert_eq!(items[0]["id"], json!(3)); + + // Test with tag filter + let response = server.get("/companies/test-company/posts?tag=tag1").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); // Reviews with tag1 (excluding deleted) + // Check that we have the expected reviews (published and draft with tag1) + let ids: Vec = items + .iter() + .map(|item| item["id"].as_i64().unwrap()) + .collect(); + assert!(ids.contains(&1)); // Published review with tag1 + assert!(ids.contains(&3)); // Draft review with tag1 + + // Test with non-existent company + let response = server.get("/companies/non-existent/posts").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 0); + + Ok(()) +} + +#[tokio::test] +async fn test_text_sanitization() -> anyhow::Result<()> { + let pool = sqlx::PgPool::connect(&get_test_db_url()).await?; + + // Clean up any existing data + sqlx::query!("DELETE FROM reviews").execute(&pool).await?; + + // Insert a review with HTML content + let now = Utc::now(); + sqlx::query!( + r#"INSERT INTO reviews (id, company, tag, sentiment, body, created_at, status) + VALUES ($1, $2, $3, $4, $5, $6, $7)"#, + 10000, + "test-company", + Some("tag1"), + "0.8".parse::().unwrap(), + r#"

This is bold and

"#, + now, + "published" + ) + .execute(&pool) + .await?; + + let app = backend::create_app(AppState { pool }); + let server = TestServer::new(app)?; + + // Test that HTML is properly sanitized in both endpoints + let response = server.get("/posts/10000").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!( + body["body"], + json!("

This is bold and

") + ); + + let response = server.get("/companies/test-company/posts").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: serde_json::Value = response.json(); + let items = body["items"].as_array().unwrap(); + assert_eq!( + items[0]["body"], + json!("

This is bold and

") + ); + + Ok(()) +}